New: Import from Plex Watchlist

This commit is contained in:
justin vanderhooft 2021-01-08 09:41:34 -05:00 committed by Mark McDowall
parent 5923b4ae0d
commit f6fbd3cfee
15 changed files with 410 additions and 10 deletions

View File

@ -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": []
}
]
}
}

View File

@ -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<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("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");
}
}
}

View File

@ -121,6 +121,8 @@ namespace NzbDrone.Core.ImportLists
seriesToAdd.Add(new Series seriesToAdd.Add(new Series
{ {
TvdbId = report.TvdbId, TvdbId = report.TvdbId,
Title = report.Title,
Year = report.Year,
Monitored = monitored, Monitored = monitored,
RootFolderPath = importList.RootFolderPath, RootFolderPath = importList.RootFolderPath,
QualityProfileId = importList.QualityProfileId, QualityProfileId = importList.QualityProfileId,

View File

@ -3,6 +3,7 @@ namespace NzbDrone.Core.ImportLists
public enum ImportListType public enum ImportListType
{ {
Program, Program,
Plex,
Trakt, Trakt,
Other Other
} }

View 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.Parser.Model;
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 IList<ImportListItemInfo> 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<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 { };
}
}
}

View File

@ -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<ImportListRequest> GetMoviesRequest()
{
var request = new ImportListRequest(_plexTvService.GetWatchlist(Settings.AccessToken));
yield return request;
}
}
}

View File

@ -0,0 +1,41 @@
using FluentValidation;
using NzbDrone.Core.Annotations;
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 : 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));
}
}
}

View File

@ -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<ImportListItemInfo> ParseResponse(ImportListResponse importResponse)
{
List<PlexSectionItem> items;
_importResponse = importResponse;
var series = new List<ImportListItemInfo>();
if (!PreProcess(_importResponse))
{
return series;
}
items = Json.Deserialize<PlexResponse<PlexSectionResponse>>(_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<PlexSectionItemGuid> guids, string prefix)
{
var scheme = $"{prefix}://";
return guids.FirstOrDefault((guid) => guid.Id.StartsWith(scheme))?.Id.Replace(scheme, "");
}
}
}

View File

@ -0,0 +1,9 @@
namespace NzbDrone.Core.Notifications.Plex
{
public enum PlexMediaType
{
None,
Movie,
Show
}
}

View File

@ -11,6 +11,8 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
PlexTvPinUrlResponse GetPinUrl(); PlexTvPinUrlResponse GetPinUrl();
PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode);
string GetAuthToken(int pinId); string GetAuthToken(int pinId);
HttpRequest GetWatchlist(string authToken);
} }
public class PlexTvService : IPlexTvService public class PlexTvService : IPlexTvService
@ -80,5 +82,31 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv
return authToken; 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;
}
} }
} }

View File

@ -3,12 +3,27 @@ using Newtonsoft.Json;
namespace NzbDrone.Core.Notifications.Plex.Server namespace NzbDrone.Core.Notifications.Plex.Server
{ {
public class PlexSectionItemGuid
{
public string Id { get; set; }
}
public class PlexSectionItem public class PlexSectionItem
{ {
public PlexSectionItem()
{
Guids = new List<PlexSectionItemGuid>();
}
[JsonProperty("ratingKey")] [JsonProperty("ratingKey")]
public int Id { get; set; } public string Id { get; set; }
public string Title { get; set; } public string Title { get; set; }
public int Year { get; set; }
[JsonProperty("Guid")]
public List<PlexSectionItemGuid> Guids { get; set; }
} }
public class PlexSectionResponse public class PlexSectionResponse

View File

@ -14,10 +14,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server
{ {
List<PlexSection> GetTvSections(PlexServerSettings settings); List<PlexSection> GetTvSections(PlexServerSettings settings);
void Update(int sectionId, PlexServerSettings settings); void Update(int sectionId, PlexServerSettings settings);
void UpdateSeries(int metadataId, PlexServerSettings settings); void UpdateSeries(string metadataId, PlexServerSettings settings);
string Version(PlexServerSettings settings); string Version(PlexServerSettings settings);
List<PlexPreference> Preferences(PlexServerSettings settings); List<PlexPreference> 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 public class PlexServerProxy : IPlexServerProxy
@ -71,7 +71,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server
CheckForError(response); CheckForError(response);
} }
public void UpdateSeries(int metadataId, PlexServerSettings settings) public void UpdateSeries(string metadataId, PlexServerSettings settings)
{ {
var resource = $"library/metadata/{metadataId}/refresh"; var resource = $"library/metadata/{metadataId}/refresh";
var request = BuildRequest(resource, HttpMethod.PUT, settings); var request = BuildRequest(resource, HttpMethod.PUT, settings);
@ -116,7 +116,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server
.Preferences; .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 guid = $"com.plexapp.agents.thetvdb://{tvdbId}?lang={language}";
var resource = $"library/sections/{sectionId}/all?guid={System.Web.HttpUtility.UrlEncode(guid)}"; var resource = $"library/sections/{sectionId}/all?guid={System.Web.HttpUtility.UrlEncode(guid)}";

View File

@ -159,10 +159,10 @@ namespace NzbDrone.Core.Notifications.Plex.Server
{ {
var metadataId = GetMetadataId(section.Id, series, section.Language, settings); 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); _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; partiallyUpdated = true;
} }
@ -171,7 +171,7 @@ namespace NzbDrone.Core.Notifications.Plex.Server
return partiallyUpdated; 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); _logger.Debug("Getting metadata from Plex host: {0} for series: {1}", settings.Host, series);

View File

@ -1,5 +1,4 @@
using System; using System;
using System.Text;
namespace NzbDrone.Core.Parser.Model namespace NzbDrone.Core.Parser.Model
{ {
@ -8,7 +7,10 @@ namespace NzbDrone.Core.Parser.Model
public int ImportListId { get; set; } public int ImportListId { get; set; }
public string ImportList { get; set; } public string ImportList { get; set; }
public string Title { get; set; } public string Title { get; set; }
public int Year { get; set; }
public int TvdbId { get; set; } public int TvdbId { get; set; }
public int TmdbId { get; set; }
public string ImdbId { get; set; }
public DateTime ReleaseDate { get; set; } public DateTime ReleaseDate { get; set; }
public override string ToString() public override string ToString()

View File

@ -6,6 +6,7 @@ using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using NLog; using NLog;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Organizer; using NzbDrone.Core.Organizer;
@ -61,8 +62,15 @@ namespace NzbDrone.Core.Tv
var existingSeries = _seriesService.GetAllSeries(); var existingSeries = _seriesService.GetAllSeries();
foreach (var s in newSeries) foreach (var s in newSeries)
{
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); _logger.Info("Adding Series {0} Path: [{1}]", s, s.Path);
}
try try
{ {