From 6d88a98282d1441f903d567470a9f1ce6ba0b52f Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 12 Mar 2023 23:41:35 -0700 Subject: [PATCH] New: Plex Watchlist RSS support --- .../ImportLists/Rss/Plex/PlexRssImport.cs | 29 ++++ .../Rss/Plex/PlexRssImportParser.cs | 45 ++++++ .../Rss/Plex/PlexRssImportSettings.cs | 27 ++++ .../ImportLists/Rss/RssImportBase.cs | 45 ++++++ .../ImportLists/Rss/RssImportBaseParser.cs | 144 ++++++++++++++++++ .../ImportLists/Rss/RssImportBaseSettings.cs | 29 ++++ .../Rss/RssImportRequestGenerator.cs | 26 ++++ 7 files changed, 345 insertions(+) create mode 100644 src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImport.cs create mode 100644 src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportParser.cs create mode 100644 src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportSettings.cs create mode 100644 src/NzbDrone.Core/ImportLists/Rss/RssImportBase.cs create mode 100644 src/NzbDrone.Core/ImportLists/Rss/RssImportBaseParser.cs create mode 100644 src/NzbDrone.Core/ImportLists/Rss/RssImportBaseSettings.cs create mode 100644 src/NzbDrone.Core/ImportLists/Rss/RssImportRequestGenerator.cs diff --git a/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImport.cs b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImport.cs new file mode 100644 index 000000000..2a02de133 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImport.cs @@ -0,0 +1,29 @@ +using System; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Rss.Plex +{ + public class PlexRssImport : RssImportBase + { + public PlexRssImport(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override ImportListType ListType => ImportListType.Plex; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(6); + public override string Name => "Plex Watchlist RSS"; + + public override IParseImportListResponse GetParser() + { + return new PlexRssImportParser(_logger); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportParser.cs b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportParser.cs new file mode 100644 index 000000000..9efdf196a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportParser.cs @@ -0,0 +1,45 @@ +using System.Xml.Linq; +using NLog; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Rss.Plex +{ + public class PlexRssImportParser : RssImportBaseParser + { + private readonly Logger _logger; + + public PlexRssImportParser(Logger logger) + : base(logger) + { + _logger = logger; + } + + protected override ImportListItemInfo ProcessItem(XElement item) + { + var info = new ImportListItemInfo(); + var guid = item.TryGetValue("guid", string.Empty); + var category = item.TryGetValue("category"); + + if (category != "show") + { + return null; + } + + info.Title = item.TryGetValue("title", "Unknown"); + + if (int.TryParse(guid.Replace("tvdb://", ""), out var tvdbId)) + { + info.TvdbId = tvdbId; + } + + if (info.TvdbId == 0) + { + throw new UnsupportedFeedException("Each item in the RSS feed must have a guid element with a TVDB ID"); + } + + return info; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportSettings.cs b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportSettings.cs new file mode 100644 index 000000000..58e16f93a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportSettings.cs @@ -0,0 +1,27 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Rss.Plex +{ + public class PlexRssImportSettingsValidator : AbstractValidator + { + public PlexRssImportSettingsValidator() + { + RuleFor(c => c.Url).NotEmpty(); + } + } + + public class PlexRssImportSettings : RssImportBaseSettings, IImportListSettings + { + private PlexRssImportSettingsValidator Validator => new PlexRssImportSettingsValidator(); + + [FieldDefinition(0, Label = "Url", Type = FieldType.Textbox, HelpLink = "https://app.plex.tv/desktop/#!/settings/watchlist")] + public override string Url { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Rss/RssImportBase.cs b/src/NzbDrone.Core/ImportLists/Rss/RssImportBase.cs new file mode 100644 index 000000000..dba32e022 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportBase.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Rss +{ + public class RssImportBase : HttpImportListBase + where TSettings : RssImportBaseSettings, new() + { + public RssImportBase(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override ImportListType ListType => ImportListType.Advanced; + public override TimeSpan MinRefreshInterval => TimeSpan.FromHours(6); + public override string Name => "RSS List Base"; + + public override IList Fetch() + { + return FetchItems(g => g.GetListItems()); + } + + public override IParseImportListResponse GetParser() + { + return new RssImportBaseParser(_logger); + } + + public override IImportListRequestGenerator GetRequestGenerator() + { + return new RssImportRequestGenerator() + { + Settings = Settings + }; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseParser.cs b/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseParser.cs new file mode 100644 index 000000000..9f17e1c7d --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseParser.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Xml; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.ImportLists.Exceptions; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Exceptions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Rss +{ + public class RssImportBaseParser : IParseImportListResponse + { + private readonly Logger _logger; + + public RssImportBaseParser(Logger logger) + { + _logger = logger; + } + + public virtual IList ParseResponse(ImportListResponse importResponse) + { + var series = new List(); + + if (!PreProcess(importResponse)) + { + return series; + } + + var document = LoadXmlDocument(importResponse); + var items = GetItems(document).ToList(); + + foreach (var item in items) + { + try + { + var itemInfo = ProcessItem(item); + + series.AddIfNotNull(itemInfo); + } + catch (UnsupportedFeedException itemEx) + { + itemEx.WithData("FeedUrl", importResponse.Request.Url); + itemEx.WithData("ItemTitle", item.Title()); + throw; + } + catch (Exception itemEx) + { + itemEx.WithData("FeedUrl", importResponse.Request.Url); + itemEx.WithData("ItemTitle", item.Title()); + _logger.Error(itemEx, "An error occurred while processing feed item from {0}", importResponse.Request.Url); + } + } + + return series; + } + + protected virtual bool PreProcess(ImportListResponse importListResponse) + { + if (importListResponse.HttpResponse.StatusCode != HttpStatusCode.OK) + { + throw new ImportListException(importListResponse, "Request resulted in an unexpected StatusCode [{0}]", importListResponse.HttpResponse.StatusCode); + } + + if (importListResponse.HttpResponse.Headers.ContentType != null && importListResponse.HttpResponse.Headers.ContentType.Contains("text/xml") && + importListResponse.HttpRequest.Headers.Accept != null && !importListResponse.HttpRequest.Headers.Accept.Contains("text/xml")) + { + throw new ImportListException(importListResponse, "Request responded with html content. Site is likely blocked or unavailable."); + } + + return true; + } + + protected virtual XDocument LoadXmlDocument(ImportListResponse importListResponse) + { + try + { + var content = XmlCleaner.ReplaceEntities(importListResponse.Content); + content = XmlCleaner.ReplaceUnicode(content); + + using (var xmlTextReader = XmlReader.Create(new StringReader(content), new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, IgnoreComments = true })) + { + return XDocument.Load(xmlTextReader); + } + } + catch (XmlException ex) + { + var contentSample = importListResponse.Content.Substring(0, Math.Min(importListResponse.Content.Length, 512)); + _logger.Debug("Truncated response content (originally {0} characters): {1}", importListResponse.Content.Length, contentSample); + + ex.WithData(importListResponse.HttpResponse); + + throw; + } + } + + protected IEnumerable GetItems(XDocument document) + { + var root = document.Root; + + if (root == null) + { + return Enumerable.Empty(); + } + + var channel = root.Element("channel"); + + if (channel == null) + { + return Enumerable.Empty(); + } + + return channel.Elements("item"); + } + + protected virtual ImportListItemInfo ProcessItem(XElement item) + { + var info = new ImportListItemInfo(); + var guid = item.TryGetValue("guid"); + + if (guid != null) + { + if (int.TryParse(guid, out var tvdbId)) + { + info.TvdbId = tvdbId; + } + } + + info.Title = item.TryGetValue("title", "Unknown"); + + if (info.TvdbId == 0) + { + throw new UnsupportedFeedException("Each item in the RSS feed must have a guid element with a TVDB ID"); + } + + return info; + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseSettings.cs b/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseSettings.cs new file mode 100644 index 000000000..19f18c7ce --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseSettings.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Rss +{ + public class RssImportSettingsValidator : AbstractValidator + { + public RssImportSettingsValidator() + { + RuleFor(c => c.Url).NotEmpty(); + } + } + + public class RssImportBaseSettings : IImportListSettings + { + private RssImportSettingsValidator Validator => new RssImportSettingsValidator(); + + public string BaseUrl { get; set; } + + [FieldDefinition(0, Label = "Url", Type = FieldType.Textbox)] + public virtual string Url { get; set; } + + public virtual NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Rss/RssImportRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/Rss/RssImportRequestGenerator.cs new file mode 100644 index 000000000..f55c181a7 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportRequestGenerator.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.ImportLists.Rss +{ + public class RssImportRequestGenerator : IImportListRequestGenerator + { + public RssImportBaseSettings Settings { get; set; } + + public virtual ImportListPageableRequestChain GetListItems() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetSeriesRequest()); + + return pageableRequests; + } + + private IEnumerable GetSeriesRequest() + { + var request = new ImportListRequest(Settings.Url, HttpAccept.Rss); + + yield return request; + } + } +}