From c4850505b011af6313e4c5dea561e4bdafb44629 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 7 Sep 2023 09:08:55 +0300 Subject: [PATCH] New: Add Plex Media Server notifications --- .../Plex/PlexAuthenticationException.cs | 15 ++ .../Notifications/Plex/PlexException.cs | 23 ++ .../Plex/PlexTv/PlexTvPinResponse.cs | 9 + .../Plex/PlexTv/PlexTvPinUrlResponse.cs | 11 + .../Notifications/Plex/PlexTv/PlexTvProxy.cs | 102 +++++++++ .../Plex/PlexTv/PlexTvService.cs | 95 ++++++++ .../Plex/PlexTv/PlexTvSignInUrlResponse.cs | 8 + .../Plex/PlexVersionException.cs | 17 ++ .../Notifications/Plex/Server/PlexError.cs | 7 + .../Notifications/Plex/Server/PlexIdentity.cs | 8 + .../Plex/Server/PlexPreferences.cs | 24 +++ .../Notifications/Plex/Server/PlexResponse.cs | 7 + .../Notifications/Plex/Server/PlexSection.cs | 57 +++++ .../Plex/Server/PlexSectionItem.cs | 37 ++++ .../Notifications/Plex/Server/PlexServer.cs | 202 ++++++++++++++++++ .../Plex/Server/PlexServerProxy.cs | 173 +++++++++++++++ .../Plex/Server/PlexServerService.cs | 182 ++++++++++++++++ .../Plex/Server/PlexServerSettings.cs | 62 ++++++ 18 files changed, 1039 insertions(+) create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexAuthenticationException.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexException.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexVersionException.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexAuthenticationException.cs b/src/NzbDrone.Core/Notifications/Plex/PlexAuthenticationException.cs new file mode 100644 index 000000000..4235168b4 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexAuthenticationException.cs @@ -0,0 +1,15 @@ +namespace NzbDrone.Core.Notifications.Plex +{ + public class PlexAuthenticationException : PlexException + { + public PlexAuthenticationException(string message) + : base(message) + { + } + + public PlexAuthenticationException(string message, params object[] args) + : base(message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexException.cs b/src/NzbDrone.Core/Notifications/Plex/PlexException.cs new file mode 100644 index 000000000..c545c20f5 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexException.cs @@ -0,0 +1,23 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Plex +{ + public class PlexException : NzbDroneException + { + public PlexException(string message) + : base(message) + { + } + + public PlexException(string message, params object[] args) + : base(message, args) + { + } + + public PlexException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs new file mode 100644 index 000000000..aa46edb48 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinResponse.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvPinResponse + { + public int Id { get; set; } + public string Code { get; set; } + public string AuthToken { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs new file mode 100644 index 000000000..4dace5645 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvPinUrlResponse + { + public string Url { get; set; } + public string Method => "POST"; + public Dictionary Headers { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs new file mode 100644 index 000000000..39432f793 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs @@ -0,0 +1,102 @@ +using System; +using System.Net; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public interface IPlexTvProxy + { + string GetAuthToken(string clientIdentifier, int pinId); + bool Ping(string clientIdentifier, string authToken); + } + + public class PlexTvProxy : IPlexTvProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public PlexTvProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public string GetAuthToken(string clientIdentifier, int pinId) + { + var request = BuildRequest(clientIdentifier); + request.ResourceUrl = $"/api/v2/pins/{pinId}"; + + if (!Json.TryDeserialize(ProcessRequest(request), out var response)) + { + response = new PlexTvPinResponse(); + } + + return response.AuthToken; + } + + public bool Ping(string clientIdentifier, string authToken) + { + try + { + // Allows us to tell plex.tv that we're still active and tokens should not be expired. + var request = BuildRequest(clientIdentifier); + + request.ResourceUrl = "/api/v2/ping"; + request.AddQueryParam("X-Plex-Token", authToken); + + ProcessRequest(request); + + return true; + } + catch (Exception e) + { + // Catch all exceptions and log at trace, this information could be interesting in debugging, but expired tokens will be handled elsewhere. + _logger.Trace(e, "Unable to ping plex.tv"); + } + + return false; + } + + private HttpRequestBuilder BuildRequest(string clientIdentifier) + { + var requestBuilder = new HttpRequestBuilder("https://plex.tv") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", clientIdentifier) + .AddQueryParam("X-Plex-Product", BuildInfo.AppName) + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", BuildInfo.AppName) + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()); + + return requestBuilder; + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder) + { + var httpRequest = requestBuilder.Build(); + + HttpResponse response; + + _logger.Debug("Url: {0}", httpRequest.Url); + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + throw new NzbDroneClientException(ex.Response.StatusCode, "Unable to connect to plex.tv"); + } + catch (WebException) + { + throw new NzbDroneClientException(HttpStatusCode.BadRequest, "Unable to connect to plex.tv"); + } + + return response.Content; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs new file mode 100644 index 000000000..861f92c4d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using System.Net.Http; +using NzbDrone.Common.Cache; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public interface IPlexTvService + { + PlexTvPinUrlResponse GetPinUrl(); + PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); + string GetAuthToken(int pinId); + void Ping(string authToken); + } + + public class PlexTvService : IPlexTvService + { + private readonly IPlexTvProxy _proxy; + private readonly IConfigService _configService; + private readonly ICached _cache; + + public PlexTvService(IPlexTvProxy proxy, IConfigService configService, ICacheManager cacheManager) + { + _proxy = proxy; + _configService = configService; + _cache = cacheManager.GetCache(GetType()); + } + + public PlexTvPinUrlResponse GetPinUrl() + { + var clientIdentifier = _configService.PlexClientIdentifier; + + var requestBuilder = new HttpRequestBuilder("https://plex.tv/api/v2/pins") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", clientIdentifier) + .AddQueryParam("X-Plex-Product", BuildInfo.AppName) + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", BuildInfo.AppName) + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()) + .AddQueryParam("strong", true); + + requestBuilder.Method = HttpMethod.Post; + + var request = requestBuilder.Build(); + + return new PlexTvPinUrlResponse + { + Url = request.Url.ToString(), + Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value) + }; + } + + public PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode) + { + var clientIdentifier = _configService.PlexClientIdentifier; + + var requestBuilder = new HttpRequestBuilder("https://app.plex.tv/auth/hashBang") + .AddQueryParam("clientID", clientIdentifier) + .AddQueryParam("forwardUrl", callbackUrl) + .AddQueryParam("code", pinCode) + .AddQueryParam("context[device][product]", BuildInfo.AppName) + .AddQueryParam("context[device][platform]", "Windows") + .AddQueryParam("context[device][platformVersion]", "7") + .AddQueryParam("context[device][version]", BuildInfo.Version.ToString()); + + // #! is stripped out of the URL when building, this works around it. + requestBuilder.Segments.Add("hashBang", "#!"); + + var request = requestBuilder.Build(); + + return new PlexTvSignInUrlResponse + { + OauthUrl = request.Url.ToString(), + PinId = pinId + }; + } + + public string GetAuthToken(int pinId) + { + var authToken = _proxy.GetAuthToken(_configService.PlexClientIdentifier, pinId); + + return authToken; + } + + public void Ping(string authToken) + { + // Ping plex.tv if we haven't done so in the last 24 hours for this auth token. + _cache.Get(authToken, () => _proxy.Ping(_configService.PlexClientIdentifier, authToken), TimeSpan.FromHours(24)); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs new file mode 100644 index 000000000..33bd2a8ff --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvSignInUrlResponse.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvSignInUrlResponse + { + public string OauthUrl { get; set; } + public int PinId { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexVersionException.cs b/src/NzbDrone.Core/Notifications/Plex/PlexVersionException.cs new file mode 100644 index 000000000..42c7889da --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexVersionException.cs @@ -0,0 +1,17 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Plex +{ + public class PlexVersionException : NzbDroneException + { + public PlexVersionException(string message) + : base(message) + { + } + + public PlexVersionException(string message, params object[] args) + : base(message, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs new file mode 100644 index 000000000..3018c080a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexError.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexError + { + public string Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs new file mode 100644 index 000000000..9762421e8 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexIdentity.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexIdentity + { + public string MachineIdentifier { get; set; } + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs new file mode 100644 index 000000000..dc1ebc3a1 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexPreferences.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexPreferences + { + [JsonProperty("Setting")] + public List Preferences { get; set; } + } + + public class PlexPreference + { + public string Id { get; set; } + public string Type { get; set; } + public string Value { get; set; } + } + + public class PlexPreferencesLegacy + { + [JsonProperty("_children")] + public List Preferences { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs new file mode 100644 index 000000000..e053b76f3 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexResponse + { + public T MediaContainer { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs new file mode 100644 index 000000000..2062c4057 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSection.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexSectionLocation + { + public int Id { get; set; } + public string Path { get; set; } + } + + public class PlexSection + { + public PlexSection() + { + Locations = new List(); + } + + [JsonProperty("key")] + public int Id { get; set; } + + public string Type { get; set; } + public string Language { get; set; } + + [JsonProperty("Location")] + public List Locations { get; set; } + } + + public class PlexSectionsContainer + { + public PlexSectionsContainer() + { + Sections = new List(); + } + + [JsonProperty("Directory")] + public List Sections { get; set; } + } + + public class PlexSectionLegacy + { + [JsonProperty("key")] + public int Id { get; set; } + + public string Type { get; set; } + public string Language { get; set; } + + [JsonProperty("_children")] + public List Locations { get; set; } + } + + public class PlexMediaContainerLegacy + { + [JsonProperty("_children")] + public List Sections { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs new file mode 100644 index 000000000..dcb5fe7a9 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexSectionItem + { + [JsonProperty("ratingKey")] + public string Id { get; set; } + + public string Title { get; set; } + public int Year { get; set; } + public string Guid { get; set; } + } + + public class PlexSectionResponse + { + [JsonProperty("Metadata")] + public List Items { get; set; } + + public PlexSectionResponse() + { + Items = new List(); + } + } + + public class PlexSectionResponseLegacy + { + [JsonProperty("_children")] + public List Items { get; set; } + + public PlexSectionResponseLegacy() + { + Items = new List(); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs new file mode 100644 index 000000000..0967a9b1a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Books; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Notifications.Plex.PlexTv; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexServer : NotificationBase + { + private readonly IPlexServerService _plexServerService; + private readonly IPlexTvService _plexTvService; + private readonly Logger _logger; + + private class PlexUpdateQueue + { + public Dictionary Pending { get; } = new (); + public bool Refreshing { get; set; } + } + + private readonly ICached _pendingAuthorsCache; + + public PlexServer(IPlexServerService plexServerService, IPlexTvService plexTvService, ICacheManager cacheManager, Logger logger) + { + _plexServerService = plexServerService; + _plexTvService = plexTvService; + _logger = logger; + + _pendingAuthorsCache = cacheManager.GetRollingCache(GetType(), "pendingAuthors", TimeSpan.FromDays(1)); + } + + public override string Link => "https://www.plex.tv/"; + public override string Name => "Plex Media Server"; + + public override void OnReleaseImport(BookDownloadMessage message) + { + UpdateIfEnabled(message.Author); + } + + public override void OnRename(Author author, List renamedFiles) + { + UpdateIfEnabled(author); + } + + public override void OnBookRetag(BookRetagMessage message) + { + UpdateIfEnabled(message.Author); + } + + public override void OnBookDelete(BookDeleteMessage deleteMessage) + { + if (deleteMessage.DeletedFiles) + { + UpdateIfEnabled(deleteMessage.Book.Author); + } + } + + public override void OnAuthorDelete(AuthorDeleteMessage deleteMessage) + { + if (deleteMessage.DeletedFiles) + { + UpdateIfEnabled(deleteMessage.Author); + } + } + + private void UpdateIfEnabled(Author author) + { + _plexTvService.Ping(Settings.AuthToken); + + if (Settings.UpdateLibrary) + { + _logger.Debug("Scheduling library update for author {0} {1}", author.Id, author.Name); + var queue = _pendingAuthorsCache.Get(Settings.Host, () => new PlexUpdateQueue()); + lock (queue) + { + queue.Pending[author.Id] = author; + } + } + } + + public override void ProcessQueue() + { + var queue = _pendingAuthorsCache.Find(Settings.Host); + + if (queue == null) + { + return; + } + + lock (queue) + { + if (queue.Refreshing) + { + return; + } + + queue.Refreshing = true; + } + + try + { + while (true) + { + List refreshingAuthors; + lock (queue) + { + if (queue.Pending.Empty()) + { + queue.Refreshing = false; + return; + } + + refreshingAuthors = queue.Pending.Values.ToList(); + queue.Pending.Clear(); + } + + if (Settings.UpdateLibrary) + { + _logger.Debug("Performing library update for {0} authors", refreshingAuthors.Count); + _plexServerService.UpdateLibrary(refreshingAuthors, Settings); + } + } + } + catch + { + lock (queue) + { + queue.Refreshing = false; + } + + throw; + } + } + + public override ValidationResult Test() + { + _plexTvService.Ping(Settings.AuthToken); + + var failures = new List(); + + failures.AddIfNotNull(_plexServerService.Test(Settings)); + + return new ValidationResult(failures); + } + + public override object RequestAction(string action, IDictionary query) + { + if (action == "startOAuth") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + return _plexTvService.GetPinUrl(); + } + else if (action == "continueOAuth") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + if (query["callbackUrl"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam callbackUrl invalid."); + } + + if (query["id"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam id invalid."); + } + + if (query["code"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam code invalid."); + } + + return _plexTvService.GetSignInUrl(query["callbackUrl"], Convert.ToInt32(query["id"]), query["code"]); + } + else if (action == "getOAuthToken") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + if (query["pinId"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam pinId invalid."); + } + + var authToken = _plexTvService.GetAuthToken(Convert.ToInt32(query["pinId"])); + + return new + { + authToken + }; + } + + return new { }; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs new file mode 100644 index 000000000..a1be8ce1f --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using NLog; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public interface IPlexServerProxy + { + List GetTvSections(PlexServerSettings settings); + string Version(PlexServerSettings settings); + void Update(int sectionId, string path, PlexServerSettings settings); + } + + public class PlexServerProxy : IPlexServerProxy + { + private readonly IHttpClient _httpClient; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public PlexServerProxy(IHttpClient httpClient, IConfigService configService, Logger logger) + { + _httpClient = httpClient; + _configService = configService; + _logger = logger; + } + + public List GetTvSections(PlexServerSettings settings) + { + var request = BuildRequest("library/sections", HttpMethod.Get, settings); + var response = ProcessRequest(request); + + CheckForError(response); + + if (response.Contains("_children")) + { + return Json.Deserialize(response) + .Sections + .Where(d => d.Type == "artist") + .Select(s => new PlexSection + { + Id = s.Id, + Language = s.Language, + Locations = s.Locations, + Type = s.Type + }) + .ToList(); + } + + return Json.Deserialize>(response) + .MediaContainer + .Sections + .Where(d => d.Type == "artist") + .ToList(); + } + + public void Update(int sectionId, string path, PlexServerSettings settings) + { + var resource = $"library/sections/{sectionId}/refresh"; + var request = BuildRequest(resource, HttpMethod.Get, settings); + + request.AddQueryParam("path", path); + + var response = ProcessRequest(request); + + CheckForError(response); + } + + public string Version(PlexServerSettings settings) + { + var request = BuildRequest("identity", HttpMethod.Get, settings); + var response = ProcessRequest(request); + + CheckForError(response); + + if (response.Contains("_children")) + { + return Json.Deserialize(response) + .Version; + } + + return Json.Deserialize>(response) + .MediaContainer + .Version; + } + + private HttpRequestBuilder BuildRequest(string resource, HttpMethod method, PlexServerSettings settings) + { + var scheme = settings.UseSsl ? "https" : "http"; + + var requestBuilder = new HttpRequestBuilder($"{scheme}://{settings.Host.ToUrlHost()}:{settings.Port}") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", _configService.PlexClientIdentifier) + .AddQueryParam("X-Plex-Product", BuildInfo.AppName) + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", BuildInfo.AppName) + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()); + + if (settings.AuthToken.IsNotNullOrWhiteSpace()) + { + requestBuilder.AddQueryParam("X-Plex-Token", settings.AuthToken); + } + + requestBuilder.ResourceUrl = resource; + requestBuilder.Method = method; + + return requestBuilder; + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder) + { + var httpRequest = requestBuilder.Build(); + + HttpResponse response; + + _logger.Debug("Url: {0}", httpRequest.Url); + + try + { + response = _httpClient.Execute(httpRequest); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new PlexAuthenticationException("Unauthorized - AuthToken is invalid"); + } + + throw new PlexException("Unable to connect to Plex Media Server. Status Code: {0}", ex.Response.StatusCode); + } + catch (WebException ex) + { + if (ex.Status == WebExceptionStatus.TrustFailure) + { + throw new PlexException("Unable to connect to Plex Media Server, certificate validation failed.", ex); + } + + throw new PlexException($"Unable to connect to Plex Media Server, {ex.Message}", ex); + } + + return response.Content; + } + + private void CheckForError(string response) + { + _logger.Trace("Checking for error"); + + if (response.IsNullOrWhiteSpace()) + { + _logger.Trace("No response body returned, no error detected"); + return; + } + + var error = response.Contains("_children") ? + Json.Deserialize(response) : + Json.Deserialize>(response).MediaContainer; + + if (error != null && !error.Error.IsNullOrWhiteSpace()) + { + throw new PlexException(error.Error); + } + + _logger.Trace("No error detected"); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs new file mode 100644 index 000000000..b141e291a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Books; +using NzbDrone.Core.RootFolders; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public interface IPlexServerService + { + void UpdateLibrary(Author author, PlexServerSettings settings); + void UpdateLibrary(IEnumerable authors, PlexServerSettings settings); + ValidationFailure Test(PlexServerSettings settings); + } + + public class PlexServerService : IPlexServerService + { + private readonly ICached _versionCache; + private readonly IPlexServerProxy _plexServerProxy; + private readonly IRootFolderService _rootFolderService; + private readonly Logger _logger; + + public PlexServerService(ICacheManager cacheManager, IPlexServerProxy plexServerProxy, IRootFolderService rootFolderService, Logger logger) + { + _versionCache = cacheManager.GetCache(GetType(), "versionCache"); + _plexServerProxy = plexServerProxy; + _rootFolderService = rootFolderService; + _logger = logger; + } + + public void UpdateLibrary(Author author, PlexServerSettings settings) + { + UpdateLibrary(new[] { author }, settings); + } + + public void UpdateLibrary(IEnumerable authors, PlexServerSettings settings) + { + try + { + _logger.Debug("Sending Update Request to Plex Server"); + var watch = Stopwatch.StartNew(); + + var version = _versionCache.Get(settings.Host, () => GetVersion(settings), TimeSpan.FromHours(2)); + ValidateVersion(version); + + var sections = GetSections(settings); + + foreach (var author in authors) + { + UpdateSections(author, sections, settings); + } + + _logger.Debug("Finished sending Update Request to Plex Server (took {0} ms)", watch.ElapsedMilliseconds); + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to Update Plex host: " + settings.Host); + throw; + } + } + + private List GetSections(PlexServerSettings settings) + { + _logger.Debug("Getting sections from Plex host: {0}", settings.Host); + + return _plexServerProxy.GetTvSections(settings).ToList(); + } + + private void ValidateVersion(Version version) + { + if (version >= new Version(1, 3, 0) && version < new Version(1, 3, 1)) + { + throw new PlexVersionException("Found version {0}, upgrade to PMS 1.3.1 to fix library updating and then restart Readarr", version); + } + } + + private Version GetVersion(PlexServerSettings settings) + { + _logger.Debug("Getting version from Plex host: {0}", settings.Host); + + var rawVersion = _plexServerProxy.Version(settings); + var version = new Version(Regex.Match(rawVersion, @"^(\d+[.-]){4}").Value.Trim('.', '-')); + + return version; + } + + private void UpdateSections(Author author, List sections, PlexServerSettings settings) + { + var rootFolderPath = _rootFolderService.GetBestRootFolderPath(author.Path); + var authorRelativePath = rootFolderPath.GetRelativePath(author.Path); + + // Try to update a matching section location before falling back to updating all section locations. + foreach (var section in sections) + { + foreach (var location in section.Locations) + { + var rootFolder = new OsPath(rootFolderPath); + var mappedPath = rootFolder; + + if (settings.MapTo.IsNotNullOrWhiteSpace()) + { + mappedPath = new OsPath(settings.MapTo) + (rootFolder - new OsPath(settings.MapFrom)); + + _logger.Trace("Mapping Path from {0} to {1} for partial scan", rootFolder, mappedPath); + } + + if (location.Path.PathEquals(mappedPath.FullPath)) + { + _logger.Debug("Updating matching section location, {0}", location.Path); + UpdateSectionPath(authorRelativePath, section, location, settings); + + return; + } + } + } + + _logger.Debug("Unable to find matching section location, updating all Music sections"); + + foreach (var section in sections) + { + foreach (var location in section.Locations) + { + UpdateSectionPath(authorRelativePath, section, location, settings); + } + } + } + + private void UpdateSectionPath(string authorRelativePath, PlexSection section, PlexSectionLocation location, PlexServerSettings settings) + { + var separator = location.Path.Contains('\\') ? "\\" : "/"; + var locationRelativePath = authorRelativePath.Replace("\\", separator).Replace("/", separator); + + // Plex location paths trim trailing extraneous separator characters, so it doesn't need to be trimmed + var pathToUpdate = $"{location.Path}{separator}{locationRelativePath}"; + + _logger.Debug("Updating section location, {0}", location.Path); + _plexServerProxy.Update(section.Id, pathToUpdate, settings); + } + + public ValidationFailure Test(PlexServerSettings settings) + { + try + { + _versionCache.Remove(settings.Host); + var sections = GetSections(settings); + + if (sections.Empty()) + { + return new ValidationFailure("Host", "At least one Music library is required"); + } + } + catch (PlexAuthenticationException ex) + { + _logger.Error(ex, "Unable to connect to Plex Media Server"); + return new ValidationFailure("AuthToken", "Invalid authentication token"); + } + catch (PlexException ex) + { + return new NzbDroneValidationFailure("Host", ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to connect to Plex Media Server"); + + return new NzbDroneValidationFailure("Host", "Unable to connect to Plex Media Server") + { + DetailedDescription = ex.Message + }; + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs new file mode 100644 index 000000000..e5b6d6537 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerSettings.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Plex.Server +{ + public class PlexServerSettingsValidator : AbstractValidator + { + public PlexServerSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + RuleFor(c => c.MapFrom).NotEmpty().Unless(c => c.MapTo.IsNullOrWhiteSpace()); + RuleFor(c => c.MapTo).NotEmpty().Unless(c => c.MapFrom.IsNullOrWhiteSpace()); + } + } + + public class PlexServerSettings : IProviderConfig + { + private static readonly PlexServerSettingsValidator Validator = new PlexServerSettingsValidator(); + + public PlexServerSettings() + { + Port = 32400; + UpdateLibrary = true; + SignIn = "startOAuth"; + } + + [FieldDefinition(0, Label = "Host")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Connect to Plex over HTTPS instead of HTTP")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Auth Token", Type = FieldType.Textbox, Privacy = PrivacyLevel.ApiKey, Advanced = true)] + public string AuthToken { get; set; } + + [FieldDefinition(4, Label = "Authenticate with Plex.tv", Type = FieldType.OAuth)] + public string SignIn { get; set; } + + [FieldDefinition(5, Label = "Update Library", Type = FieldType.Checkbox)] + public bool UpdateLibrary { get; set; } + + [FieldDefinition(6, Label = "Map Paths From", Type = FieldType.Textbox, Advanced = true, HelpText = "Readarr path, used to modify author paths when Plex sees library path location differently from Readarr")] + public string MapFrom { get; set; } + + [FieldDefinition(7, Label = "Map Paths To", Type = FieldType.Textbox, Advanced = true, HelpText = "Plex path, used to modify author paths when Plex sees library path location differently from Readarr")] + public string MapTo { get; set; } + + public bool IsValid => !string.IsNullOrWhiteSpace(Host); + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +}