mirror of
https://github.com/Radarr/Radarr
synced 2025-12-15 21:03:27 +01:00
New: Add support for Plex Watchlist importing (#5707)
* New: Add support for Plex Watchlists * Fixed: Error when trying to import an empty Plex Watchlist * cleanups Co-authored-by: Mark McDowall <mark@mcdowall.ca> Co-authored-by: Qstick <qstick@gmail.com>
This commit is contained in:
parent
ba770dce73
commit
a1fa1ddf5d
12 changed files with 396 additions and 8 deletions
41
src/NzbDrone.Core.Test/Files/plex_watchlist.json
Normal file
41
src/NzbDrone.Core.Test/Files/plex_watchlist.json
Normal file
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PlexParser>
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ public enum ImportListType
|
|||
Program,
|
||||
TMDB,
|
||||
Trakt,
|
||||
Plex,
|
||||
Other,
|
||||
Advanced
|
||||
}
|
||||
|
|
|
|||
104
src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs
Normal file
104
src/NzbDrone.Core/ImportLists/Plex/PlexImport.cs
Normal file
|
|
@ -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<PlexListSettings>
|
||||
{
|
||||
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<string, string> 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 { };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ImportListRequest> GetMoviesRequest()
|
||||
{
|
||||
var request = new ImportListRequest(_plexTvService.GetWatchlist(Settings.AccessToken));
|
||||
|
||||
yield return request;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs
Normal file
40
src/NzbDrone.Core/ImportLists/Plex/PlexListSettings.cs
Normal file
|
|
@ -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<PlexListSettings>
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs
Normal file
79
src/NzbDrone.Core/ImportLists/Plex/PlexParser.cs
Normal file
|
|
@ -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<ImportListMovie> ParseResponse(ImportListResponse importResponse)
|
||||
{
|
||||
List<PlexSectionItem> items;
|
||||
|
||||
_importResponse = importResponse;
|
||||
|
||||
var movies = new List<ImportListMovie>();
|
||||
|
||||
if (!PreProcess(_importResponse))
|
||||
{
|
||||
return movies;
|
||||
}
|
||||
|
||||
items = Json.Deserialize<PlexResponse<PlexSectionResponse>>(_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<PlexSectionItemGuid> guids, string prefix)
|
||||
{
|
||||
var scheme = $"{prefix}://";
|
||||
|
||||
return guids.FirstOrDefault((guid) => guid.Id.StartsWith(scheme))?.Id.Replace(scheme, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/NzbDrone.Core/Notifications/Plex/PlexMediaType.cs
Normal file
9
src/NzbDrone.Core/Notifications/Plex/PlexMediaType.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace NzbDrone.Core.Notifications.Plex
|
||||
{
|
||||
public enum PlexMediaType
|
||||
{
|
||||
None,
|
||||
Movie,
|
||||
Show
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ public interface IPlexTvService
|
|||
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 @@ public string GetAuthToken(int pinId)
|
|||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,33 @@
|
|||
|
||||
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<PlexSectionItemGuid> Guids { get; set; }
|
||||
}
|
||||
|
||||
public class PlexSectionResponse
|
||||
{
|
||||
[JsonProperty("Metadata")]
|
||||
public List<PlexSectionItem> Items { get; set; }
|
||||
|
||||
public PlexSectionResponse()
|
||||
{
|
||||
Items = new List<PlexSectionItem>();
|
||||
}
|
||||
}
|
||||
|
||||
public class PlexSectionResponseLegacy
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ public interface IPlexServerProxy
|
|||
{
|
||||
List<PlexSection> 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<PlexPreference> 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 @@ public void Update(int sectionId, PlexServerSettings settings)
|
|||
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 @@ public List<PlexPreference> Preferences(PlexServerSettings settings)
|
|||
.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)}";
|
||||
|
|
|
|||
|
|
@ -159,10 +159,10 @@ private bool UpdatePartialSection(Movie movie, List<PlexSection> sections, PlexS
|
|||
{
|
||||
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 @@ private bool UpdatePartialSection(Movie movie, List<PlexSection> sections, PlexS
|
|||
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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue