Move all data fetching to BookInfo

pull/1398/head
BookInfo 2 years ago
parent 2dff18490e
commit f6ff53ca31

@ -17,10 +17,15 @@
font-size: 36px;
}
.series {
font-weight: 300;
font-size: 24px;
}
.authorName {
margin-bottom: 20px;
font-weight: 300;
font-size: 20px;
font-size: 24px;
}
.disambiguation {

@ -43,6 +43,7 @@ class AddNewBookModalContent extends Component {
render() {
const {
bookTitle,
seriesTitle,
authorName,
disambiguation,
overview,
@ -84,6 +85,13 @@ class AddNewBookModalContent extends Component {
<span className={styles.disambiguation}>({disambiguation})</span>
}
{
!!seriesTitle &&
<div className={styles.series}>
{seriesTitle}
</div>
}
<div>
<span className={styles.authorName}> By: {authorName}</span>
</div>
@ -144,6 +152,7 @@ class AddNewBookModalContent extends Component {
AddNewBookModalContent.propTypes = {
bookTitle: PropTypes.string.isRequired,
seriesTitle: PropTypes.string,
authorName: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
overview: PropTypes.string,

@ -52,6 +52,11 @@
font-size: 36px;
}
.series {
font-weight: 300;
font-size: 24px;
}
.year {
margin-left: 10px;
color: $disabledColor;

@ -74,6 +74,7 @@ class AddNewBookSearchResult extends Component {
foreignBookId,
titleSlug,
title,
seriesTitle,
releaseDate,
disambiguation,
overview,
@ -151,6 +152,13 @@ class AddNewBookSearchResult extends Component {
</div>
</div>
{
seriesTitle &&
<div className={styles.series}>
{seriesTitle}
</div>
}
<div>
<Label size={sizes.LARGE}>
<HeartRating
@ -188,6 +196,7 @@ class AddNewBookSearchResult extends Component {
isExistingAuthor={isExistingAuthor}
foreignBookId={foreignBookId}
bookTitle={title}
seriesTitle={seriesTitle}
disambiguation={disambiguation}
authorName={author.authorName}
overview={overview}
@ -203,6 +212,7 @@ AddNewBookSearchResult.propTypes = {
foreignBookId: PropTypes.string.isRequired,
titleSlug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
seriesTitle: PropTypes.string,
releaseDate: PropTypes.string,
disambiguation: PropTypes.string,
overview: PropTypes.string,

@ -39,7 +39,7 @@ namespace NzbDrone.Core.Test.ImportListTests
.Returns(new List<Book>());
Mocker.GetMock<ISearchForNewBook>()
.Setup(v => v.SearchByGoodreadsId(It.IsAny<int>()))
.Setup(v => v.SearchByGoodreadsBookId(It.IsAny<int>()))
.Returns<int>(x => Builder<Book>
.CreateListOfSize(1)
.TheFirst(1)

@ -60,7 +60,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification
Mocker.SetConstant<IConfigService>(Mocker.Resolve<IConfigService>());
Mocker.SetConstant<IProvideAuthorInfo>(Mocker.Resolve<BookInfoProxy>());
Mocker.SetConstant<IProvideBookInfo>(Mocker.Resolve<GoodreadsProxy>());
Mocker.SetConstant<IProvideBookInfo>(Mocker.Resolve<BookInfoProxy>());
_addAuthorService = Mocker.Resolve<AddAuthorService>();

@ -4,17 +4,16 @@ using System.Linq;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.MetadataSource.BookInfo;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
[TestFixture]
public class GoodreadsProxyFixture : CoreTest<GoodreadsProxy>
public class BookInfoProxyFixture : CoreTest<BookInfoProxy>
{
private MetadataProfile _metadataProfile;
@ -45,8 +44,8 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
details.Name.Should().Be(name);
}
[TestCase("64216", "Guards! Guards!")]
[TestCase("1371", "Ιλιάς")]
[TestCase("1128601", "Guards! Guards!")]
[TestCase("3293141", "Ιλιάς")]
public void should_be_able_to_get_book_detail(string mbId, string name)
{
var details = Subject.GetBookInfo(mbId);
@ -56,13 +55,13 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
details.Item2.Title.Should().Be(name);
}
[TestCase("54837483", "The Book of Dust", "1")]
[TestCase("28360360", "October Daye", "9.3")]
[TestCase("14190696", "The Book of Dust", "1")]
[TestCase("48427681", "October Daye Chronological Order", "7.1")]
public void should_parse_series_from_title(string id, string series, string position)
{
var result = Subject.GetBookInfo(id);
var link = result.Item2.SeriesLinks.Value.First();
var link = result.Item2.SeriesLinks.Value.OrderBy(x => x.SeriesPosition).First();
link.Series.Value.Title.Should().Be(series);
link.Position.Should().Be(position);
}
@ -70,13 +69,13 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
[Test]
public void getting_details_of_invalid_author()
{
Assert.Throws<AuthorNotFoundException>(() => Subject.GetAuthorInfo("66c66aaa-6e2f-4930-8610-912e24c63ed1"));
Assert.Throws<AuthorNotFoundException>(() => Subject.GetAuthorInfo("1"));
}
[Test]
public void getting_details_of_invalid_book()
{
Assert.Throws<BookNotFoundException>(() => Subject.GetBookInfo("66c66aaa-6e2f-4930-8610-912e24c63ed1"));
Assert.Throws<BookNotFoundException>(() => Subject.GetBookInfo("99999999"));
}
private void ValidateAuthor(Author author)

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Core.Books;
using NzbDrone.Core.Http;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.MetadataSource.BookInfo;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
[TestFixture]
public class BookInfoProxySearchFixture : CoreTest<BookInfoProxy>
{
[SetUp]
public void Setup()
{
UseRealHttp();
Mocker.SetConstant<IGoodreadsSearchProxy>(Mocker.Resolve<GoodreadsSearchProxy>());
var httpClient = Mocker.Resolve<IHttpClient>();
Mocker.GetMock<ICachedHttpResponseService>()
.Setup(x => x.Get<List<SearchJsonResource>>(It.IsAny<HttpRequest>(), It.IsAny<bool>(), It.IsAny<TimeSpan>()))
.Returns((HttpRequest request, bool useCache, TimeSpan ttl) => httpClient.Get<List<SearchJsonResource>>(request));
var metadataProfile = new MetadataProfile();
Mocker.GetMock<IMetadataProfileService>()
.Setup(s => s.All())
.Returns(new List<MetadataProfile> { metadataProfile });
Mocker.GetMock<IMetadataProfileService>()
.Setup(s => s.Get(It.IsAny<int>()))
.Returns(metadataProfile);
}
[TestCase("Robert Harris", "Robert Harris")]
[TestCase("James Patterson", "James Patterson")]
[TestCase("Antoine de Saint-Exupéry", "Antoine de Saint-Exupéry")]
public void successful_author_search(string title, string expected)
{
var result = Subject.SearchForNewAuthor(title);
result.Should().NotBeEmpty();
result[0].Name.Should().Be(expected);
ExceptionVerification.IgnoreWarns();
}
[TestCase("Harry Potter and the sorcerer's stone", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("edition:3", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("edition: 3", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("asin:B0192CTMYG", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("isbn:9780439554930", null, "Harry Potter and the Sorcerer's Stone")]
public void successful_book_search(string title, string author, string expected)
{
var result = Subject.SearchForNewBook(title, author);
result.Should().NotBeEmpty();
result[0].Editions.Value[0].Title.Should().Be(expected);
ExceptionVerification.IgnoreWarns();
}
[TestCase("edition:")]
[TestCase("edition: 99999999999999999999")]
[TestCase("edition: 0")]
[TestCase("edition: -12")]
[TestCase("edition: aaaa")]
[TestCase("adjalkwdjkalwdjklawjdlKAJD")]
public void no_author_search_result(string term)
{
var result = Subject.SearchForNewAuthor(term);
result.Should().BeEmpty();
ExceptionVerification.IgnoreWarns();
}
[TestCase("Philip Pullman", 0, typeof(Author), "Philip Pullman")]
[TestCase("Philip Pullman", 1, typeof(Book), "Northern Lights")]
public void successful_combined_search(string query, int position, Type resultType, string expected)
{
var result = Subject.SearchForNewEntity(query);
result.Should().NotBeEmpty();
result[position].GetType().Should().Be(resultType);
if (resultType == typeof(Author))
{
var cast = result[position] as Author;
cast.Should().NotBeNull();
cast.Name.Should().Be(expected);
}
else
{
var cast = result[position] as Book;
cast.Should().NotBeNull();
cast.Title.Should().Be(expected);
}
}
}
}

@ -1,15 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Http;
using NzbDrone.Core.Books;
using NzbDrone.Core.Http;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
@ -23,52 +19,35 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{
UseRealHttp();
Mocker.SetConstant<IProvideBookInfo>(Mocker.Resolve<GoodreadsProxy>());
var httpClient = Mocker.Resolve<IHttpClient>();
Mocker.GetMock<ICachedHttpResponseService>()
.Setup(x => x.Get<List<SearchJsonResource>>(It.IsAny<HttpRequest>(), It.IsAny<bool>(), It.IsAny<TimeSpan>()))
.Returns((HttpRequest request, bool useCache, TimeSpan ttl) => httpClient.Get<List<SearchJsonResource>>(request));
var metadataProfile = new MetadataProfile();
Mocker.GetMock<IMetadataProfileService>()
.Setup(s => s.All())
.Returns(new List<MetadataProfile> { metadataProfile });
Mocker.GetMock<IMetadataProfileService>()
.Setup(s => s.Get(It.IsAny<int>()))
.Returns(metadataProfile);
.Returns((HttpRequest request, bool useCache, TimeSpan ttl) => Mocker.Resolve<IHttpClient>().Get<List<SearchJsonResource>>(request));
}
[TestCase("Robert Harris", "Robert Harris")]
[TestCase("James Patterson", "James Patterson")]
[TestCase("Antoine de Saint-Exupéry", "Antoine de Saint-Exupéry")]
public void successful_author_search(string title, string expected)
[TestCase("Robert Harris", 575)]
[TestCase("James Patterson", 3780)]
[TestCase("Antoine de Saint-Exupéry", 1020792)]
public void successful_author_search(string title, int expected)
{
var result = Subject.SearchForNewAuthor(title);
var result = Subject.Search(title);
result.Should().NotBeEmpty();
result[0].Name.Should().Be(expected);
result[0].Author.Id.Should().Be(expected);
ExceptionVerification.IgnoreWarns();
}
[TestCase("Harry Potter and the sorcerer's stone", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("readarr:3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("readarr: 3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("readarrid:3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("goodreads:3", null, "Harry Potter and the Philosopher's Stone")]
[TestCase("asin:B0192CTMYG", null, "Harry Potter and the Sorcerer's Stone")]
[TestCase("isbn:9780439554930", null, "Harry Potter and the Sorcerer's Stone")]
public void successful_book_search(string title, string author, string expected)
[TestCase("Harry Potter and the sorcerer's stone", 3)]
[TestCase("B0192CTMYG", 42844155)]
[TestCase("9780439554930", 48517161)]
public void successful_book_search(string title, int expected)
{
var result = Subject.SearchForNewBook(title, author);
var result = Subject.Search(title);
result.Should().NotBeEmpty();
result[0].Title.Should().Be(expected);
result[0].BookId.Should().Be(expected);
ExceptionVerification.IgnoreWarns();
}
@ -81,54 +60,10 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
[TestCase("adjalkwdjkalwdjklawjdlKAJD")]
public void no_author_search_result(string term)
{
var result = Subject.SearchForNewAuthor(term);
var result = Subject.Search(term);
result.Should().BeEmpty();
ExceptionVerification.IgnoreWarns();
}
[TestCase("Philip Pullman", 0, typeof(Author), "Philip Pullman")]
[TestCase("Philip Pullman", 1, typeof(Book), "The Golden Compass")]
public void successful_combined_search(string query, int position, Type resultType, string expected)
{
var result = Subject.SearchForNewEntity(query);
result.Should().NotBeEmpty();
result[position].GetType().Should().Be(resultType);
if (resultType == typeof(Author))
{
var cast = result[position] as Author;
cast.Should().NotBeNull();
cast.Name.Should().Be(expected);
}
else
{
var cast = result[position] as Book;
cast.Should().NotBeNull();
cast.Title.Should().Be(expected);
}
}
[TestCase("B01N390U59", "The Book of Dust", "1")]
[TestCase("B0191WS1EE", "October Daye", "9.3")]
public void should_parse_series_from_title(string query, string series, string position)
{
var result = Subject.SearchByField("field", query);
var link = result.First().SeriesLinks.Value.First();
link.Series.Value.Title.Should().Be(series);
link.Position.Should().Be(position);
}
[TestCase("Imperium: A Novel of Ancient Rome (Cicero, #1)", "Imperium: A Novel of Ancient Rome", "Cicero", "1")]
[TestCase("Sons of Valor (The Tier One Shared-World Series Book 1)", "Sons of Valor", "Tier One Shared-World", "1")]
public void should_map_series_for_search(string title, string titleWithoutSeries, string series, string position)
{
var result = GoodreadsProxy.MapSearchSeries(title, titleWithoutSeries);
result.Should().HaveCount(1);
result[0].Series.Value.Title.Should().Be(series);
result[0].Position.Should().Be(position);
}
}
}

@ -30,20 +30,20 @@ namespace NzbDrone.Core.Test.MusicTests
.Build();
}
private void GivenValidBook(string readarrId)
private void GivenValidBook(string bookId, string editionId)
{
_fakeBook = Builder<Book>
.CreateNew()
.With(x => x.Editions = Builder<Edition>
.CreateListOfSize(1)
.TheFirst(1)
.With(e => e.ForeignEditionId = readarrId)
.With(e => e.ForeignEditionId = editionId)
.With(e => e.Monitored = true)
.BuildList())
.Build();
Mocker.GetMock<IProvideBookInfo>()
.Setup(s => s.GetBookInfo(readarrId, true))
.Setup(s => s.GetBookInfo(bookId, true))
.Returns(Tuple.Create(_fakeAuthor.Metadata.Value.ForeignAuthorId,
_fakeBook,
new List<AuthorMetadata> { _fakeAuthor.Metadata.Value }));
@ -85,7 +85,7 @@ namespace NzbDrone.Core.Test.MusicTests
{
var newBook = BookToAdd("edition", "book", "author");
GivenValidBook("edition");
GivenValidBook("book", "edition");
GivenValidPath();
var book = Subject.AddBook(newBook);
@ -99,7 +99,7 @@ namespace NzbDrone.Core.Test.MusicTests
var newBook = BookToAdd("edition", "book", "author");
Mocker.GetMock<IProvideBookInfo>()
.Setup(s => s.GetBookInfo("edition", true))
.Setup(s => s.GetBookInfo("book", true))
.Throws(new BookNotFoundException("edition"));
Assert.Throws<ValidationException>(() => Subject.AddBook(newBook));

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text.Json.Serialization;
using Equ;
using NzbDrone.Common.Extensions;
@ -8,6 +9,7 @@ using NzbDrone.Core.MediaFiles;
namespace NzbDrone.Core.Books
{
[DebuggerDisplay("{GetType().FullName} ID = {Id} [{ForeignBookId}][{Title}]")]
public class Book : Entity<Book>
{
public Book()

@ -1,10 +1,12 @@
using System.Collections.Generic;
using System.Diagnostics;
using Equ;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Books
{
[DebuggerDisplay("{GetType().FullName} ID = {Id} [{ForeignSeriesId}][{Title}]")]
public class Series : Entity<Series>
{
public string ForeignSeriesId { get; set; }

@ -44,16 +44,13 @@ namespace NzbDrone.Core.Books
{
_logger.Debug($"Adding book {book}");
book = AddSkyhookData(book);
// we allow adding extra editions, so check if the book already exists
var dbBook = _bookService.FindById(book.ForeignBookId);
if (dbBook != null)
{
dbBook.Editions = book.Editions;
book = dbBook;
}
else
{
book = AddSkyhookData(book);
book.UseDbFieldsFrom(dbBook);
}
// Remove any import list exclusions preventing addition
@ -107,10 +104,11 @@ namespace NzbDrone.Core.Books
private Book AddSkyhookData(Book newBook)
{
var editionId = newBook.Editions.Value.Single(x => x.Monitored).ForeignEditionId;
Tuple<string, Book, List<AuthorMetadata>> tuple = null;
try
{
tuple = _bookInfo.GetBookInfo(editionId);
tuple = _bookInfo.GetBookInfo(newBook.ForeignBookId);
}
catch (BookNotFoundException)
{

@ -75,11 +75,9 @@ namespace NzbDrone.Core.Books
private Author GetSkyhookData(Book book)
{
var foreignId = book.Editions.Value.First().ForeignEditionId;
try
{
var tuple = _bookInfo.GetBookInfo(foreignId, false);
var tuple = _bookInfo.GetBookInfo(book.ForeignBookId, false);
var author = _authorInfo.GetAuthorInfo(tuple.Item1, false);
var newbook = tuple.Item2;
@ -88,19 +86,12 @@ namespace NzbDrone.Core.Books
newbook.AuthorMetadataId = book.AuthorMetadataId;
newbook.AuthorMetadata.Value.Id = book.AuthorMetadataId;
// make sure to grab editions data for any other existing editions
foreach (var edition in book.Editions.Value.Skip(1))
{
tuple = _bookInfo.GetBookInfo(edition.ForeignEditionId, false);
newbook.Editions.Value.AddRange(tuple.Item2.Editions.Value);
}
author.Books = new List<Book> { newbook };
return author;
}
catch (BookNotFoundException)
{
_logger.Error($"Could not find book with id {foreignId}");
_logger.Error($"Could not find book with id {book.ForeignBookId}");
}
return null;
@ -119,6 +110,7 @@ namespace NzbDrone.Core.Books
if (book == null)
{
data = GetSkyhookData(local);
book = data.Books.Value.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId);
}

@ -1,8 +1,8 @@
using System.Diagnostics;
using System.Diagnostics;
namespace NzbDrone.Core.Datastore
{
[DebuggerDisplay("{GetType()} ID = {Id}")]
[DebuggerDisplay("{GetType().FullName} ID = {Id}")]
public abstract class ModelBase
{
public int Id { get; set; }

@ -141,7 +141,7 @@ namespace NzbDrone.Core.ImportLists
if (report.EditionGoodreadsId.IsNotNullOrWhiteSpace() && int.TryParse(report.EditionGoodreadsId, out var goodreadsId))
{
var search = _bookSearchService.SearchByGoodreadsId(goodreadsId);
var search = _bookSearchService.SearchByGoodreadsBookId(goodreadsId);
mappedBook = search.FirstOrDefault(x => x.Editions.Value.Any(e => int.TryParse(e.ForeignEditionId, out var editionId) && editionId == goodreadsId));
}
else

@ -565,7 +565,7 @@
"Search": "Search",
"SearchAll": "Search All",
"SearchBook": "Search Book",
"SearchBoxPlaceHolder": "eg. War and Peace, goodreads:656, isbn:067003469X, asin:B00JCDK5ME",
"SearchBoxPlaceHolder": "eg. War and Peace, edition:656, work:4912783, author:128382, isbn:067003469X, asin:B00JCDK5ME",
"SearchForAllCutoffUnmetBooks": "Search for all Cutoff Unmet books",
"SearchForAllMissingBooks": "Search for all missing books",
"SearchForMissing": "Search for Missing",

@ -254,7 +254,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification
try
{
remoteBooks = _bookSearchService.SearchByGoodreadsId(id);
remoteBooks = _bookSearchService.SearchByGoodreadsBookId(id);
}
catch (GoodreadsException e)
{

@ -308,7 +308,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual
var edition = _editionService.GetEditionByForeignEditionId(file.ForeignEditionId);
if (edition == null)
{
var tuple = _bookInfo.GetBookInfo(file.ForeignEditionId);
var tuple = _bookInfo.GetBookInfo(book.ForeignBookId);
edition = tuple.Item2.Editions.Value.SingleOrDefault(x => x.ForeignEditionId == file.ForeignEditionId);
}

@ -8,25 +8,39 @@ using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource.Goodreads;
namespace NzbDrone.Core.MetadataSource.BookInfo
{
public class BookInfoProxy : IProvideAuthorInfo
public class BookInfoProxy : IProvideAuthorInfo, IProvideBookInfo, ISearchForNewBook, ISearchForNewAuthor, ISearchForNewEntity
{
private readonly IHttpClient _httpClient;
private readonly IGoodreadsSearchProxy _goodreadsSearchProxy;
private readonly IAuthorService _authorService;
private readonly IBookService _bookService;
private readonly IEditionService _editionService;
private readonly Logger _logger;
private readonly IMetadataRequestBuilder _requestBuilder;
private readonly ICached<HashSet<string>> _cache;
public BookInfoProxy(IHttpClient httpClient,
IMetadataRequestBuilder requestBuilder,
Logger logger,
ICacheManager cacheManager)
IGoodreadsSearchProxy goodreadsSearchProxy,
IAuthorService authorService,
IBookService bookService,
IEditionService editionService,
IMetadataRequestBuilder requestBuilder,
Logger logger,
ICacheManager cacheManager)
{
_httpClient = httpClient;
_goodreadsSearchProxy = goodreadsSearchProxy;
_authorService = authorService;
_bookService = bookService;
_editionService = editionService;
_requestBuilder = requestBuilder;
_cache = cacheManager.GetCache<HashSet<string>>(GetType());
_logger = logger;
@ -124,12 +138,307 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return null;
}
public Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string foreignBookId)
public Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string foreignBookId, bool useCache = false)
{
return null;
return PollBook(foreignBookId);
}
public List<Author> SearchForNewAuthor(string title)
{
var books = SearchForNewBook(title, null);
return books.Select(x => x.Author.Value).ToList();
}
public List<Book> SearchForNewBook(string title, string author)
{
var q = title.ToLower().Trim();
if (author != null)
{
q += " " + author;
}
try
{
var lowerTitle = title.ToLowerInvariant();
var split = lowerTitle.Split(':');
var prefix = split[0];
if (split.Length == 2 && new[] { "author", "work", "edition", "isbn", "asin" }.Contains(prefix))
{
var slug = split[1].Trim();
if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace))
{
return new List<Book>();
}
if (prefix == "author" || prefix == "work" || prefix == "edition")
{
var isValid = int.TryParse(slug, out var searchId);
if (!isValid)
{
return new List<Book>();
}
if (prefix == "author")
{
return SearchByGoodreadsAuthorId(searchId);
}
if (prefix == "work")
{
return SearchByGoodreadsWorkId(searchId);
}
if (prefix == "edition")
{
return SearchByGoodreadsBookId(searchId);
}
}
q = slug;
}
return Search(q);
}
catch (HttpException ex)
{
_logger.Warn(ex, ex.Message);
throw new GoodreadsException("Search for '{0}' failed. Unable to communicate with Goodreads.", title);
}
catch (Exception ex)
{
_logger.Warn(ex, ex.Message);
throw new GoodreadsException("Search for '{0}' failed. Invalid response received from Goodreads.",
title);
}
}
public List<Book> SearchByIsbn(string isbn)
{
return Search(isbn);
}
public List<Book> SearchByAsin(string asin)
{
return Search(asin);
}
private List<Book> SearchByGoodreadsAuthorId(int id)
{
try
{
var authorId = id.ToString();
var result = GetAuthorInfo(authorId);
var books = result.Books.Value.OrderByDescending(x => x.Ratings.Popularity).Take(10).ToList();
var authors = new Dictionary<string, AuthorMetadata> { { authorId, result.Metadata.Value } };
foreach (var book in books)
{
AddDbIds(authorId, book, authors);
}
return books;
}
catch (AuthorNotFoundException)
{
return new List<Book>();
}
}
public List<Book> SearchByGoodreadsWorkId(int id)
{
try
{
var tuple = GetBookInfo(id.ToString());
AddDbIds(tuple.Item1, tuple.Item2, tuple.Item3.ToDictionary(x => x.ForeignAuthorId));
return new List<Book> { tuple.Item2 };
}
catch (BookNotFoundException)
{
return new List<Book>();
}
}
public List<Book> SearchByGoodreadsBookId(int id)
{
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
.SetSegment("route", $"book/{id}")
.Build();
httpRequest.SuppressHttpError = true;
var httpResponse = _httpClient.Get<BulkBookResource>(httpRequest);
return MapBulkBook(httpResponse.Resource);
}
public List<object> SearchForNewEntity(string title)
{
var books = SearchForNewBook(title, null);
var result = new List<object>();
foreach (var book in books)
{
var author = book.Author.Value;
if (!result.Contains(author))
{
result.Add(author);
}
result.Add(book);
}
return result;
}
private List<Book> Search(string query)
{
var result = _goodreadsSearchProxy.Search(query);
var ids = result.Select(x => x.BookId).ToList();
return MapSearchResult(ids);
}
private List<Book> MapSearchResult(List<int> ids)
{
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
.SetSegment("route", $"book/bulk")
.SetHeader("Content-Type", "application/json")
.Build();
httpRequest.SetContent(ids.ToJson());
httpRequest.AllowAutoRedirect = true;
var httpResponse = _httpClient.Post<BulkBookResource>(httpRequest);
var mapped = MapBulkBook(httpResponse.Resource);
var idStr = ids.Select(x => x.ToString()).ToList();
return mapped.OrderBy(b => idStr.IndexOf(b.Editions.Value.First().ForeignEditionId)).ToList();
}
private Author MapAuthor(AuthorResource resource)
private List<Book> MapBulkBook(BulkBookResource resource)
{
var authors = resource.Authors.Select(MapAuthorMetadata).ToDictionary(x => x.ForeignAuthorId, x => x);
var series = resource.Series.Select(MapSeries).ToList();
var books = new List<Book>();
foreach (var work in resource.Works)
{
var book = MapBook(work);
var authorId = work.Books.OrderByDescending(b => b.AverageRating * b.RatingCount).First().Contributors.First().ForeignId.ToString();
AddDbIds(authorId, book, authors);
books.Add(book);
}
MapSeriesLinks(series, books, resource.Series);
return books;
}
private void AddDbIds(string authorId, Book book, Dictionary<string, AuthorMetadata> authors)
{
var dbBook = _bookService.FindById(book.ForeignBookId);
if (dbBook != null)
{
book.UseDbFieldsFrom(dbBook);
var editions = _editionService.GetEditionsByBook(dbBook.Id).ToDictionary(x => x.ForeignEditionId);
foreach (var edition in book.Editions.Value)
{
if (editions.TryGetValue(edition.ForeignEditionId, out var dbEdition))
{
edition.UseDbFieldsFrom(dbEdition);
}
}
}
var author = _authorService.FindById(authorId);
if (author == null)
{
var metadata = authors[authorId];
author = new Author
{
CleanName = Parser.Parser.CleanAuthorName(metadata.Name),
Metadata = metadata
};
}
book.Author = author;
book.AuthorMetadata = author.Metadata.Value;
book.AuthorMetadataId = author.AuthorMetadataId;
}
private Tuple<string, Book, List<AuthorMetadata>> PollBook(string foreignBookId)
{
WorkResource resource = null;
for (var i = 0; i < 60; i++)
{
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
.SetSegment("route", $"work/{foreignBookId}")
.Build();
httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true;
var httpResponse = _httpClient.Get<WorkResource>(httpRequest);
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new BookNotFoundException(foreignBookId);
}
else if (httpResponse.StatusCode == HttpStatusCode.BadRequest)
{
throw new BadRequestException(foreignBookId);
}
else
{
throw new HttpException(httpRequest, httpResponse);
}
}
resource = httpResponse.Resource;
if (resource.Books != null)
{
break;
}
Thread.Sleep(2000);
}
if (resource?.Books == null)
{
throw new BookInfoException($"Failed to get books for {foreignBookId}");
}
var book = MapBook(resource);
var authorId = resource.Books.OrderByDescending(x => x.AverageRating * x.RatingCount).First().Contributors.First().ForeignId.ToString();
var metadata = resource.Authors.Select(MapAuthorMetadata).ToList();
var series = resource.Series.Select(MapSeries).ToList();
MapSeriesLinks(series, new List<Book> { book }, resource.Series);
return Tuple.Create(authorId, book, metadata);
}
private static AuthorMetadata MapAuthorMetadata(AuthorResource resource)
{
var metadata = new AuthorMetadata
{
@ -159,6 +468,13 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
metadata.Links.Add(new Links { Url = resource.Url, Name = "Goodreads" });
}
return metadata;
}
private static Author MapAuthor(AuthorResource resource)
{
var metadata = MapAuthorMetadata(resource);
var books = resource.Works
.Where(x => x.ForeignId > 0 && GetAuthorId(x) == resource.ForeignId)
.Select(MapBook)
@ -168,7 +484,7 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
var series = resource.Series.Select(MapSeries).ToList();
MapSeriesLinks(series, books, resource);
MapSeriesLinks(series, books, resource.Series);
var result = new Author
{
@ -181,13 +497,18 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return result;
}
private static void MapSeriesLinks(List<Series> series, List<Book> books, AuthorResource resource)
private static void MapSeriesLinks(List<Series> series, List<Book> books, List<SeriesResource> resource)
{
var bookDict = books.ToDictionary(x => x.ForeignBookId);
var seriesDict = series.ToDictionary(x => x.ForeignSeriesId);
foreach (var book in books)
{
book.SeriesLinks = new List<SeriesBookLink>();
}
// only take series where there are some works
foreach (var s in resource.Series.Where(x => x.LinkItems.Any()))
foreach (var s in resource.Where(x => x.LinkItems.Any()))
{
if (seriesDict.TryGetValue(s.ForeignId.ToString(), out var curr))
{
@ -199,6 +520,11 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
Position = l.PositionInSeries,
SeriesPosition = l.SeriesPosition
}).ToList();
foreach (var l in curr.LinkItems.Value)
{
l.Book.Value.SeriesLinks.Value.Add(l);
}
}
}
}
@ -234,8 +560,8 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
{
book.Editions = resource.Books.Select(x => MapEdition(x)).ToList();
// monitor the most rated release
var mostPopular = book.Editions.Value.OrderByDescending(x => x.Ratings.Votes).FirstOrDefault();
// monitor the most popular release
var mostPopular = book.Editions.Value.OrderByDescending(x => x.Ratings.Popularity).FirstOrDefault();
if (mostPopular != null)
{
mostPopular.Monitored = true;
@ -252,17 +578,24 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
book.Editions = new List<Edition>();
}
// sometimes the work release date is after the earliest good edition release
var editionReleases = book.Editions.Value
.Where(x => x.ReleaseDate.HasValue && x.ReleaseDate.Value.Month != 1 && x.ReleaseDate.Value.Day != 1)
.ToList();
if (editionReleases.Any())
// If we are missing the book release date, set as the earliest edition release date
if (!book.ReleaseDate.HasValue)
{
var earliestRelease = editionReleases.Min(x => x.ReleaseDate.Value);
if (earliestRelease < book.ReleaseDate)
var editionReleases = book.Editions.Value
.Where(x => x.ReleaseDate.HasValue && x.ReleaseDate.Value.Month != 1 && x.ReleaseDate.Value.Day != 1)
.ToList();
if (editionReleases.Any())
{
book.ReleaseDate = editionReleases.Min(x => x.ReleaseDate.Value);
}
else
{
book.ReleaseDate = earliestRelease;
editionReleases = book.Editions.Value.Where(x => x.ReleaseDate.HasValue).ToList();
if (editionReleases.Any())
{
book.ReleaseDate = editionReleases.Min(x => x.ReleaseDate.Value);
}
}
}
@ -322,7 +655,7 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return edition;
}
private int GetAuthorId(WorkResource b)
private static int GetAuthorId(WorkResource b)
{
return b.Books.First().Contributors.FirstOrDefault()?.ForeignId ?? 0;
}

@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
namespace NzbDrone.Core.MetadataSource.BookInfo
{
public class BulkBookResource
{
public List<WorkResource> Works { get; set; }
public List<SeriesResource> Series { get; set; }
public List<AuthorResource> Authors { get; set; }
}
}

@ -12,6 +12,8 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
public DateTime? ReleaseDate { get; set; }
public List<string> Genres { get; set; }
public List<int> RelatedWorks { get; set; }
public List<BookResource> Books { get; set; } = new List<BookResource>();
public List<BookResource> Books { get; set; }
public List<SeriesResource> Series { get; set; } = new List<SeriesResource>();
public List<AuthorResource> Authors { get; set; } = new List<AuthorResource>();
}
}

@ -1,56 +1,26 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Http;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.MetadataSource.Goodreads
{
public class GoodreadsProxy : IProvideBookInfo, IProvideSeriesInfo, IProvideListInfo
public class GoodreadsProxy : IProvideSeriesInfo, IProvideListInfo
{
private static readonly RegexReplace FullSizeImageRegex = new RegexReplace(@"\._[SU][XY]\d+_.jpg$",
".jpg",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex DuplicateSpacesRegex = new Regex(@"\s{2,}", RegexOptions.Compiled);
private static readonly Regex NoPhotoRegex = new Regex(@"/nophoto/(book|user)/",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly List<Regex> SeriesRegex = new List<Regex>
{
new Regex(@"\((?<series>[^,]+),\s+#(?<position>[\w\d\.]+)\)$", RegexOptions.Compiled),
new Regex(@"(The\s+(?<series>.+)\s+Series\s+Book\s+(?<position>[\w\d\.]+)\)$)", RegexOptions.Compiled)
};
private readonly ICachedHttpResponseService _cachedHttpClient;
private readonly Logger _logger;
private readonly IAuthorService _authorService;
private readonly IEditionService _editionService;
private readonly IHttpRequestBuilderFactory _requestBuilder;
private readonly ICached<HashSet<string>> _cache;
public GoodreadsProxy(ICachedHttpResponseService cachedHttpClient,
IAuthorService authorService,
IEditionService editionService,
Logger logger,
ICacheManager cacheManager)
Logger logger)
{
_cachedHttpClient = cachedHttpClient;
_authorService = authorService;
_editionService = editionService;
_cache = cacheManager.GetCache<HashSet<string>>(GetType());
_logger = logger;
_requestBuilder = new HttpRequestBuilder("https://www.goodreads.com/{route}")
@ -61,252 +31,6 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
.CreateFactory();
}
public HashSet<string> GetChangedAuthors(DateTime startTime)
{
return null;
}
public Author GetAuthorInfo(string foreignAuthorId, bool useCache = true)
{
_logger.Debug("Getting Author details GoodreadsId of {0}", foreignAuthorId);
var httpRequest = _requestBuilder.Create()
.SetSegment("route", $"author/show/{foreignAuthorId}.xml")
.AddQueryParam("exclude_books", "true")
.Build();
httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true;
var httpResponse = _cachedHttpClient.Get(httpRequest, useCache, TimeSpan.FromDays(30));
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new AuthorNotFoundException(foreignAuthorId);
}
else if (httpResponse.StatusCode == HttpStatusCode.BadRequest)
{
throw new BadRequestException(foreignAuthorId);
}
else
{
throw new HttpException(httpRequest, httpResponse);
}
}
var resource = httpResponse.Deserialize<AuthorResource>();
var author = new Author
{
Metadata = MapAuthor(resource)
};
author.CleanName = Parser.Parser.CleanAuthorName(author.Metadata.Value.Name);
// we can only get a rating from the author list page...
var listResource = GetAuthorBooksPageResource(foreignAuthorId, 10, 1);
var authorResource = listResource.List.SelectMany(x => x.Authors).FirstOrDefault(a => a.Id.ToString() == foreignAuthorId);
author.Metadata.Value.Ratings = new Ratings
{
Votes = authorResource?.RatingsCount ?? 0,
Value = authorResource?.AverageRating ?? 0
};
return author;
}
public Author GetAuthorAndBooks(string foreignAuthorId, double minPopularity = 0)
{
var author = GetAuthorInfo(foreignAuthorId);
var bookList = GetAuthorBooks(foreignAuthorId, minPopularity);
var books = bookList.Select((x, i) =>
{
_logger.ProgressDebug($"{author}: Fetching book {i}/{bookList.Count}");
return GetBookInfo(x.Editions.Value.First().ForeignEditionId).Item2;
}).ToList();
var existingAuthor = _authorService.FindById(foreignAuthorId);
if (existingAuthor != null)
{
var existingEditions = _editionService.GetEditionsByAuthor(existingAuthor.Id);
var extraEditionIds = existingEditions
.Select(x => x.ForeignEditionId)
.Except(books.Select(x => x.Editions.Value.First().ForeignEditionId))
.ToList();
_logger.Debug($"Getting data for extra editions {extraEditionIds.ConcatToString()}");
var extraEditions = new List<Tuple<string, Book, List<AuthorMetadata>>>();
foreach (var id in extraEditionIds)
{
if (TryGetBookInfo(id, true, out var result))
{
extraEditions.Add(result);
}
}
var bookDict = books.ToDictionary(x => x.ForeignBookId);
foreach (var edition in extraEditions)
{
var b = edition.Item2;
if (bookDict.TryGetValue(b.ForeignBookId, out var book))
{
book.Editions.Value.Add(b.Editions.Value.First());
}
else
{
bookDict.Add(b.ForeignBookId, b);
}
}
books = bookDict.Values.ToList();
}
books.ForEach(x => x.AuthorMetadata = author.Metadata.Value);
author.Books = books;
author.Series = GetAuthorSeries(foreignAuthorId, author.Books);
return author;
}
private List<Book> GetAuthorBooks(string foreignAuthorId, double minPopularity)
{
var perPage = 100;
var page = 0;
var result = new List<Book>();
List<Book> current;
IEnumerable<Book> filtered;
do
{
current = GetAuthorBooksPage(foreignAuthorId, perPage, ++page);
filtered = current.Where(x => x.Editions.Value.First().Ratings.Popularity >= minPopularity);
result.AddRange(filtered);
}
while (current.Count == perPage && filtered.Any());
return result;
}
private List<Book> GetAuthorBooksPage(string foreignAuthorId, int perPage, int page)
{
var resource = GetAuthorBooksPageResource(foreignAuthorId, perPage, page);
var books = resource?.List.Where(x => x.Authors.First().Id.ToString() == foreignAuthorId)
.Select(MapBook)
.ToList() ??
new List<Book>();
books.ForEach(x => x.CleanTitle = x.Title.CleanAuthorName());
return books;
}
private AuthorBookListResource GetAuthorBooksPageResource(string foreignAuthorId, int perPage, int page)
{
_logger.Debug("Getting Author Books with GoodreadsId of {0}", foreignAuthorId);
var httpRequest = _requestBuilder.Create()
.SetSegment("route", $"author/list/{foreignAuthorId}.xml")
.AddQueryParam("per_page", perPage)
.AddQueryParam("page", page)
.AddQueryParam("sort", "popularity")
.Build();
httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true;
var httpResponse = _cachedHttpClient.Get(httpRequest, true, TimeSpan.FromDays(7));
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new AuthorNotFoundException(foreignAuthorId);
}
else if (httpResponse.StatusCode == HttpStatusCode.BadRequest)
{
throw new BadRequestException(foreignAuthorId);
}
else
{
throw new HttpException(httpRequest, httpResponse);
}
}
return httpResponse.Deserialize<AuthorBookListResource>();
}
private List<Series> GetAuthorSeries(string foreignAuthorId, List<Book> books)
{
_logger.Debug("Getting Author Series with GoodreadsId of {0}", foreignAuthorId);
var httpRequest = _requestBuilder.Create()
.SetSegment("route", $"series/list/{foreignAuthorId}.xml")
.Build();
httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true;
var httpResponse = _cachedHttpClient.Get(httpRequest, true, TimeSpan.FromDays(90));
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new AuthorNotFoundException(foreignAuthorId);
}
else if (httpResponse.StatusCode == HttpStatusCode.BadRequest)
{
throw new BadRequestException(foreignAuthorId);
}
else
{
throw new HttpException(httpRequest, httpResponse);
}
}
var resource = httpResponse.Deserialize<AuthorSeriesListResource>();
var result = new List<Series>();
var bookDict = books.ToDictionary(x => x.ForeignBookId);
// only take series where there are some works
// and the title is not null
// e.g. https://www.goodreads.com/series/work/6470221?format=xml is in series 260494
// which has a null title and is not shown anywhere on goodreads webpage
foreach (var seriesResource in resource.List.Where(x => x.Title.IsNotNullOrWhiteSpace() && x.Works.Any()))
{
var series = MapSeries(seriesResource);
series.LinkItems = new List<SeriesBookLink>();
var works = seriesResource.Works
.Where(x => x.BestBook.AuthorId.ToString() == foreignAuthorId &&
bookDict.ContainsKey(x.Id.ToString()));
foreach (var work in works)
{
series.LinkItems.Value.Add(new SeriesBookLink
{
Book = bookDict[work.Id.ToString()],
Series = series,
IsPrimary = true,
Position = work.UserPosition
});
}
if (series.LinkItems.Value.Any())
{
result.Add(series);
}
}
return result;
}
public SeriesResource GetSeriesInfo(int foreignSeriesId, bool useCache = true)
{
_logger.Debug("Getting Series with GoodreadsId of {0}", foreignSeriesId);
@ -379,233 +103,5 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
return httpResponse.Deserialize<ListResource>();
}
private bool TryGetBookInfo(string foreignEditionId, bool useCache, out Tuple<string, Book, List<AuthorMetadata>> result)
{
try
{
result = GetBookInfo(foreignEditionId, useCache);
return true;
}
catch (BookNotFoundException e)
{
result = null;
_logger.Warn(e, "Book not found");
return false;
}
}
public Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string foreignEditionId, bool useCache = true)
{
_logger.Debug("Getting Book with GoodreadsId of {0}", foreignEditionId);
var httpRequest = _requestBuilder.Create()
.SetSegment("route", $"api/book/basic_book_data/{foreignEditionId}")
.AddQueryParam("format", "xml")
.Build();
httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true;
var httpResponse = _cachedHttpClient.Get(httpRequest, useCache, TimeSpan.FromDays(90));
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new BookNotFoundException(foreignEditionId);
}
else if (httpResponse.StatusCode == HttpStatusCode.BadRequest)
{
throw new BadRequestException(foreignEditionId);
}
else
{
throw new HttpException(httpRequest, httpResponse);
}
}
var resource = httpResponse.Deserialize<BookResource>();
var book = MapBook(resource);
book.CleanTitle = Parser.Parser.CleanAuthorName(book.Title);
var authors = resource.Authors.SelectList(MapAuthor);
book.AuthorMetadata = authors.First();
return new Tuple<string, Book, List<AuthorMetadata>>(resource.Authors.First().Id.ToString(), book, authors);
}
private static AuthorMetadata MapAuthor(AuthorResource resource)
{
var author = new AuthorMetadata
{
ForeignAuthorId = resource.Id.ToString(),
TitleSlug = resource.Id.ToString(),
Name = resource.Name.CleanSpaces(),
Overview = resource.About,
Gender = resource.Gender,
Hometown = resource.Hometown,
Born = resource.BornOnDate,
Died = resource.DiedOnDate,
Status = resource.DiedOnDate < DateTime.UtcNow ? AuthorStatusType.Ended : AuthorStatusType.Continuing
};
author.SortName = author.Name.ToLower();
author.NameLastFirst = author.Name.ToLastFirst();
author.SortNameLastFirst = author.NameLastFirst.ToLower();
if (!NoPhotoRegex.IsMatch(resource.LargeImageUrl))
{
author.Images.Add(new MediaCover.MediaCover
{
Url = FullSizeImageRegex.Replace(resource.LargeImageUrl),
CoverType = MediaCoverTypes.Poster
});
}
author.Links.Add(new Links { Url = resource.Link, Name = "Goodreads" });
return author;
}
private static AuthorMetadata MapAuthor(AuthorSummaryResource resource)
{
var author = new AuthorMetadata
{
ForeignAuthorId = resource.Id.ToString(),
Name = resource.Name.CleanSpaces(),
TitleSlug = resource.Id.ToString()
};
author.SortName = author.Name.ToLower();
author.NameLastFirst = author.Name.ToLastFirst();
author.SortNameLastFirst = author.NameLastFirst.ToLower();
if (resource.RatingsCount.HasValue)
{
author.Ratings = new Ratings
{
Votes = resource.RatingsCount ?? 0,
Value = resource.AverageRating ?? 0
};
}
if (!NoPhotoRegex.IsMatch(resource.ImageUrl))
{
author.Images.Add(new MediaCover.MediaCover
{
Url = FullSizeImageRegex.Replace(resource.ImageUrl),
CoverType = MediaCoverTypes.Poster
});
}
return author;
}
private static Series MapSeries(SeriesResource resource)
{
var series = new Series
{
ForeignSeriesId = resource.Id.ToString(),
Title = resource.Title,
Description = resource.Description,
Numbered = resource.IsNumbered,
WorkCount = resource.SeriesWorksCount,
PrimaryWorkCount = resource.PrimaryWorksCount
};
return series;
}
private static Book MapBook(BookResource resource)
{
var book = new Book
{
ForeignBookId = resource.Work.Id.ToString(),
Title = (resource.Work.OriginalTitle ?? resource.TitleWithoutSeries).CleanSpaces(),
TitleSlug = resource.Work.Id.ToString(),
ReleaseDate = resource.Work.OriginalPublicationDate ?? resource.PublicationDate,
Ratings = new Ratings { Votes = resource.Work.RatingsCount, Value = resource.Work.AverageRating },
AnyEditionOk = true
};
if (resource.EditionsUrl != null)
{
book.Links.Add(new Links { Url = resource.EditionsUrl, Name = "Goodreads Editions" });
}
var edition = new Edition
{
ForeignEditionId = resource.Id.ToString(),
TitleSlug = resource.Id.ToString(),
Isbn13 = resource.Isbn13,
Asin = resource.Asin ?? resource.KindleAsin,
Title = resource.TitleWithoutSeries,
Language = resource.LanguageCode,
Overview = resource.Description,
Format = resource.Format,
IsEbook = resource.IsEbook,
Disambiguation = resource.EditionInformation,
Publisher = resource.Publisher,
PageCount = resource.Pages,
ReleaseDate = resource.PublicationDate,
Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating },
Monitored = true
};
if (resource.ImageUrl.IsNotNullOrWhiteSpace() && !NoPhotoRegex.IsMatch(resource.ImageUrl))
{
edition.Images.Add(new MediaCover.MediaCover
{
Url = FullSizeImageRegex.Replace(resource.ImageUrl),
CoverType = MediaCoverTypes.Cover
});
}
edition.Links.Add(new Links { Url = resource.Url, Name = "Goodreads Book" });
book.Editions = new List<Edition> { edition };
Debug.Assert(!book.Editions.Value.Any() || book.Editions.Value.Count(x => x.Monitored) == 1, "one edition monitored");
book.SeriesLinks = MapSearchSeries(resource.Title, resource.TitleWithoutSeries);
return book;
}
public static List<SeriesBookLink> MapSearchSeries(string title, string titleWithoutSeries)
{
if (title != titleWithoutSeries &&
title.Substring(0, titleWithoutSeries.Length) == titleWithoutSeries)
{
var seriesText = title.Substring(titleWithoutSeries.Length);
foreach (var regex in SeriesRegex)
{
var match = regex.Match(seriesText);
if (match.Success)
{
var series = match.Groups["series"].Value;
var position = match.Groups["position"].Value;
return new List<SeriesBookLink>
{
new SeriesBookLink
{
Series = new Series
{
Title = series
},
Position = position
}
};
}
}
}
return new List<SeriesBookLink>();
}
}
}

@ -1,59 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Http;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.MetadataSource.Goodreads
{
public class GoodreadsSearchProxy : ISearchForNewAuthor, ISearchForNewBook, ISearchForNewEntity
public interface IGoodreadsSearchProxy
{
private static readonly RegexReplace FullSizeImageRegex = new RegexReplace(@"\._[SU][XY]\d+_.jpg$",
".jpg",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex DuplicateSpacesRegex = new Regex(@"\s{2,}", RegexOptions.Compiled);
private static readonly Regex NoPhotoRegex = new Regex(@"/nophoto/(book|user)/",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly List<Regex> SeriesRegex = new List<Regex>
{
new Regex(@"\((?<series>[^,]+),\s+#(?<position>[\w\d\.]+)\)$", RegexOptions.Compiled),
new Regex(@"(The\s+(?<series>.+)\s+Series\s+Book\s+(?<position>[\w\d\.]+)\)$)", RegexOptions.Compiled)
};
public List<SearchJsonResource> Search(string query);
}
public class GoodreadsSearchProxy : IGoodreadsSearchProxy
{
private readonly ICachedHttpResponseService _cachedHttpClient;
private readonly Logger _logger;
private readonly IProvideBookInfo _bookInfo;
private readonly IAuthorService _authorService;
private readonly IBookService _bookService;
private readonly IEditionService _editionService;
private readonly IHttpRequestBuilderFactory _searchBuilder;
private readonly ICached<HashSet<string>> _cache;
public GoodreadsSearchProxy(ICachedHttpResponseService cachedHttpClient,
IProvideBookInfo bookInfo,
IAuthorService authorService,
IBookService bookService,
IEditionService editionService,
Logger logger,
ICacheManager cacheManager)
Logger logger)
{
_cachedHttpClient = cachedHttpClient;
_bookInfo = bookInfo;
_authorService = authorService;
_bookService = bookService;
_editionService = editionService;
_cache = cacheManager.GetCache<HashSet<string>>(GetType());
_logger = logger;
_searchBuilder = new HttpRequestBuilder("https://www.goodreads.com/book/auto_complete")
@ -64,127 +31,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
.CreateFactory();
}
public List<Author> SearchForNewAuthor(string title)
{
var books = SearchForNewBook(title, null);
return books.Select(x => x.Author.Value).ToList();
}
public List<Book> SearchForNewBook(string title, string author)
{
try
{
var lowerTitle = title.ToLowerInvariant();
var split = lowerTitle.Split(':');
var prefix = split[0];
if (split.Length == 2 && new[] { "readarr", "readarrid", "goodreads", "isbn", "asin" }.Contains(prefix))
{
var slug = split[1].Trim();
if (slug.IsNullOrWhiteSpace() || slug.Any(char.IsWhiteSpace))
{
return new List<Book>();
}
if (prefix == "goodreads" || prefix == "readarr" || prefix == "readarrid")
{
var isValid = int.TryParse(slug, out var searchId);
if (!isValid)
{
return new List<Book>();
}
return SearchByGoodreadsId(searchId);
}
else if (prefix == "isbn")
{
return SearchByIsbn(slug);
}
else if (prefix == "asin")
{
return SearchByAsin(slug);
}
}
var q = title.ToLower().Trim();
if (author != null)
{
q += " " + author;
}
return SearchByField("all", q);
}
catch (HttpException)
{
throw new GoodreadsException("Search for '{0}' failed. Unable to communicate with Goodreads.", title);
}
catch (Exception ex)
{
_logger.Warn(ex, ex.Message);
throw new GoodreadsException("Search for '{0}' failed. Invalid response received from Goodreads.",
title);
}
}
public List<Book> SearchByIsbn(string isbn)
{
return SearchByField("isbn", isbn, e => e.Isbn13 = isbn);
}
public List<Book> SearchByAsin(string asin)
{
return SearchByField("asin", asin, e => e.Asin = asin);
}
public List<Book> SearchByGoodreadsId(int id)
{
try
{
var remote = _bookInfo.GetBookInfo(id.ToString());
var book = _bookService.FindById(remote.Item2.ForeignBookId);
var result = book ?? remote.Item2;
// at this point, book could have the wrong edition.
// Check if we already have the correct edition.
var remoteEdition = remote.Item2.Editions.Value.Single(x => x.Monitored);
var localEdition = _editionService.GetEditionByForeignEditionId(remoteEdition.ForeignEditionId);
if (localEdition != null)
{
result.Editions = new List<Edition> { localEdition };
}
// If we don't have the correct edition in the response, add it in.
if (!result.Editions.Value.Any(x => x.ForeignEditionId == remoteEdition.ForeignEditionId))
{
result.Editions.Value.ForEach(x => x.Monitored = false);
result.Editions.Value.Add(remoteEdition);
}
var author = _authorService.FindById(remote.Item1);
if (author == null)
{
author = new Author
{
CleanName = Parser.Parser.CleanAuthorName(remote.Item2.AuthorMetadata.Value.Name),
Metadata = remote.Item2.AuthorMetadata.Value
};
}
result.Author = author;
return new List<Book> { result };
}
catch (BookNotFoundException)
{
return new List<Book>();
}
}
public List<Book> SearchByField(string field, string query, Action<Edition> applyData = null)
public List<SearchJsonResource> Search(string query)
{
try
{
@ -194,125 +41,17 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
var response = _cachedHttpClient.Get<List<SearchJsonResource>>(httpRequest, true, TimeSpan.FromDays(5));
return response.Resource.SelectList(x =>
MapJsonSearchResult(x, response.Resource.Count == 1 ? applyData : null));
return response.Resource;
}
catch (HttpException)
{
throw new GoodreadsException("Search for {0} '{1}' failed. Unable to communicate with Goodreads.", field, query);
throw new GoodreadsException("Search for '{0}' failed. Unable to communicate with Goodreads.", query);
}
catch (Exception ex)
{
_logger.Warn(ex, ex.Message);
throw new GoodreadsException("Search for {0} '{1}' failed. Invalid response received from Goodreads.", field, query);
}
}
public List<object> SearchForNewEntity(string title)
{
var books = SearchForNewBook(title, null);
var result = new List<object>();
foreach (var book in books)
{
var author = book.Author.Value;
if (!result.Contains(author))
{
result.Add(author);
}
result.Add(book);
}
return result;
}
private Book MapJsonSearchResult(SearchJsonResource resource, Action<Edition> applyData = null)
{
var book = _bookService.FindById(resource.WorkId.ToString());
var edition = _editionService.GetEditionByForeignEditionId(resource.BookId.ToString());
if (edition == null)
{
edition = new Edition
{
ForeignEditionId = resource.BookId.ToString(),
Title = resource.BookTitleBare,
TitleSlug = resource.BookId.ToString(),
Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating },
PageCount = resource.PageCount,
Overview = resource.Description?.Html ?? string.Empty
};
if (applyData != null)
{
applyData(edition);
}
}
edition.Monitored = true;
edition.ManualAdd = true;
if (resource.ImageUrl.IsNotNullOrWhiteSpace() && !NoPhotoRegex.IsMatch(resource.ImageUrl))
{
edition.Images.Add(new MediaCover.MediaCover
{
Url = FullSizeImageRegex.Replace(resource.ImageUrl),
CoverType = MediaCoverTypes.Cover
});
throw new GoodreadsException("Search for '{0}' failed. Invalid response received from Goodreads.", query);
}
if (book == null)
{
book = new Book
{
ForeignBookId = resource.WorkId.ToString(),
Title = resource.BookTitleBare,
TitleSlug = resource.WorkId.ToString(),
Ratings = new Ratings { Votes = resource.RatingsCount, Value = resource.AverageRating },
AnyEditionOk = true
};
}
if (book.Editions != null)
{
if (book.Editions.Value.Any())
{
edition.Monitored = false;
}
book.Editions.Value.Add(edition);
}
else
{
book.Editions = new List<Edition> { edition };
}
var authorId = resource.Author.Id.ToString();
var author = _authorService.FindById(authorId);
if (author == null)
{
author = new Author
{
CleanName = Parser.Parser.CleanAuthorName(resource.Author.Name),
Metadata = new AuthorMetadata()
{
ForeignAuthorId = resource.Author.Id.ToString(),
Name = DuplicateSpacesRegex.Replace(resource.Author.Name, " "),
TitleSlug = resource.Author.Id.ToString()
}
};
}
book.Author = author;
book.AuthorMetadata = book.Author.Value.Metadata.Value;
book.AuthorMetadataId = author.AuthorMetadataId;
book.CleanTitle = book.Title.CleanAuthorName();
book.SeriesLinks = GoodreadsProxy.MapSearchSeries(resource.Title, resource.BookTitleBare);
return book;
}
}
}

@ -8,6 +8,7 @@ namespace NzbDrone.Core.MetadataSource
List<Book> SearchForNewBook(string title, string author);
List<Book> SearchByIsbn(string isbn);
List<Book> SearchByAsin(string asin);
List<Book> SearchByGoodreadsId(int goodreadsId);
List<Book> SearchByGoodreadsWorkId(int goodreadsId);
List<Book> SearchByGoodreadsBookId(int goodreadsId);
}
}

@ -16,7 +16,7 @@ namespace NzbDrone.Integration.Test.ApiTests
EnsureNoAuthor("14586394", "Andrew Hunter Murray");
var tag = EnsureTag("abc");
var author = Author.Lookup("readarr:43765115").Single();
var author = Author.Lookup("edition:43765115").Single();
author.QualityProfileId = 1;
author.MetadataProfileId = 1;
@ -36,7 +36,7 @@ namespace NzbDrone.Integration.Test.ApiTests
{
EnsureNoAuthor("14586394", "Andrew Hunter Murray");
var author = Author.Lookup("readarr:43765115").Single();
var author = Author.Lookup("edition:43765115").Single();
author.Path = Path.Combine(AuthorRootFolder, author.AuthorName);
@ -49,7 +49,7 @@ namespace NzbDrone.Integration.Test.ApiTests
{
EnsureNoAuthor("14586394", "Andrew Hunter Murray");
var author = Author.Lookup("readarr:43765115").Single();
var author = Author.Lookup("edition:43765115").Single();
author.QualityProfileId = 1;
@ -62,7 +62,7 @@ namespace NzbDrone.Integration.Test.ApiTests
{
EnsureNoAuthor("14586394", "Andrew Hunter Murray");
var author = Author.Lookup("readarr:43765115").Single();
var author = Author.Lookup("edition:43765115").Single();
author.QualityProfileId = 1;
author.MetadataProfileId = 1;

@ -19,7 +19,7 @@ namespace NzbDrone.Integration.Test.ApiTests
[Test]
public void lookup_new_author_by_goodreads_book_id()
{
var author = Author.Lookup("readarr:1");
var author = Author.Lookup("edition:1");
author.Should().NotBeEmpty();
author.Should().Contain(c => c.AuthorName == "J.K. Rowling");

@ -243,7 +243,7 @@ namespace NzbDrone.Integration.Test
if (result == null)
{
var lookup = Author.Lookup("readarr:" + goodreadsEditionId);
var lookup = Author.Lookup("edition:" + goodreadsEditionId);
var author = lookup.First();
author.QualityProfileId = 1;
author.MetadataProfileId = 1;

Loading…
Cancel
Save