From f6fbd3cfee4db891e68f9f15551ea62b02077b5e Mon Sep 17 00:00:00 2001 From: justin vanderhooft Date: Fri, 8 Jan 2021 09:41:34 -0500 Subject: [PATCH] New: Import from Plex Watchlist --- .../Files/plex_watchlist.json | 44 ++++++++ .../ImportListTests/Plex/PlexParserFixture.cs | 37 +++++++ .../ImportLists/ImportListSyncService.cs | 2 + .../ImportLists/ImportListType.cs | 1 + .../ImportLists/Plex/PlexImport.cs | 104 ++++++++++++++++++ .../Plex/PlexListRequestGenerator.cs | 32 ++++++ .../ImportLists/Plex/PlexListSettings.cs | 41 +++++++ .../ImportLists/Plex/PlexParser.cs | 77 +++++++++++++ .../Notifications/Plex/PlexMediaType.cs | 9 ++ .../Plex/PlexTv/PlexTvService.cs | 28 +++++ .../Plex/Server/PlexSectionItem.cs | 17 ++- .../Plex/Server/PlexServerProxy.cs | 8 +- .../Plex/Server/PlexServerService.cs | 6 +- .../Parser/Model/ImportListItemInfo.cs | 4 +- src/NzbDrone.Core/Tv/AddSeriesService.cs | 10 +- 15 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 src/NzbDrone.Core.Test/Files/plex_watchlist.json create mode 100644 src/NzbDrone.Core.Test/ImportListTests/Plex/PlexParserFixture.cs create mode 100644 src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs create mode 100644 src/NzbDrone.Core/ImportLists/Plex/PlexListRequestGenerator.cs create mode 100644 src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs create mode 100644 src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs create mode 100644 src/NzbDrone.Core/Notifications/Plex/PlexMediaType.cs diff --git a/src/NzbDrone.Core.Test/Files/plex_watchlist.json b/src/NzbDrone.Core.Test/Files/plex_watchlist.json new file mode 100644 index 000000000..7b1f0b3a7 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/plex_watchlist.json @@ -0,0 +1,44 @@ +{ + "MediaContainer": { + "librarySectionID": "watchlist", + "librarySectionTitle": "Watchlist", + "offset": 0, + "totalSize": 3, + "identifier": "tv.plex.provider.metadata", + "size": 3, + "Metadata": [ + { + "type": "show", + "title": "30 Rock", + "year": 2006, + "Guid": [ + { + "id": "tvdb://79488" + } + ] + }, + { + "type": "show", + "title": "Anthony Bourdain: No Reservations", + "year": 2005, + "Guid": [ + { + "id": "imdb://tt0475900" + }, + { + "id": "tmdb://4533" + }, + { + "id": "tvdb://79668" + } + ] + }, + { + "type": "show", + "title": "Series Title", + "year": 2021, + "Guid": [] + } + ] + } +} \ No newline at end of file diff --git a/src/NzbDrone.Core.Test/ImportListTests/Plex/PlexParserFixture.cs b/src/NzbDrone.Core.Test/ImportListTests/Plex/PlexParserFixture.cs new file mode 100644 index 000000000..21a331e8c --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Plex/PlexParserFixture.cs @@ -0,0 +1,37 @@ +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.ImportLists; +using NzbDrone.Core.ImportLists.Plex; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.ImportList.Plex +{ + public class PlexTest : CoreTest + { + private ImportListResponse CreateResponse(string url, string content) + { + var httpRequest = new HttpRequest(url); + var httpResponse = new HttpResponse(httpRequest, new HttpHeader(), Encoding.UTF8.GetBytes(content)); + + return new ImportListResponse(new ImportListRequest(httpRequest), httpResponse); + } + + [Test] + public void should_parse_plex_watchlist() + { + var json = ReadAllText("Files/plex_watchlist.json"); + + var result = Subject.ParseResponse(CreateResponse("https://metadata.provider.plex.tv/library/sections/watchlist/all", json)); + + result.First().Title.Should().Be("30 Rock"); + result.First().Year.Should().Be(2006); + result.First().TvdbId.Should().Be(79488); + + result[1].TmdbId.Should().Be(4533); + result[1].ImdbId.Should().Be("tt0475900"); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs index 5db20193c..42081e905 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -121,6 +121,8 @@ namespace NzbDrone.Core.ImportLists seriesToAdd.Add(new Series { TvdbId = report.TvdbId, + Title = report.Title, + Year = report.Year, Monitored = monitored, RootFolderPath = importList.RootFolderPath, QualityProfileId = importList.QualityProfileId, diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index deabff71a..1ccb1520e 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -3,6 +3,7 @@ namespace NzbDrone.Core.ImportLists public enum ImportListType { Program, + Plex, Trakt, Other } diff --git a/src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs b/src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs new file mode 100644 index 000000000..666c0bc11 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Notifications.Plex.PlexTv; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Plex +{ + public class PlexImport : HttpImportListBase + { + public readonly IPlexTvService _plexTvService; + public override ImportListType ListType => ImportListType.Plex; + + public PlexImport(IPlexTvService plexTvService, + IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + _plexTvService = plexTvService; + } + + public override string Name => "Plex Watchlist"; + + public override IList Fetch() + { + Settings.Validate().Filter("AccessToken").ThrowOnError(); + + // var generator = GetRequestGenerator(); + + return FetchItems(g =>g.GetListItems()); + } + + public override IParseImportListResponse GetParser() + { + return new PlexParser(); + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new PlexListRequestGenerator(_plexTvService) + { + Settings = Settings + }; + } + + 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 accessToken = _plexTvService.GetAuthToken(Convert.ToInt32(query["pinId"])); + + return new + { + accessToken + }; + } + + return new { }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Plex/PlexListRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Plex/PlexListRequestGenerator.cs new file mode 100644 index 000000000..f3dd5e585 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Plex/PlexListRequestGenerator.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.Notifications.Plex.PlexTv; + +namespace NzbDrone.Core.ImportLists.Plex +{ + public class PlexListRequestGenerator : IImportListRequestGenerator + { + private readonly IPlexTvService _plexTvService; + public PlexListSettings Settings { get; set; } + + public PlexListRequestGenerator(IPlexTvService plexTvService) + { + _plexTvService = plexTvService; + } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetMoviesRequest()); + + return pageableRequests; + } + + private IEnumerable GetMoviesRequest() + { + var request = new ImportListRequest(_plexTvService.GetWatchlist(Settings.AccessToken)); + + yield return request; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs b/src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs new file mode 100644 index 000000000..8d95285e8 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Plex +{ + public class PlexListSettingsValidator : AbstractValidator + { + public PlexListSettingsValidator() + { + RuleFor(c => c.AccessToken).NotEmpty() + .OverridePropertyName("SignIn") + .WithMessage("Must authenticate with Plex"); + } + } + + public class PlexListSettings : IImportListSettings + { + protected virtual PlexListSettingsValidator Validator => new PlexListSettingsValidator(); + + public PlexListSettings() + { + SignIn = "startOAuth"; + } + + public virtual string Scope => ""; + + public string BaseUrl { get; set; } + + [FieldDefinition(0, Label = "Access Token", Type = FieldType.Textbox, Hidden = HiddenType.Hidden)] + public string AccessToken { get; set; } + + [FieldDefinition(99, Label = "Authenticate with Plex.tv", Type = FieldType.OAuth)] + public string SignIn { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs b/src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs new file mode 100644 index 000000000..ab1decd9c --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Notifications.Plex.Server; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Plex +{ + public class PlexParser : IParseImportListResponse + { + private ImportListResponse _importResponse; + + public virtual IList ParseResponse(ImportListResponse importResponse) + { + List items; + + _importResponse = importResponse; + + var series = new List(); + + if (!PreProcess(_importResponse)) + { + return series; + } + + items = Json.Deserialize>(_importResponse.Content) + .MediaContainer + .Items; + + foreach (var item in items) + { + var tvdbIdString = FindGuid(item.Guids, "tvdb"); + var tmdbIdString = FindGuid(item.Guids, "tmdb"); + var imdbId = FindGuid(item.Guids, "imdb"); + + int.TryParse(tvdbIdString, out int tvdbId); + int.TryParse(tmdbIdString, out int tmdbId); + + series.Add(new ImportListItemInfo + { + TvdbId = tvdbId, + TmdbId = tmdbId, + ImdbId = imdbId, + Title = item.Title, + Year = item.Year + }); + } + + return series; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Plex API call resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && importListResponse.HttpResponse.Headers.ContentType.Contains("text/json") && + importListResponse.HttpRequest.Headers.Accept != null && !importListResponse.HttpRequest.Headers.Accept.Contains("text/json")) + { + throw new ImportListException(importListResponse, "Plex API responded with html content. Site is likely blocked or unavailable."); + } + + return true; + } + + private string FindGuid(List guids, string prefix) + { + var scheme = $"{prefix}://"; + + return guids.FirstOrDefault((guid) => guid.Id.StartsWith(scheme))?.Id.Replace(scheme, ""); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexMediaType.cs b/src/NzbDrone.Core/Notifications/Plex/PlexMediaType.cs new file mode 100644 index 000000000..c6fb9862d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexMediaType.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Plex +{ + public enum PlexMediaType + { + None, + Movie, + Show + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs index 8866775c6..32919d69c 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -11,6 +11,8 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv PlexTvPinUrlResponse GetPinUrl(); PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); string GetAuthToken(int pinId); + + HttpRequest GetWatchlist(string authToken); } public class PlexTvService : IPlexTvService @@ -80,5 +82,31 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv return authToken; } + + public HttpRequest GetWatchlist(string authToken) + { + var clientIdentifier = _configService.PlexClientIdentifier; + + var requestBuilder = new HttpRequestBuilder("https://metadata.provider.plex.tv/library/sections/watchlist/all") + .Accept(HttpAccept.Json) + .AddQueryParam("clientID", clientIdentifier) + .AddQueryParam("context[device][product]", BuildInfo.AppName) + .AddQueryParam("context[device][platform]", "Windows") + .AddQueryParam("context[device][platformVersion]", "7") + .AddQueryParam("context[device][version]", BuildInfo.Version.ToString()) + .AddQueryParam("includeFields", "title,type,year,ratingKey") + .AddQueryParam("includeElements", "Guid") + .AddQueryParam("sort", "watchlistedAt:desc") + .AddQueryParam("type", (int)PlexMediaType.Show); + + if (!string.IsNullOrWhiteSpace(authToken)) + { + requestBuilder.AddQueryParam("X-Plex-Token", authToken); + } + + var request = requestBuilder.Build(); + + return request; + } } } diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs index 70a8aaa3b..dac12e8c2 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs @@ -3,12 +3,27 @@ using Newtonsoft.Json; namespace NzbDrone.Core.Notifications.Plex.Server { + public class PlexSectionItemGuid + { + public string Id { get; set; } + } + public class PlexSectionItem { + public PlexSectionItem() + { + Guids = new List(); + } + [JsonProperty("ratingKey")] - public int Id { get; set; } + public string Id { get; set; } public string Title { get; set; } + + public int Year { get; set; } + + [JsonProperty("Guid")] + public List Guids { get; set; } } public class PlexSectionResponse diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs index 79c27ba7a..2997fcb54 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs @@ -14,10 +14,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server { List GetTvSections(PlexServerSettings settings); void Update(int sectionId, PlexServerSettings settings); - void UpdateSeries(int metadataId, PlexServerSettings settings); + void UpdateSeries(string metadataId, PlexServerSettings settings); string Version(PlexServerSettings settings); List Preferences(PlexServerSettings settings); - int? GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings); + string GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings); } public class PlexServerProxy : IPlexServerProxy @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server CheckForError(response); } - public void UpdateSeries(int metadataId, PlexServerSettings settings) + public void UpdateSeries(string metadataId, PlexServerSettings settings) { var resource = $"library/metadata/{metadataId}/refresh"; var request = BuildRequest(resource, HttpMethod.PUT, settings); @@ -116,7 +116,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server .Preferences; } - public int? GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings) + public string GetMetadataId(int sectionId, int tvdbId, string language, PlexServerSettings settings) { var guid = $"com.plexapp.agents.thetvdb://{tvdbId}?lang={language}"; var resource = $"library/sections/{sectionId}/all?guid={System.Web.HttpUtility.UrlEncode(guid)}"; diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs index 857dff4d3..574267f2a 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerService.cs @@ -159,10 +159,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server { var metadataId = GetMetadataId(section.Id, series, section.Language, settings); - if (metadataId.HasValue) + if (metadataId.IsNotNullOrWhiteSpace()) { _logger.Debug("Updating Plex host: {0}, Section: {1}, Series: {2}", settings.Host, section.Id, series); - _plexServerProxy.UpdateSeries(metadataId.Value, settings); + _plexServerProxy.UpdateSeries(metadataId, settings); partiallyUpdated = true; } @@ -171,7 +171,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server return partiallyUpdated; } - private int? GetMetadataId(int sectionId, Series series, string language, PlexServerSettings settings) + private string GetMetadataId(int sectionId, Series series, string language, PlexServerSettings settings) { _logger.Debug("Getting metadata from Plex host: {0} for series: {1}", settings.Host, series); diff --git a/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs index de929ae7d..df76ac2c2 100644 --- a/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ImportListItemInfo.cs @@ -1,5 +1,4 @@ using System; -using System.Text; namespace NzbDrone.Core.Parser.Model { @@ -8,7 +7,10 @@ namespace NzbDrone.Core.Parser.Model public int ImportListId { get; set; } public string ImportList { get; set; } public string Title { get; set; } + public int Year { get; set; } public int TvdbId { get; set; } + public int TmdbId { get; set; } + public string ImdbId { get; set; } public DateTime ReleaseDate { get; set; } public override string ToString() diff --git a/src/NzbDrone.Core/Tv/AddSeriesService.cs b/src/NzbDrone.Core/Tv/AddSeriesService.cs index 4c1b0cb3f..5cedf5b4c 100644 --- a/src/NzbDrone.Core/Tv/AddSeriesService.cs +++ b/src/NzbDrone.Core/Tv/AddSeriesService.cs @@ -6,6 +6,7 @@ using FluentValidation; using FluentValidation.Results; using NLog; using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Organizer; @@ -62,7 +63,14 @@ namespace NzbDrone.Core.Tv foreach (var s in newSeries) { - _logger.Info("Adding Series {0} Path: [{1}]", s, s.Path); + if (s.Path.IsNullOrWhiteSpace()) + { + _logger.Info("Adding Series {0} Root Folder Path: [{1}]", s, s.RootFolderPath); + } + else + { + _logger.Info("Adding Series {0} Path: [{1}]", s, s.Path); + } try {