diff --git a/src/NzbDrone.Core.Test/NotificationTests/PlexProviderTest.cs b/src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs similarity index 89% rename from src/NzbDrone.Core.Test/NotificationTests/PlexProviderTest.cs rename to src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs index e79e7bc84..ee21a6e6a 100644 --- a/src/NzbDrone.Core.Test/NotificationTests/PlexProviderTest.cs +++ b/src/NzbDrone.Core.Test/NotificationTests/PlexClientServiceTest.cs @@ -9,7 +9,7 @@ namespace NzbDrone.Core.Test.NotificationTests { [TestFixture] - public class PlexProviderTest : CoreTest + public class PlexClientServiceTest : CoreTest { private PlexClientSettings _clientSettings; @@ -43,7 +43,7 @@ namespace NzbDrone.Core.Test.NotificationTests .Returns("ok"); - Mocker.Resolve().Notify(_clientSettings, header, message); + Mocker.Resolve().Notify(_clientSettings, header, message); fakeHttp.Verify(v => v.DownloadString(expectedUrl), Times.Once()); @@ -63,8 +63,8 @@ namespace NzbDrone.Core.Test.NotificationTests fakeHttp.Setup(s => s.DownloadString(expectedUrl, "plex", "plex")) .Returns("ok"); - - Mocker.Resolve().Notify(_clientSettings, header, message); + + Mocker.Resolve().Notify(_clientSettings, header, message); fakeHttp.Verify(v => v.DownloadString(expectedUrl, "plex", "plex"), Times.Once()); diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index b2aab0ee0..cff9fe01d 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -274,7 +274,7 @@ - + diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexIdentity.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexIdentity.cs new file mode 100644 index 000000000..1d2b03c0f --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Models/PlexIdentity.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Notifications.Plex.Models +{ + public class PlexIdentity + { + public string MachineIdentifier { get; set; } + public string Version { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexPreferences.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexPreferences.cs new file mode 100644 index 000000000..39a7040b7 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Models/PlexPreferences.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Plex.Models +{ + public class PlexPreferences + { + [JsonProperty("_children")] + public List Preferences { get; set; } + } + + public class PlexPreference + { + public string Id { get; set; } + public string Type { get; set; } + public string Value { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs new file mode 100644 index 000000000..11decb6ff --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Models/PlexSection.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Plex.Models +{ + public class PlexSectionDetails + { + public int Id { get; set; } + public string Path { get; set; } + public string Language { get; set; } + } + + public class PlexSection + { + [JsonProperty("key")] + public int Id { get; set; } + + public string Type { get; set; } + public string Language { get; set; } + + [JsonProperty("_children")] + public List Sections { get; set; } + } + + public class PlexMediaContainer + { + [JsonProperty("_children")] + public List Directories { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/Models/PlexSectionItem.cs b/src/NzbDrone.Core/Notifications/Plex/Models/PlexSectionItem.cs new file mode 100644 index 000000000..7758c2d0a --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/Models/PlexSectionItem.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Notifications.Plex.Models +{ + public class PlexSectionItem + { + [JsonProperty("ratingKey")] + public int Id { get; set; } + + public string Title { get; set; } + } + + public class PlexSectionResponse + { + [JsonProperty("_children")] + public List Items { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs b/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs index d329e6577..bdd72bb90 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexClient.cs @@ -7,11 +7,11 @@ namespace NzbDrone.Core.Notifications.Plex { public class PlexClient : NotificationBase { - private readonly IPlexService _plexService; + private readonly IPlexClientService _plexClientService; - public PlexClient(IPlexService plexService) + public PlexClient(IPlexClientService plexClientService) { - _plexService = plexService; + _plexClientService = plexClientService; } public override string Link @@ -22,13 +22,13 @@ namespace NzbDrone.Core.Notifications.Plex public override void OnGrab(string message) { const string header = "Sonarr [TV] - Grabbed"; - _plexService.Notify(Settings, header, message); + _plexClientService.Notify(Settings, header, message); } public override void OnDownload(DownloadMessage message) { const string header = "Sonarr [TV] - Downloaded"; - _plexService.Notify(Settings, header, message.Message); + _plexClientService.Notify(Settings, header, message.Message); } public override void AfterRename(Series series) @@ -47,7 +47,7 @@ namespace NzbDrone.Core.Notifications.Plex { var failures = new List(); - failures.AddIfNotNull(_plexService.Test(Settings)); + failures.AddIfNotNull(_plexClientService.Test(Settings)); return new ValidationResult(failures); } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexClientService.cs similarity index 51% rename from src/NzbDrone.Core/Notifications/Plex/PlexService.cs rename to src/NzbDrone.Core/Notifications/Plex/PlexClientService.cs index 81caa24d4..fd17f8fa2 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexClientService.cs @@ -5,27 +5,24 @@ using FluentValidation.Results; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; +using NzbDrone.Core.Notifications.Plex.Models; namespace NzbDrone.Core.Notifications.Plex { - public interface IPlexService + public interface IPlexClientService { void Notify(PlexClientSettings settings, string header, string message); - void UpdateLibrary(PlexServerSettings settings); ValidationFailure Test(PlexClientSettings settings); - ValidationFailure Test(PlexServerSettings settings); } - public class PlexService : IPlexService + public class PlexClientService : IPlexClientService { private readonly IHttpProvider _httpProvider; - private readonly IPlexServerProxy _plexServerProxy; private readonly Logger _logger; - public PlexService(IHttpProvider httpProvider, IPlexServerProxy plexServerProxy, Logger logger) + public PlexClientService(IHttpProvider httpProvider, Logger logger) { _httpProvider = httpProvider; - _plexServerProxy = plexServerProxy; _logger = logger; } @@ -42,36 +39,6 @@ namespace NzbDrone.Core.Notifications.Plex } } - public void UpdateLibrary(PlexServerSettings settings) - { - try - { - _logger.Debug("Sending Update Request to Plex Server"); - var sections = GetSectionKeys(settings); - sections.ForEach(s => UpdateSection(s, settings)); - } - - catch(Exception ex) - { - _logger.WarnException("Failed to Update Plex host: " + settings.Host, ex); - throw; - } - } - - private List GetSectionKeys(PlexServerSettings settings) - { - _logger.Debug("Getting sections from Plex host: {0}", settings.Host); - - return _plexServerProxy.GetTvSections(settings).Select(s => s.Key).ToList(); - } - - private void UpdateSection(int key, PlexServerSettings settings) - { - _logger.Debug("Updating Plex host: {0}, Section: {1}", settings.Host, key); - - _plexServerProxy.Update(key, settings); - } - private string SendCommand(string host, int port, string command, string username, string password) { var url = String.Format("http://{0}:{1}/xbmcCmds/xbmcHttp?command={2}", host, port, command); @@ -106,31 +73,5 @@ namespace NzbDrone.Core.Notifications.Plex return null; } - - public ValidationFailure Test(PlexServerSettings settings) - { - try - { - var sections = GetSectionKeys(new PlexServerSettings - { - Host = settings.Host, - Port = settings.Port, - Username = settings.Username, - Password = settings.Password - }); - - if (sections.Empty()) - { - return new ValidationFailure("Host", "At least one TV library is required"); - } - } - catch (Exception ex) - { - _logger.ErrorException("Unable to connect to Plex Server: " + ex.Message, ex); - return new ValidationFailure("Host", "Unable to connect to Plex Server"); - } - - return null; - } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexSection.cs b/src/NzbDrone.Core/Notifications/Plex/PlexSection.cs deleted file mode 100644 index 0b7e96e4a..000000000 --- a/src/NzbDrone.Core/Notifications/Plex/PlexSection.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace NzbDrone.Core.Notifications.Plex -{ - public class PlexSection - { - public Int32 Id { get; set; } - public String Path { get; set; } - } - - public class PlexDirectory - { - public String Type { get; set; } - - [JsonProperty("_children")] - public List Sections { get; set; } - - public Int32 Key { get; set; } - } - - public class PlexMediaContainer - { - [JsonProperty("_children")] - public List Directories { get; set; } - } -} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs index 6949be6e0..839df84fd 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexServer.cs @@ -7,11 +7,11 @@ namespace NzbDrone.Core.Notifications.Plex { public class PlexServer : NotificationBase { - private readonly IPlexService _plexService; + private readonly IPlexServerService _plexServerService; - public PlexServer(IPlexService plexService) + public PlexServer(IPlexServerService plexServerService) { - _plexService = plexService; + _plexServerService = plexServerService; } public override string Link @@ -25,19 +25,19 @@ namespace NzbDrone.Core.Notifications.Plex public override void OnDownload(DownloadMessage message) { - UpdateIfEnabled(); + UpdateIfEnabled(message.Series); } public override void AfterRename(Series series) { - UpdateIfEnabled(); + UpdateIfEnabled(series); } - private void UpdateIfEnabled() + private void UpdateIfEnabled(Series series) { if (Settings.UpdateLibrary) { - _plexService.UpdateLibrary(Settings); + _plexServerService.UpdateLibrary(series, Settings); } } @@ -53,7 +53,7 @@ namespace NzbDrone.Core.Notifications.Plex { var failures = new List(); - failures.AddIfNotNull(_plexService.Test(Settings)); + failures.AddIfNotNull(_plexServerService.Test(Settings)); return new ValidationResult(failures); } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs index 71d149768..facbc135f 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexServerProxy.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using Newtonsoft.Json.Linq; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Serializer; +using NzbDrone.Core.Notifications.Plex.Models; using NzbDrone.Core.Rest; using RestSharp; @@ -14,8 +16,12 @@ namespace NzbDrone.Core.Notifications.Plex { public interface IPlexServerProxy { - List GetTvSections(PlexServerSettings settings); + List GetTvSections(PlexServerSettings settings); void Update(int sectionId, PlexServerSettings settings); + void UpdateSeries(int metadataId, PlexServerSettings settings); + string Version(PlexServerSettings settings); + List Preferences(PlexServerSettings settings); + int? GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings); } public class PlexServerProxy : IPlexServerProxy @@ -29,16 +35,14 @@ namespace NzbDrone.Core.Notifications.Plex _logger = logger; } - public List GetTvSections(PlexServerSettings settings) + public List GetTvSections(PlexServerSettings settings) { var request = GetPlexServerRequest("library/sections", Method.GET, settings); var client = GetPlexServerClient(settings); - var response = client.Execute(request); _logger.Trace("Sections response: {0}", response.Content); - - CheckForError(response.Content); + CheckForError(response); return Json.Deserialize(response.Content) .Directories @@ -51,11 +55,68 @@ namespace NzbDrone.Core.Notifications.Plex var resource = String.Format("library/sections/{0}/refresh", sectionId); var request = GetPlexServerRequest(resource, Method.GET, settings); var client = GetPlexServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Update response: {0}", response.Content); + CheckForError(response); + } + + public void UpdateSeries(int metadataId, PlexServerSettings settings) + { + var resource = String.Format("library/metadata/{0}/refresh", metadataId); + var request = GetPlexServerRequest(resource, Method.PUT, settings); + var client = GetPlexServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Update Series response: {0}", response.Content); + CheckForError(response); + } + public string Version(PlexServerSettings settings) + { + var request = GetPlexServerRequest("identity", Method.GET, settings); + var client = GetPlexServerClient(settings); var response = client.Execute(request); - CheckForError(response.Content); - _logger.Debug("Update response: {0}", response.Content); + _logger.Trace("Version response: {0}", response.Content); + CheckForError(response); + + return Json.Deserialize(response.Content).Version; + } + + public List Preferences(PlexServerSettings settings) + { + var request = GetPlexServerRequest(":/prefs", Method.GET, settings); + var client = GetPlexServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Preferences response: {0}", response.Content); + CheckForError(response); + + return Json.Deserialize(response.Content).Preferences; + } + + public int? GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings) + { + var guid = String.Format("com.plexapp.agents.thetvdb://{0}?lang={1}", tvdbId, language); + var resource = String.Format("library/sections/{0}/all?guid={1}", sectionId, System.Web.HttpUtility.UrlEncode(guid)); + var request = GetPlexServerRequest(resource, Method.GET, settings); + var client = GetPlexServerClient(settings); + var response = client.Execute(request); + + _logger.Trace("Sections response: {0}", response.Content); + CheckForError(response); + + var item = Json.Deserialize(response.Content) + .Items + .FirstOrDefault(); + + if (item == null) + { + return null; + } + + return item.Id; } private String Authenticate(string username, string password) @@ -65,8 +126,8 @@ namespace NzbDrone.Core.Notifications.Plex var response = client.Execute(request); - CheckForError(response.Content); _logger.Debug("Authentication Response: {0}", response.Content); + CheckForError(response); var user = Json.Deserialize(JObject.Parse(response.Content).SelectToken("user").ToString()); @@ -109,7 +170,7 @@ namespace NzbDrone.Core.Notifications.Plex if (!settings.Username.IsNullOrWhiteSpace()) { - request.AddParameter("X-Plex-Token", GetAuthenticationToken(settings.Username, settings.Password)); + request.AddParameter("X-Plex-Token", GetAuthenticationToken(settings.Username, settings.Password), ParameterType.HttpHeader); } return request; @@ -120,14 +181,23 @@ namespace NzbDrone.Core.Notifications.Plex return _authCache.Get(username, () => Authenticate(username, password)); } - private void CheckForError(string response) + private void CheckForError(IRestResponse response) { - var error = Json.Deserialize(response); + _logger.Trace("Checking for error"); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + throw new PlexException("Unauthorized"); + } + + var error = Json.Deserialize(response.Content); if (error != null && !error.Error.IsNullOrWhiteSpace()) { throw new PlexException(error.Error); } + + _logger.Trace("No error detected"); } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServerService.cs new file mode 100644 index 000000000..850fe75b1 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexServerService.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Notifications.Plex.Models; +using NzbDrone.Core.Tv; + +namespace NzbDrone.Core.Notifications.Plex +{ + public interface IPlexServerService + { + void UpdateLibrary(Series series, PlexServerSettings settings); + ValidationFailure Test(PlexServerSettings settings); + } + + public class PlexServerService : IPlexServerService + { + private readonly ICached _partialUpdateCache; + private readonly IPlexServerProxy _plexServerProxy; + private readonly Logger _logger; + + public PlexServerService(ICacheManager cacheManager, IPlexServerProxy plexServerProxy, Logger logger) + { + _partialUpdateCache = cacheManager.GetCache(GetType(), "partialUpdateCache"); + _plexServerProxy = plexServerProxy; + _logger = logger; + } + + + public void UpdateLibrary(Series series, PlexServerSettings settings) + { + try + { + _logger.Debug("Sending Update Request to Plex Server"); + + var sections = GetSections(settings); + + //TODO: How long should we cache this for? + var partialUpdates = _partialUpdateCache.Get(settings.Host, () => PartialUpdatesAllowed(settings), TimeSpan.FromHours(2)); + + if (partialUpdates) + { + sections.ForEach(s => UpdateSeries(s.Id, series, s.Language, settings)); + } + + else + { + sections.ForEach(s => UpdateSection(s.Id, settings)); + } + } + + catch(Exception ex) + { + _logger.WarnException("Failed to Update Plex host: " + settings.Host, ex); + throw; + } + } + + private List GetSections(PlexServerSettings settings) + { + _logger.Debug("Getting sections from Plex host: {0}", settings.Host); + + return _plexServerProxy.GetTvSections(settings).ToList(); + } + + private bool PartialUpdatesAllowed(PlexServerSettings settings) + { + try + { + var rawVersion = GetVersion(settings); + var version = new Version(Regex.Match(rawVersion, @"^(\d+\.){4}").Value.Trim('.')); + + if (version >= new Version(0, 9, 12, 0)) + { + var preferences = GetPreferences(settings); + var partialScanPreference = preferences.SingleOrDefault(p => p.Id.Equals("FSEventLibraryPartialScanEnabled")); + + if (partialScanPreference == null) + { + return false; + } + + return Convert.ToBoolean(partialScanPreference.Value); + } + } + catch (Exception ex) + { + _logger.WarnException("Unable to check if partial updates are allowed", ex); + } + + return false; + } + + private string GetVersion(PlexServerSettings settings) + { + _logger.Debug("Getting version from Plex host: {0}", settings.Host); + + return _plexServerProxy.Version(settings); + } + + private List GetPreferences(PlexServerSettings settings) + { + _logger.Debug("Getting preferences from Plex host: {0}", settings.Host); + + return _plexServerProxy.Preferences(settings); + } + + private void UpdateSection(int sectionId, PlexServerSettings settings) + { + _logger.Debug("Updating Plex host: {0}, Section: {1}", settings.Host, sectionId); + + _plexServerProxy.Update(sectionId, settings); + } + + private void UpdateSeries(int sectionId, Series series, string language, PlexServerSettings settings) + { + _logger.Debug("Updating Plex host: {0}, Section: {1}, Series: {2}", settings.Host, sectionId, series); + + var metadataId = GetMetadataId(sectionId, series, language, settings); + + if (metadataId.HasValue) + { + _plexServerProxy.UpdateSeries(metadataId.Value, settings); + } + + else + { + UpdateSection(sectionId, settings); + } + } + + private int? GetMetadataId(int sectionId, Series series, string language, PlexServerSettings settings) + { + _logger.Debug("Getting metadata from Plex host: {0} for series: {1}", settings.Host, series); + + return _plexServerProxy.GetMetadataId(sectionId, series.TvdbId, language, settings); + } + + public ValidationFailure Test(PlexServerSettings settings) + { + try + { + var sections = GetSections(new PlexServerSettings + { + Host = settings.Host, + Port = settings.Port, + Username = settings.Username, + Password = settings.Password + }); + + if (sections.Empty()) + { + return new ValidationFailure("Host", "At least one TV library is required"); + } + } + catch (Exception ex) + { + _logger.ErrorException("Unable to connect to Plex Server: " + ex.Message, ex); + return new ValidationFailure("Host", "Unable to connect to Plex Server"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs b/src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs index bb7977273..9d5858586 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexServerSettings.cs @@ -30,6 +30,7 @@ namespace NzbDrone.Core.Notifications.Plex [FieldDefinition(1, Label = "Port")] public Int32 Port { get; set; } + //TODO: Change username and password to token and get a plex.tv OAuth token properly [FieldDefinition(2, Label = "Username")] public String Username { get; set; } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index a683120c3..d78b6c363 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -701,8 +701,13 @@ + + + + + @@ -754,11 +759,10 @@ - - +