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..21452de97 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/plex_watchlist.json @@ -0,0 +1,41 @@ +{ + "MediaContainer": { + "librarySectionID": "watchlist", + "librarySectionTitle": "Watchlist", + "offset": 0, + "totalSize": 3, + "identifier": "tv.plex.provider.metadata", + "size": 3, + "Metadata": [ + { + "type": "movie", + "title": "Arrival", + "year": 2016, + "Guid": [ + { + "id": "imdb://tt2543164" + } + ] + }, + { + "type": "movie", + "title": "The Last Witch Hunter", + "year": 2015, + "Guid": [ + { + "id": "imdb://tt1618442" + }, + { + "id": "tmdb://274854" + } + ] + }, + { + "type": "movie", + "title": "Avengers: Endgame", + "year": 2019, + "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..945d2717b --- /dev/null +++ b/src/NzbDrone.Core.Test/ImportListTests/Plex/PlexParserFixture.cs @@ -0,0 +1,39 @@ +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 +{ + [TestFixture] + public class PlexParserFixture : 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("Arrival"); + result.First().Year.Should().Be(2016); + result.First().ImdbId.Should().Be("tt2543164"); + result.First().TmdbId.Should().Be(0); + + result[1].TmdbId.Should().Be(274854); + result[1].ImdbId.Should().Be("tt1618442"); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListType.cs b/src/NzbDrone.Core/ImportLists/ImportListType.cs index f31d835c2..68f865308 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListType.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListType.cs @@ -5,6 +5,7 @@ namespace NzbDrone.Core.ImportLists Program, TMDB, Trakt, + Plex, Other, Advanced } diff --git a/src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs b/src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs new file mode 100644 index 000000000..8bf7adbb9 --- /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.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 bool Enabled => true; + public override bool EnableAuto => false; + + public override ImportListFetchResult Fetch() + { + Settings.Validate().Filter("AccessToken").ThrowOnError(); + + var generator = GetRequestGenerator(); + return FetchMovies(generator.GetMovies()); + } + + 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..22b90cdda --- /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 GetMovies() + { + 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..109fdb425 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs @@ -0,0 +1,40 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +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 : IProviderConfig + { + protected virtual PlexListSettingsValidator Validator => new PlexListSettingsValidator(); + + public PlexListSettings() + { + SignIn = "startOAuth"; + } + + public virtual string Scope => ""; + + [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..1778f9602 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.ImportLists.ImportListMovies; +using NzbDrone.Core.Notifications.Plex.Server; + +namespace NzbDrone.Core.ImportLists.Plex +{ + public class PlexParser : IParseImportListResponse + { + private ImportListResponse _importResponse; + + public PlexParser() + { + } + + public virtual IList ParseResponse(ImportListResponse importResponse) + { + List items; + + _importResponse = importResponse; + + var movies = new List(); + + if (!PreProcess(_importResponse)) + { + return movies; + } + + items = Json.Deserialize>(_importResponse.Content) + .MediaContainer + .Items; + + foreach (var item in items) + { + var tmdbIdString = FindGuid(item.Guids, "tmdb"); + var imdbId = FindGuid(item.Guids, "imdb"); + + int.TryParse(tmdbIdString, out int tmdbId); + + movies.AddIfNotNull(new ImportListMovie() + { + ImdbId = imdbId, + TmdbId = tmdbId, + Title = item.Title, + Year = item.Year + }); + } + + return movies; + } + + 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 aadf31388..50b39d4a2 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.Movie); + + 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..42e6919cc 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexSectionItem.cs @@ -3,18 +3,33 @@ using Newtonsoft.Json; namespace NzbDrone.Core.Notifications.Plex.Server { + public class PlexSectionItemGuid + { + public string Id { get; set; } + } + public class PlexSectionItem { [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 { [JsonProperty("Metadata")] public List Items { get; set; } + + public PlexSectionResponse() + { + Items = new List(); + } } public class PlexSectionResponseLegacy diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs index 86f4a5f4d..e4fab75cc 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs @@ -15,10 +15,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server { List GetMovieSections(PlexServerSettings settings); void Update(int sectionId, PlexServerSettings settings); - void UpdateMovie(int metadataId, PlexServerSettings settings); + void UpdateMovie(string metadataId, PlexServerSettings settings); string Version(PlexServerSettings settings); List Preferences(PlexServerSettings settings); - int? GetMetadataId(int sectionId, string imdbId, string language, PlexServerSettings settings); + string GetMetadataId(int sectionId, string imdbId, string language, PlexServerSettings settings); } public class PlexServerProxy : IPlexServerProxy @@ -72,7 +72,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server CheckForError(response); } - public void UpdateMovie(int metadataId, PlexServerSettings settings) + public void UpdateMovie(string metadataId, PlexServerSettings settings) { var resource = $"library/metadata/{metadataId}/refresh"; var request = BuildRequest(resource, HttpMethod.Put, settings); @@ -117,7 +117,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server .Preferences; } - public int? GetMetadataId(int sectionId, string imdbId, string language, PlexServerSettings settings) + public string GetMetadataId(int sectionId, string imdbId, string language, PlexServerSettings settings) { var guid = $"com.plexapp.agents.imdb://{imdbId}?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 3d6592a19..eb14090b6 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, movie, section.Language, settings); - if (metadataId.HasValue) + if (metadataId.IsNotNullOrWhiteSpace()) { _logger.Debug("Updating Plex host: {0}, Section: {1}, Movie: {2}", settings.Host, section.Id, movie); - _plexServerProxy.UpdateMovie(metadataId.Value, settings); + _plexServerProxy.UpdateMovie(metadataId, settings); partiallyUpdated = true; } @@ -171,7 +171,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server return partiallyUpdated; } - private int? GetMetadataId(int sectionId, Movie movie, string language, PlexServerSettings settings) + private string GetMetadataId(int sectionId, Movie movie, string language, PlexServerSettings settings) { _logger.Debug("Getting metadata from Plex host: {0} for movie: {1}", settings.Host, movie);