diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Lists/GoodreadsListImportList.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Lists/GoodreadsListImportList.cs new file mode 100644 index 000000000..3ba3cb27e --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Lists/GoodreadsListImportList.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.ImportLists.Goodreads +{ + public class GoodreadsListImportList : ImportListBase + { + private readonly IProvideListInfo _listInfo; + + public override string Name => "Goodreads List"; + public override ImportListType ListType => ImportListType.Goodreads; + + public GoodreadsListImportList(IProvideListInfo listInfo, + IImportListStatusService importListStatusService, + IConfigService configService, + IParsingService parsingService, + Logger logger) + : base(importListStatusService, configService, parsingService, logger) + { + _listInfo = listInfo; + } + + public override IList Fetch() + { + var result = new List(); + + try + { + var pageNum = 1; + while (true) + { + if (pageNum > 100) + { + // you always seem to get back page 100 for bigger pages... + break; + } + + var page = FetchPage(pageNum++); + + if (page.Any()) + { + result.AddRange(page); + } + else + { + break; + } + } + + _importListStatusService.RecordSuccess(Definition.Id); + } + catch + { + _importListStatusService.RecordFailure(Definition.Id); + } + + return CleanupListItems(result); + } + + private List FetchPage(int page) + { + var list = _listInfo.GetListInfo(Settings.ListId, page); + var result = new List(); + + foreach (var book in list.Books) + { + var author = book.Authors.FirstOrDefault(); + + result.Add(new ImportListItemInfo + { + BookGoodreadsId = book.Work.Id.ToString(), + Book = book.Work.OriginalTitle, + EditionGoodreadsId = book.Id.ToString(), + Author = author?.Name, + AuthorGoodreadsId = author?.Id.ToString() + }); + } + + return result; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + } + + private ValidationFailure TestConnection() + { + try + { + _listInfo.GetListInfo(Settings.ListId, 1); + return null; + } + catch (HttpException e) + { + _logger.Warn(e, "Goodreads API Error"); + if (e.Response.StatusCode == HttpStatusCode.NotFound) + { + return new ValidationFailure(nameof(Settings.ListId), $"List {Settings.ListId} not found"); + } + + return new ValidationFailure(nameof(Settings.ListId), $"Could not get list data"); + } + catch (Exception ex) + { + _logger.Warn(ex, "Unable to connect to Goodreads"); + + return new ValidationFailure(string.Empty, "Unable to connect to import list, check the log for more details"); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/Goodreads/Lists/GoodreadsListImportListSettings.cs b/src/NzbDrone.Core/ImportLists/Goodreads/Lists/GoodreadsListImportListSettings.cs new file mode 100644 index 000000000..ec03499fb --- /dev/null +++ b/src/NzbDrone.Core/ImportLists/Goodreads/Lists/GoodreadsListImportListSettings.cs @@ -0,0 +1,34 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.ImportLists.Goodreads +{ + public class GoodreadsListImportListValidator : AbstractValidator + { + public GoodreadsListImportListValidator() + { + RuleFor(c => c.ListId).GreaterThan(0); + } + } + + public class GoodreadsListImportListSettings : IImportListSettings + { + private static readonly GoodreadsListImportListValidator Validator = new (); + + public GoodreadsListImportListSettings() + { + BaseUrl = "www.goodreads.com"; + } + + public string BaseUrl { get; set; } + + [FieldDefinition(0, Label = "List ID", HelpText = "Goodreads list ID")] + public int ListId { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs index 898be1e77..9e60ace1d 100644 --- a/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/GoodreadsProxy.cs @@ -17,7 +17,7 @@ using NzbDrone.Core.Parser; namespace NzbDrone.Core.MetadataSource.Goodreads { - public class GoodreadsProxy : IProvideBookInfo, IProvideSeriesInfo + public class GoodreadsProxy : IProvideBookInfo, IProvideSeriesInfo, IProvideListInfo { private static readonly RegexReplace FullSizeImageRegex = new RegexReplace(@"\._[SU][XY]\d+_.jpg$", ".jpg", @@ -338,6 +338,48 @@ namespace NzbDrone.Core.MetadataSource.Goodreads return resource.Series; } + public ListResource GetListInfo(int foreignListId, int page, bool useCache = true) + { + _logger.Debug("Getting List with GoodreadsId of {0}", foreignListId); + + var httpRequest = new HttpRequestBuilder("https://www.goodreads.com/book/list/listopia.xml") + .AddQueryParam("key", new string("whFzJP3Ud0gZsAdyXxSr7T".Reverse().ToArray())) + .AddQueryParam("_nc", "1") + .AddQueryParam("format", "xml") + .AddQueryParam("id", foreignListId) + .AddQueryParam("items_per_page", 30) + .AddQueryParam("page", page) + .SetHeader("User-Agent", "Goodreads/3.33.1 (iPhone; iOS 14.3; Scale/3.00)") + .SetHeader("X_APPLE_DEVICE_MODEL", "iPhone") + .SetHeader("x-gr-os-version", "iOS 14.3") + .SetHeader("Accept-Language", "en-GB;q=1") + .SetHeader("X_APPLE_APP_VERSION", "761") + .SetHeader("x-gr-app-version", "761") + .SetHeader("x-gr-hw-model", "iPhone11,6") + .SetHeader("X_APPLE_SYSTEM_VERSION", "14.3") + .KeepAlive() + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _cachedHttpClient.Get(httpRequest, useCache, TimeSpan.FromDays(7)); + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignListId.ToString()); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + return httpResponse.Deserialize(); + } + private bool TryGetBookInfo(string foreignEditionId, bool useCache, out Tuple> result) { try diff --git a/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/ListResource.cs b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/ListResource.cs new file mode 100644 index 000000000..b54081bf4 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Goodreads/Resources/ListResource.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Xml.Linq; +using NzbDrone.Core.Books; + +namespace NzbDrone.Core.MetadataSource.Goodreads +{ + /// + /// Represents information about a book series as defined by the Goodreads API. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed class ListResource : GoodreadsResource + { + public ListResource() + { + } + + public override string ElementName => "list"; + + public int Page { get; private set; } + + public int PerPage { get; private set; } + + public int ListBooksCount { get; private set; } + + public List Books { get; set; } + + public override void Parse(XElement element) + { + Page = element.ElementAsInt("page"); + PerPage = element.ElementAsInt("per_page"); + ListBooksCount = element.ElementAsInt("total_books"); + + Books = element.ParseChildren("books", "book") ?? new List(); + } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/IProvideListInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideListInfo.cs new file mode 100644 index 000000000..f2fdb1528 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/IProvideListInfo.cs @@ -0,0 +1,9 @@ +using NzbDrone.Core.MetadataSource.Goodreads; + +namespace NzbDrone.Core.MetadataSource +{ + public interface IProvideListInfo + { + ListResource GetListInfo(int id, int page, bool useCache = true); + } +} diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 18b3df508..90254aac4 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -639,6 +639,11 @@ namespace NzbDrone.Core.Parser public static string CleanAuthorName(this string name) { + if (name.IsNullOrWhiteSpace()) + { + return string.Empty; + } + // If Title only contains numbers return it as is. if (long.TryParse(name, out _)) {