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 0000000000..b4df671666 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImport.cs @@ -0,0 +1,27 @@ +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 override string Name => "Plex Watchlist RSS"; + public override ImportListType ListType => ImportListType.Plex; + + public PlexRssImport(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + 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 0000000000..3efa2e5ef5 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/Plex/PlexRssImportParser.cs @@ -0,0 +1,46 @@ +using System.Xml.Linq; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.ImportLists.ImportListMovies; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Exceptions; + +namespace NzbDrone.Core.ImportLists.Rss.Plex +{ + public class PlexRssImportParser : RssImportBaseParser + { + public PlexRssImportParser(Logger logger) + : base(logger) + { + } + + protected override ImportListMovie ProcessItem(XElement item) + { + var category = item.TryGetValue("category"); + + if (category != "movie") + { + return null; + } + + var info = new ImportListMovie + { + Title = item.TryGetValue("title", "Unknown") + }; + + var guid = item.TryGetValue("guid", string.Empty); + + if (guid.IsNotNullOrWhiteSpace() && guid.StartsWith("imdb://")) + { + info.ImdbId = Parser.Parser.ParseImdbId(guid.Replace("imdb://", "")); + } + + if (info.ImdbId.IsNullOrWhiteSpace()) + { + throw new UnsupportedFeedException("Each item in the RSS feed must have a guid element with a IMDB 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 0000000000..941b90a4a8 --- /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 + { + private PlexRssImportSettingsValidator Validator => new (); + + [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 0000000000..5d3c1c29cf --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportBase.cs @@ -0,0 +1,43 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.ImportLists.Rss +{ + public abstract class RssImportBase : HttpImportListBase + where TSettings : RssImportBaseSettings, new() + { + public override bool Enabled => true; + public override bool EnableAuto => false; + + public RssImportBase(IHttpClient httpClient, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(httpClient, importListStatusService, configService, parsingService, logger) + { + } + + public override ImportListFetchResult Fetch() + { + var generator = GetRequestGenerator(); + + return FetchMovies(generator.GetMovies()); + } + + 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 0000000000..b2c68a518a --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseParser.cs @@ -0,0 +1,142 @@ +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.ImportLists.ImportListMovies; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Indexers.Exceptions; + +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 movies = new List(); + + if (!PreProcess(importResponse)) + { + return movies; + } + + var document = LoadXmlDocument(importResponse); + var items = GetItems(document).ToList(); + + foreach (var item in items) + { + try + { + var itemInfo = ProcessItem(item); + + movies.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 movies; + } + + 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 ImportListMovie ProcessItem(XElement item) + { + var info = new ImportListMovie + { + Title = item.TryGetValue("title", "Unknown") + }; + + var guid = item.TryGetValue("guid"); + + if (guid != null && int.TryParse(guid, out var tmdbId)) + { + info.TmdbId = tmdbId; + } + + if (info.TmdbId == 0) + { + throw new UnsupportedFeedException("Each item in the RSS feed must have a guid element with a TMDB 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 0000000000..581f4434e2 --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Rss/RssImportBaseSettings.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Rss +{ + public class RssImportSettingsValidator : AbstractValidator + { + public RssImportSettingsValidator() + { + RuleFor(c => c.Url).NotEmpty(); + } + } + + public class RssImportBaseSettings : IProviderConfig + { + private RssImportSettingsValidator Validator => new (); + + [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 0000000000..825eda0d76 --- /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 GetMovies() + { + var pageableRequests = new ImportListPageableRequestChain(); + + pageableRequests.Add(GetMoviesRequest()); + + return pageableRequests; + } + + private IEnumerable GetMoviesRequest() + { + var request = new ImportListRequest(Settings.Url, HttpAccept.Rss); + + yield return request; + } + } +}