Move all data fetching to BookInfo v2

pull/1449/head
BookInfo 2 years ago committed by ta264
parent 33e1c4a537
commit 1491788081

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

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

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

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

@ -7,6 +7,7 @@ using NzbDrone.Core.Books;
using NzbDrone.Core.ImportLists; using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
@ -30,27 +31,23 @@ namespace NzbDrone.Core.Test.ImportListTests
.Setup(v => v.Fetch()) .Setup(v => v.Fetch())
.Returns(_importListReports); .Returns(_importListReports);
Mocker.GetMock<ISearchForNewAuthor>() Mocker.GetMock<IGoodreadsSearchProxy>()
.Setup(v => v.SearchForNewAuthor(It.IsAny<string>())) .Setup(v => v.Search(It.IsAny<string>()))
.Returns(new List<Author>()); .Returns(new List<SearchJsonResource>());
Mocker.GetMock<ISearchForNewBook>() Mocker.GetMock<IGoodreadsProxy>()
.Setup(v => v.SearchForNewBook(It.IsAny<string>(), It.IsAny<string>())) .Setup(v => v.GetBookInfo(It.IsAny<string>(), true))
.Returns(new List<Book>()); .Returns<string, bool>((id, useCache) => Builder<Book>
.CreateNew()
Mocker.GetMock<ISearchForNewBook>() .With(b => b.AuthorMetadata = Builder<AuthorMetadata>.CreateNew().Build())
.Setup(v => v.SearchByGoodreadsId(It.IsAny<int>())) .With(b => b.ForeignBookId = "4321")
.Returns<int>(x => Builder<Book> .With(b => b.Editions = Builder<Edition>
.CreateListOfSize(1) .CreateListOfSize(1)
.TheFirst(1) .TheFirst(1)
.With(b => b.ForeignBookId = "4321") .With(e => e.ForeignEditionId = id.ToString())
.With(b => b.Editions = Builder<Edition> .With(e => e.Monitored = true)
.CreateListOfSize(1) .BuildList())
.TheFirst(1) .Build());
.With(e => e.ForeignEditionId = x.ToString())
.With(e => e.Monitored = true)
.BuildList())
.BuildList());
Mocker.GetMock<IImportListFactory>() Mocker.GetMock<IImportListFactory>()
.Setup(v => v.Get(It.IsAny<int>())) .Setup(v => v.Get(It.IsAny<int>()))
@ -111,8 +108,8 @@ namespace NzbDrone.Core.Test.ImportListTests
private void WithExistingBook() private void WithExistingBook()
{ {
Mocker.GetMock<IBookService>() Mocker.GetMock<IBookService>()
.Setup(v => v.FindById(_importListReports.First().EditionGoodreadsId)) .Setup(v => v.FindById("4321"))
.Returns(new Book { Id = 1, ForeignBookId = _importListReports.First().EditionGoodreadsId }); .Returns(new Book { Id = 1, ForeignBookId = _importListReports.First().BookGoodreadsId });
} }
private void WithExcludedAuthor() private void WithExcludedAuthor()
@ -153,8 +150,8 @@ namespace NzbDrone.Core.Test.ImportListTests
{ {
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewAuthor>() Mocker.GetMock<IGoodreadsSearchProxy>()
.Verify(v => v.SearchForNewAuthor(It.IsAny<string>()), Times.Once()); .Verify(v => v.Search(It.IsAny<string>()), Times.Once());
} }
[Test] [Test]
@ -173,8 +170,8 @@ namespace NzbDrone.Core.Test.ImportListTests
WithBook(); WithBook();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewBook>() Mocker.GetMock<IGoodreadsSearchProxy>()
.Verify(v => v.SearchForNewBook(It.IsAny<string>(), It.IsAny<string>()), Times.Once()); .Verify(v => v.Search(It.IsAny<string>()), Times.Once());
} }
[Test] [Test]
@ -184,8 +181,8 @@ namespace NzbDrone.Core.Test.ImportListTests
WithBookId(); WithBookId();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewBook>() Mocker.GetMock<IGoodreadsSearchProxy>()
.Verify(v => v.SearchForNewBook(It.IsAny<string>(), It.IsAny<string>()), Times.Never()); .Verify(v => v.Search(It.IsAny<string>()), Times.Never());
} }
[Test] [Test]
@ -196,11 +193,11 @@ namespace NzbDrone.Core.Test.ImportListTests
WithBookId(); WithBookId();
Subject.Execute(new ImportListSyncCommand()); Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewAuthor>() Mocker.GetMock<IGoodreadsSearchProxy>()
.Verify(v => v.SearchForNewAuthor(It.IsAny<string>()), Times.Never()); .Verify(v => v.Search(It.IsAny<string>()), Times.Never());
Mocker.GetMock<ISearchForNewBook>() Mocker.GetMock<IGoodreadsSearchProxy>()
.Verify(v => v.SearchForNewBook(It.IsAny<string>(), It.IsAny<string>()), Times.Never()); .Verify(v => v.Search(It.IsAny<string>()), Times.Never());
} }
[Test] [Test]

@ -17,7 +17,7 @@ namespace NzbDrone.Core.Test.MediaFiles.BookImport.Identification
public void should_not_throw_on_goodreads_exception() public void should_not_throw_on_goodreads_exception()
{ {
Mocker.GetMock<ISearchForNewBook>() Mocker.GetMock<ISearchForNewBook>()
.Setup(s => s.SearchForNewBook(It.IsAny<string>(), It.IsAny<string>())) .Setup(s => s.SearchForNewBook(It.IsAny<string>(), It.IsAny<string>(), true))
.Throws(new GoodreadsException("Bad search")); .Throws(new GoodreadsException("Bad search"));
var edition = new LocalEdition var edition = new LocalEdition

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

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

@ -0,0 +1,111 @@
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, false);
result.Should().NotBeEmpty();
result[0].Editions.Value[0].Title.Should().Be(expected);
ExceptionVerification.IgnoreWarns();
ExceptionVerification.IgnoreErrors();
}
[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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Core.Books;
using NzbDrone.Core.Http; using NzbDrone.Core.Http;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.MetadataSource.Goodreads; using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@ -23,52 +19,35 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
{ {
UseRealHttp(); UseRealHttp();
Mocker.SetConstant<IProvideBookInfo>(Mocker.Resolve<GoodreadsProxy>());
var httpClient = Mocker.Resolve<IHttpClient>();
Mocker.GetMock<ICachedHttpResponseService>() Mocker.GetMock<ICachedHttpResponseService>()
.Setup(x => x.Get<List<SearchJsonResource>>(It.IsAny<HttpRequest>(), It.IsAny<bool>(), It.IsAny<TimeSpan>())) .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)); .Returns((HttpRequest request, bool useCache, TimeSpan ttl) => Mocker.Resolve<IHttpClient>().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("Robert Harris", 575)]
[TestCase("James Patterson", "James Patterson")] [TestCase("James Patterson", 3780)]
[TestCase("Antoine de Saint-Exupéry", "Antoine de Saint-Exupéry")] [TestCase("Antoine de Saint-Exupéry", 1020792)]
public void successful_author_search(string title, string expected) public void successful_author_search(string title, int expected)
{ {
var result = Subject.SearchForNewAuthor(title); var result = Subject.Search(title);
result.Should().NotBeEmpty(); result.Should().NotBeEmpty();
result[0].Name.Should().Be(expected); result[0].Author.Id.Should().Be(expected);
ExceptionVerification.IgnoreWarns(); ExceptionVerification.IgnoreWarns();
} }
[TestCase("Harry Potter and the sorcerer's stone", null, "Harry Potter and the Sorcerer's Stone")] [TestCase("Harry Potter and the sorcerer's stone", 3)]
[TestCase("readarr:3", null, "Harry Potter and the Philosopher's Stone")] [TestCase("B0192CTMYG", 42844155)]
[TestCase("readarr: 3", null, "Harry Potter and the Philosopher's Stone")] [TestCase("9780439554930", 48517161)]
[TestCase("readarrid:3", null, "Harry Potter and the Philosopher's Stone")] public void successful_book_search(string title, int expected)
[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)
{ {
var result = Subject.SearchForNewBook(title, author); var result = Subject.Search(title);
result.Should().NotBeEmpty(); result.Should().NotBeEmpty();
result[0].Title.Should().Be(expected); result[0].BookId.Should().Be(expected);
ExceptionVerification.IgnoreWarns(); ExceptionVerification.IgnoreWarns();
} }
@ -81,54 +60,10 @@ namespace NzbDrone.Core.Test.MetadataSource.Goodreads
[TestCase("adjalkwdjkalwdjklawjdlKAJD")] [TestCase("adjalkwdjkalwdjklawjdlKAJD")]
public void no_author_search_result(string term) public void no_author_search_result(string term)
{ {
var result = Subject.SearchForNewAuthor(term); var result = Subject.Search(term);
result.Should().BeEmpty(); result.Should().BeEmpty();
ExceptionVerification.IgnoreWarns(); 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(); .Build();
} }
private void GivenValidBook(string readarrId) private void GivenValidBook(string bookId, string editionId)
{ {
_fakeBook = Builder<Book> _fakeBook = Builder<Book>
.CreateNew() .CreateNew()
.With(x => x.Editions = Builder<Edition> .With(x => x.Editions = Builder<Edition>
.CreateListOfSize(1) .CreateListOfSize(1)
.TheFirst(1) .TheFirst(1)
.With(e => e.ForeignEditionId = readarrId) .With(e => e.ForeignEditionId = editionId)
.With(e => e.Monitored = true) .With(e => e.Monitored = true)
.BuildList()) .BuildList())
.Build(); .Build();
Mocker.GetMock<IProvideBookInfo>() Mocker.GetMock<IProvideBookInfo>()
.Setup(s => s.GetBookInfo(readarrId, true)) .Setup(s => s.GetBookInfo(bookId))
.Returns(Tuple.Create(_fakeAuthor.Metadata.Value.ForeignAuthorId, .Returns(Tuple.Create(_fakeAuthor.Metadata.Value.ForeignAuthorId,
_fakeBook, _fakeBook,
new List<AuthorMetadata> { _fakeAuthor.Metadata.Value })); new List<AuthorMetadata> { _fakeAuthor.Metadata.Value }));
@ -85,7 +85,7 @@ namespace NzbDrone.Core.Test.MusicTests
{ {
var newBook = BookToAdd("edition", "book", "author"); var newBook = BookToAdd("edition", "book", "author");
GivenValidBook("edition"); GivenValidBook("book", "edition");
GivenValidPath(); GivenValidPath();
var book = Subject.AddBook(newBook); var book = Subject.AddBook(newBook);
@ -99,7 +99,7 @@ namespace NzbDrone.Core.Test.MusicTests
var newBook = BookToAdd("edition", "book", "author"); var newBook = BookToAdd("edition", "book", "author");
Mocker.GetMock<IProvideBookInfo>() Mocker.GetMock<IProvideBookInfo>()
.Setup(s => s.GetBookInfo("edition", true)) .Setup(s => s.GetBookInfo("book"))
.Throws(new BookNotFoundException("edition")); .Throws(new BookNotFoundException("edition"));
Assert.Throws<ValidationException>(() => Subject.AddBook(newBook)); Assert.Throws<ValidationException>(() => Subject.AddBook(newBook));

@ -28,12 +28,16 @@ namespace NzbDrone.Core.Test.MusicTests
.With(s => s.Path = null) .With(s => s.Path = null)
.Build(); .Build();
_fakeAuthor.Books = new List<Book>(); _fakeAuthor.Books = new List<Book>();
Mocker.GetMock<IAuthorService>()
.Setup(s => s.AddAuthor(It.IsAny<Author>(), It.IsAny<bool>()))
.Returns<Author, bool>((author, _) => author);
} }
private void GivenValidAuthor(string readarrId) private void GivenValidAuthor(string readarrId)
{ {
Mocker.GetMock<IProvideAuthorInfo>() Mocker.GetMock<IProvideAuthorInfo>()
.Setup(s => s.GetAuthorInfo(readarrId, true, false)) .Setup(s => s.GetAuthorInfo(readarrId, false))
.Returns(_fakeAuthor); .Returns(_fakeAuthor);
} }
@ -113,7 +117,7 @@ namespace NzbDrone.Core.Test.MusicTests
}; };
Mocker.GetMock<IProvideAuthorInfo>() Mocker.GetMock<IProvideAuthorInfo>()
.Setup(s => s.GetAuthorInfo(newAuthor.ForeignAuthorId, true, false)) .Setup(s => s.GetAuthorInfo(newAuthor.ForeignAuthorId, false))
.Throws(new AuthorNotFoundException(newAuthor.ForeignAuthorId)); .Throws(new AuthorNotFoundException(newAuthor.ForeignAuthorId));
Mocker.GetMock<IAddAuthorValidator>() Mocker.GetMock<IAddAuthorValidator>()

@ -65,7 +65,7 @@ namespace NzbDrone.Core.Test.MusicTests
.Returns(_remoteBooks); .Returns(_remoteBooks);
Mocker.GetMock<IProvideAuthorInfo>() Mocker.GetMock<IProvideAuthorInfo>()
.Setup(s => s.GetAuthorAndBooks(It.IsAny<string>(), It.IsAny<double>())) .Setup(s => s.GetAuthorInfo(It.IsAny<string>(), true))
.Callback(() => { throw new AuthorNotFoundException(_author.ForeignAuthorId); }); .Callback(() => { throw new AuthorNotFoundException(_author.ForeignAuthorId); });
Mocker.GetMock<IMediaFileService>() Mocker.GetMock<IMediaFileService>()
@ -92,7 +92,7 @@ namespace NzbDrone.Core.Test.MusicTests
private void GivenNewAuthorInfo(Author author) private void GivenNewAuthorInfo(Author author)
{ {
Mocker.GetMock<IProvideAuthorInfo>() Mocker.GetMock<IProvideAuthorInfo>()
.Setup(s => s.GetAuthorAndBooks(_author.ForeignAuthorId, It.IsAny<double>())) .Setup(s => s.GetAuthorInfo(_author.ForeignAuthorId, true))
.Returns(author); .Returns(author);
} }

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

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

@ -58,9 +58,7 @@ namespace NzbDrone.Core.Books
newAuthor.AuthorMetadataId = newAuthor.Metadata.Value.Id; newAuthor.AuthorMetadataId = newAuthor.Metadata.Value.Id;
// add the author itself // add the author itself
_authorService.AddAuthor(newAuthor, doRefresh); return _authorService.AddAuthor(newAuthor, doRefresh);
return newAuthor;
} }
public List<Author> AddAuthors(List<Author> newAuthors, bool doRefresh = true) public List<Author> AddAuthors(List<Author> newAuthors, bool doRefresh = true)
@ -97,7 +95,7 @@ namespace NzbDrone.Core.Books
try try
{ {
author = _authorInfo.GetAuthorInfo(newAuthor.Metadata.Value.ForeignAuthorId, includeBooks: false); author = _authorInfo.GetAuthorInfo(newAuthor.Metadata.Value.ForeignAuthorId, false);
} }
catch (AuthorNotFoundException) catch (AuthorNotFoundException)
{ {

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

@ -84,11 +84,11 @@ namespace NzbDrone.Core.Books
_logger = logger; _logger = logger;
} }
private Author GetSkyhookData(string foreignId, double minPopularity) private Author GetSkyhookData(string foreignId)
{ {
try try
{ {
return _authorInfo.GetAuthorAndBooks(foreignId, minPopularity); return _authorInfo.GetAuthorInfo(foreignId);
} }
catch (AuthorNotFoundException) catch (AuthorNotFoundException)
{ {
@ -347,7 +347,7 @@ namespace NzbDrone.Core.Books
{ {
try try
{ {
var data = GetSkyhookData(author.ForeignAuthorId, author.MetadataProfile.Value.MinPopularity); var data = GetSkyhookData(author.ForeignAuthorId);
updated |= RefreshEntityInfo(author, null, data, true, false, null); updated |= RefreshEntityInfo(author, null, data, true, false, null);
} }
catch (Exception e) catch (Exception e)
@ -397,7 +397,7 @@ namespace NzbDrone.Core.Books
try try
{ {
LogProgress(author); LogProgress(author);
var data = GetSkyhookData(author.ForeignAuthorId, author.MetadataProfile.Value.MinPopularity); var data = GetSkyhookData(author.ForeignAuthorId);
updated |= RefreshEntityInfo(author, null, data, manualTrigger, false, message.LastStartTime); updated |= RefreshEntityInfo(author, null, data, manualTrigger, false, message.LastStartTime);
} }
catch (Exception e) catch (Exception e)

@ -75,12 +75,10 @@ namespace NzbDrone.Core.Books
private Author GetSkyhookData(Book book) private Author GetSkyhookData(Book book)
{ {
var foreignId = book.Editions.Value.First().ForeignEditionId;
try try
{ {
var tuple = _bookInfo.GetBookInfo(foreignId, false); var tuple = _bookInfo.GetBookInfo(book.ForeignBookId);
var author = _authorInfo.GetAuthorInfo(tuple.Item1, false); var author = _authorInfo.GetAuthorInfo(tuple.Item1);
var newbook = tuple.Item2; var newbook = tuple.Item2;
newbook.Author = author; newbook.Author = author;
@ -88,19 +86,12 @@ namespace NzbDrone.Core.Books
newbook.AuthorMetadataId = book.AuthorMetadataId; newbook.AuthorMetadataId = book.AuthorMetadataId;
newbook.AuthorMetadata.Value.Id = 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 }; author.Books = new List<Book> { newbook };
return author; return author;
} }
catch (BookNotFoundException) catch (BookNotFoundException)
{ {
_logger.Error($"Could not find book with id {foreignId}"); _logger.Error($"Could not find book with id {book.ForeignBookId}");
} }
return null; return null;
@ -119,6 +110,7 @@ namespace NzbDrone.Core.Books
if (book == null) if (book == null)
{ {
data = GetSkyhookData(local);
book = data.Books.Value.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId); book = data.Books.Value.SingleOrDefault(x => x.ForeignBookId == local.ForeignBookId);
} }

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

@ -18,6 +18,8 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
{ {
mapper.Execute(@"DELETE FROM HttpResponse WHERE Expiry < date('now')"); mapper.Execute(@"DELETE FROM HttpResponse WHERE Expiry < date('now')");
} }
_database.Vacuum();
} }
} }
} }

@ -1,5 +1,6 @@
using System; using System;
using System.Net; using System.Net;
using NLog;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
namespace NzbDrone.Core.Http namespace NzbDrone.Core.Http
@ -15,12 +16,15 @@ namespace NzbDrone.Core.Http
{ {
private readonly ICachedHttpResponseRepository _repo; private readonly ICachedHttpResponseRepository _repo;
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly Logger _logger;
public CachedHttpResponseService(ICachedHttpResponseRepository httpResponseRepository, public CachedHttpResponseService(ICachedHttpResponseRepository httpResponseRepository,
IHttpClient httpClient) IHttpClient httpClient,
Logger logger)
{ {
_repo = httpResponseRepository; _repo = httpResponseRepository;
_httpClient = httpClient; _httpClient = httpClient;
_logger = logger;
} }
public HttpResponse Get(HttpRequest request, bool useCache, TimeSpan ttl) public HttpResponse Get(HttpRequest request, bool useCache, TimeSpan ttl)
@ -29,6 +33,7 @@ namespace NzbDrone.Core.Http
if (useCache && cached != null && cached.Expiry > DateTime.UtcNow) if (useCache && cached != null && cached.Expiry > DateTime.UtcNow)
{ {
_logger.Trace($"Returning cached response for [GET] {request.Url}");
return new HttpResponse(request, new HttpHeader(), cached.Value, (HttpStatusCode)cached.StatusCode); return new HttpResponse(request, new HttpHeader(), cached.Value, (HttpStatusCode)cached.StatusCode);
} }

@ -6,11 +6,12 @@ using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Books; using NzbDrone.Core.Books;
using NzbDrone.Core.Books.Commands; using NzbDrone.Core.Books.Commands;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.ImportLists.Exclusions; using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource.Goodreads;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
namespace NzbDrone.Core.ImportLists namespace NzbDrone.Core.ImportLists
@ -20,10 +21,11 @@ namespace NzbDrone.Core.ImportLists
private readonly IImportListFactory _importListFactory; private readonly IImportListFactory _importListFactory;
private readonly IImportListExclusionService _importListExclusionService; private readonly IImportListExclusionService _importListExclusionService;
private readonly IFetchAndParseImportList _listFetcherAndParser; private readonly IFetchAndParseImportList _listFetcherAndParser;
private readonly ISearchForNewBook _bookSearchService; private readonly IGoodreadsProxy _goodreadsProxy;
private readonly ISearchForNewAuthor _authorSearchService; private readonly IGoodreadsSearchProxy _goodreadsSearchProxy;
private readonly IAuthorService _authorService; private readonly IAuthorService _authorService;
private readonly IBookService _bookService; private readonly IBookService _bookService;
private readonly IEditionService _editionService;
private readonly IAddAuthorService _addAuthorService; private readonly IAddAuthorService _addAuthorService;
private readonly IAddBookService _addBookService; private readonly IAddBookService _addBookService;
private readonly IEventAggregator _eventAggregator; private readonly IEventAggregator _eventAggregator;
@ -33,10 +35,11 @@ namespace NzbDrone.Core.ImportLists
public ImportListSyncService(IImportListFactory importListFactory, public ImportListSyncService(IImportListFactory importListFactory,
IImportListExclusionService importListExclusionService, IImportListExclusionService importListExclusionService,
IFetchAndParseImportList listFetcherAndParser, IFetchAndParseImportList listFetcherAndParser,
ISearchForNewBook bookSearchService, IGoodreadsProxy goodreadsProxy,
ISearchForNewAuthor authorSearchService, IGoodreadsSearchProxy goodreadsSearchProxy,
IAuthorService authorService, IAuthorService authorService,
IBookService bookService, IBookService bookService,
IEditionService editionService,
IAddAuthorService addAuthorService, IAddAuthorService addAuthorService,
IAddBookService addBookService, IAddBookService addBookService,
IEventAggregator eventAggregator, IEventAggregator eventAggregator,
@ -46,10 +49,11 @@ namespace NzbDrone.Core.ImportLists
_importListFactory = importListFactory; _importListFactory = importListFactory;
_importListExclusionService = importListExclusionService; _importListExclusionService = importListExclusionService;
_listFetcherAndParser = listFetcherAndParser; _listFetcherAndParser = listFetcherAndParser;
_bookSearchService = bookSearchService; _goodreadsProxy = goodreadsProxy;
_authorSearchService = authorSearchService; _goodreadsSearchProxy = goodreadsSearchProxy;
_authorService = authorService; _authorService = authorService;
_bookService = bookService; _bookService = bookService;
_editionService = editionService;
_addAuthorService = addAuthorService; _addAuthorService = addAuthorService;
_addBookService = addBookService; _addBookService = addBookService;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
@ -137,32 +141,55 @@ namespace NzbDrone.Core.ImportLists
private void MapBookReport(ImportListItemInfo report) private void MapBookReport(ImportListItemInfo report)
{ {
Book mappedBook;
if (report.EditionGoodreadsId.IsNotNullOrWhiteSpace() && int.TryParse(report.EditionGoodreadsId, out var goodreadsId)) if (report.EditionGoodreadsId.IsNotNullOrWhiteSpace() && int.TryParse(report.EditionGoodreadsId, out var goodreadsId))
{ {
var search = _bookSearchService.SearchByGoodreadsId(goodreadsId); // check the local DB
mappedBook = search.FirstOrDefault(x => x.Editions.Value.Any(e => int.TryParse(e.ForeignEditionId, out var editionId) && editionId == goodreadsId)); var edition = _editionService.GetEditionByForeignEditionId(report.EditionGoodreadsId);
if (edition != null)
{
var book = edition.Book.Value;
report.BookGoodreadsId = book.ForeignBookId;
report.Book = edition.Title;
report.Author ??= book.AuthorMetadata.Value.Name;
report.AuthorGoodreadsId ??= book.AuthorMetadata.Value.ForeignAuthorId;
return;
}
try
{
var remoteBook = _goodreadsProxy.GetBookInfo(report.EditionGoodreadsId);
_logger.Trace($"Mapped {report.EditionGoodreadsId} to [{remoteBook.ForeignBookId}] {remoteBook.Title}");
report.BookGoodreadsId = remoteBook.ForeignBookId;
report.Book = remoteBook.Title;
report.Author ??= remoteBook.AuthorMetadata.Value.Name;
report.AuthorGoodreadsId ??= remoteBook.AuthorMetadata.Value.Name;
}
catch (BookNotFoundException)
{
_logger.Debug($"Nothing found for edition [{report.EditionGoodreadsId}]");
report.EditionGoodreadsId = null;
}
} }
else else
{ {
mappedBook = _bookSearchService.SearchForNewBook(report.Book, report.Author).FirstOrDefault(); var mappedBook = _goodreadsSearchProxy.Search($"{report.Book} {report.Author}").FirstOrDefault();
}
// Break if we are looking for a book and cant find it. This will avoid us from adding the author and possibly getting it wrong. if (mappedBook == null)
if (mappedBook == null) {
{ _logger.Trace($"Nothing found for {report.Author} - {report.Book}");
_logger.Trace($"Nothing found for {report.EditionGoodreadsId}"); return;
report.EditionGoodreadsId = null; }
return;
}
_logger.Trace($"Mapped {report.EditionGoodreadsId} to {mappedBook}"); _logger.Trace($"Mapped {report.EditionGoodreadsId} to [{mappedBook.WorkId}] {mappedBook.BookTitleBare}");
report.BookGoodreadsId = mappedBook.ForeignBookId; report.BookGoodreadsId = mappedBook.WorkId.ToString();
report.Book = mappedBook.Title; report.Book = mappedBook.BookTitleBare;
report.Author ??= mappedBook.AuthorMetadata?.Value?.Name; report.Author ??= mappedBook.Author.Name;
report.AuthorGoodreadsId ??= mappedBook.AuthorMetadata?.Value?.ForeignAuthorId; report.AuthorGoodreadsId ??= mappedBook.Author.Id.ToString();
}
} }
private void ProcessBookReport(ImportListDefinition importList, ImportListItemInfo report, List<ImportListExclusion> listExclusions, List<Book> booksToAdd, List<Author> authorsToAdd) private void ProcessBookReport(ImportListDefinition importList, ImportListItemInfo report, List<ImportListExclusion> listExclusions, List<Book> booksToAdd, List<Author> authorsToAdd)
@ -297,10 +324,18 @@ namespace NzbDrone.Core.ImportLists
private void MapAuthorReport(ImportListItemInfo report) private void MapAuthorReport(ImportListItemInfo report)
{ {
var mappedAuthor = _authorSearchService.SearchForNewAuthor(report.Author) var mappedBook = _goodreadsSearchProxy.Search(report.Author).FirstOrDefault();
.FirstOrDefault();
report.AuthorGoodreadsId = mappedAuthor?.Metadata.Value?.ForeignAuthorId; if (mappedBook == null)
report.Author = mappedAuthor?.Metadata.Value?.Name; {
_logger.Trace($"Nothing found for {report.Author}");
return;
}
_logger.Trace($"Mapped {report.Author} to [{mappedBook.Author.Name}]");
report.Author = mappedBook.Author.Name;
report.AuthorGoodreadsId = mappedBook.Author.Id.ToString();
} }
private Author ProcessAuthorReport(ImportListDefinition importList, ImportListItemInfo report, List<ImportListExclusion> listExclusions, List<Author> authorsToAdd) private Author ProcessAuthorReport(ImportListDefinition importList, ImportListItemInfo report, List<ImportListExclusion> listExclusions, List<Author> authorsToAdd)

@ -582,7 +582,7 @@
"Search": "Search", "Search": "Search",
"SearchAll": "Search All", "SearchAll": "Search All",
"SearchBook": "Search Book", "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", "SearchForAllCutoffUnmetBooks": "Search for all Cutoff Unmet books",
"SearchForAllMissingBooks": "Search for all missing books", "SearchForAllMissingBooks": "Search for all missing books",
"SearchForMissing": "Search for Missing", "SearchForMissing": "Search for Missing",

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

@ -141,7 +141,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification
usedRemote = true; usedRemote = true;
} }
if (!candidateReleases.Any()) GetBestRelease(localBookRelease, candidateReleases, allLocalTracks, out var seenCandidate);
if (!seenCandidate)
{ {
// can't find any candidates even after using remote search // can't find any candidates even after using remote search
// populate the overrides and return // populate the overrides and return
@ -155,8 +157,6 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification
return; return;
} }
GetBestRelease(localBookRelease, candidateReleases, allLocalTracks);
// If the result isn't great and we haven't tried remote candidates, try looking for remote candidates // If the result isn't great and we haven't tried remote candidates, try looking for remote candidates
// Goodreads may have a better edition of a local book // Goodreads may have a better edition of a local book
if (localBookRelease.Distance.NormalizedDistance() > 0.15 && !usedRemote) if (localBookRelease.Distance.NormalizedDistance() > 0.15 && !usedRemote)
@ -169,7 +169,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification
candidateReleases = candidateReleases.Where(x => x.Edition.Book.Value.Id > 0); candidateReleases = candidateReleases.Where(x => x.Edition.Book.Value.Id > 0);
} }
GetBestRelease(localBookRelease, candidateReleases, allLocalTracks); GetBestRelease(localBookRelease, candidateReleases, allLocalTracks, out _);
} }
_logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms");
@ -179,7 +179,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification
_logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms"); _logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms");
} }
private void GetBestRelease(LocalEdition localBookRelease, IEnumerable<CandidateEdition> candidateReleases, List<LocalBook> extraTracksOnDisk) private void GetBestRelease(LocalEdition localBookRelease, IEnumerable<CandidateEdition> candidateReleases, List<LocalBook> extraTracksOnDisk, out bool seenCandidate)
{ {
var watch = System.Diagnostics.Stopwatch.StartNew(); var watch = System.Diagnostics.Stopwatch.StartNew();
@ -187,9 +187,12 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Identification
_logger.Trace("Processing files:\n{0}", string.Join("\n", localBookRelease.LocalBooks.Select(x => x.Path))); _logger.Trace("Processing files:\n{0}", string.Join("\n", localBookRelease.LocalBooks.Select(x => x.Path)));
double bestDistance = 1.0; double bestDistance = 1.0;
seenCandidate = false;
foreach (var candidateRelease in candidateReleases) foreach (var candidateRelease in candidateReleases)
{ {
seenCandidate = true;
var release = candidateRelease.Edition; var release = candidateRelease.Edition;
_logger.Debug($"Trying Release {release}"); _logger.Debug($"Trying Release {release}");
var rwatch = System.Diagnostics.Stopwatch.StartNew(); var rwatch = System.Diagnostics.Stopwatch.StartNew();

@ -346,6 +346,9 @@ namespace NzbDrone.Core.MediaFiles.BookImport
try try
{ {
dbAuthor = _addAuthorService.AddAuthor(author, false); dbAuthor = _addAuthorService.AddAuthor(author, false);
// this looks redundant but is necessary to get the LazyLoads populated
dbAuthor = _authorService.GetAuthor(dbAuthor.Id);
addedAuthors.Add(dbAuthor); addedAuthors.Add(dbAuthor);
} }
catch (Exception e) catch (Exception e)

@ -308,7 +308,7 @@ namespace NzbDrone.Core.MediaFiles.BookImport.Manual
var edition = _editionService.GetEditionByForeignEditionId(file.ForeignEditionId); var edition = _editionService.GetEditionByForeignEditionId(file.ForeignEditionId);
if (edition == null) 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); edition = tuple.Item2.Editions.Value.SingleOrDefault(x => x.ForeignEditionId == file.ForeignEditionId);
} }

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO.Compression; using System.IO.Compression;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -27,7 +27,12 @@ namespace VersOne.Epub.Internal
XNamespace opfNamespace = "http://www.idpf.org/2007/opf"; XNamespace opfNamespace = "http://www.idpf.org/2007/opf";
var packageNode = containerDocument.Element(opfNamespace + "package"); var packageNode = containerDocument.Element(opfNamespace + "package");
var result = new EpubPackage();
if (packageNode == null)
{
throw new Exception("Invalid epub file");
}
var epubVersionValue = packageNode.Attribute("version").Value; var epubVersionValue = packageNode.Attribute("version").Value;
EpubVersion epubVersion; EpubVersion epubVersion;
switch (epubVersionValue) switch (epubVersionValue)
@ -46,6 +51,7 @@ namespace VersOne.Epub.Internal
throw new Exception($"Unsupported EPUB version: {epubVersionValue}."); throw new Exception($"Unsupported EPUB version: {epubVersionValue}.");
} }
var result = new EpubPackage();
result.EpubVersion = epubVersion; result.EpubVersion = epubVersion;
var metadataNode = packageNode.Element(opfNamespace + "metadata"); var metadataNode = packageNode.Element(opfNamespace + "metadata");
if (metadataNode == null) if (metadataNode == null)

@ -3,32 +3,59 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Text.Json;
using System.Threading; using System.Threading;
using NLog; using NLog;
using NzbDrone.Common.Cache; using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Books; using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Http;
using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource.Goodreads;
namespace NzbDrone.Core.MetadataSource.BookInfo namespace NzbDrone.Core.MetadataSource.BookInfo
{ {
public class BookInfoProxy : IProvideAuthorInfo public class BookInfoProxy : IProvideAuthorInfo, IProvideBookInfo, ISearchForNewBook, ISearchForNewAuthor, ISearchForNewEntity
{ {
private static readonly JsonSerializerOptions SerializerSettings = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = false,
Converters = { new STJUtcConverter() }
};
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly ICachedHttpResponseService _cachedHttpClient;
private readonly IGoodreadsSearchProxy _goodreadsSearchProxy;
private readonly IAuthorService _authorService;
private readonly IBookService _bookService;
private readonly IEditionService _editionService;
private readonly Logger _logger; private readonly Logger _logger;
private readonly IMetadataRequestBuilder _requestBuilder; private readonly IMetadataRequestBuilder _requestBuilder;
private readonly ICached<HashSet<string>> _cache; private readonly ICached<HashSet<string>> _cache;
private readonly ICached<Author> _authorCache;
public BookInfoProxy(IHttpClient httpClient, public BookInfoProxy(IHttpClient httpClient,
IMetadataRequestBuilder requestBuilder, ICachedHttpResponseService cachedHttpClient,
Logger logger, IGoodreadsSearchProxy goodreadsSearchProxy,
ICacheManager cacheManager) IAuthorService authorService,
IBookService bookService,
IEditionService editionService,
IMetadataRequestBuilder requestBuilder,
Logger logger,
ICacheManager cacheManager)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_cachedHttpClient = cachedHttpClient;
_goodreadsSearchProxy = goodreadsSearchProxy;
_authorService = authorService;
_bookService = bookService;
_editionService = editionService;
_requestBuilder = requestBuilder; _requestBuilder = requestBuilder;
_cache = cacheManager.GetCache<HashSet<string>>(GetType()); _cache = cacheManager.GetCache<HashSet<string>>(GetType());
_authorCache = cacheManager.GetRollingCache<Author>(GetType(), "authorCache", TimeSpan.FromMinutes(5));
_logger = logger; _logger = logger;
} }
@ -51,17 +78,393 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return new HashSet<string>(httpResponse.Resource.Ids.Select(x => x.ToString())); return new HashSet<string>(httpResponse.Resource.Ids.Select(x => x.ToString()));
} }
public Author GetAuthorInfo(string foreignAuthorId, bool useCache = false, bool includeBooks = true) public Author GetAuthorInfo(string foreignAuthorId, bool useCache = true)
{ {
_logger.Debug("Getting Author details GoodreadsId of {0}", foreignAuthorId); _logger.Debug("Getting Author details GoodreadsId of {0}", foreignAuthorId);
return PollAuthor(foreignAuthorId, includeBooks); if (useCache)
{
return PollAuthor(foreignAuthorId);
}
return PollAuthorUncached(foreignAuthorId);
}
public HashSet<string> GetChangedBooks(DateTime startTime)
{
return _cache.Get("ChangedBooks", () => GetChangedBooksUncached(startTime), TimeSpan.FromMinutes(30));
}
private HashSet<string> GetChangedBooksUncached(DateTime startTime)
{
return null;
}
public Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string foreignBookId)
{
return PollBook(foreignBookId);
}
public List<object> SearchForNewEntity(string title)
{
var books = SearchForNewBook(title, null, false);
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;
}
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, bool getAllEditions = true)
{
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, getAllEditions);
}
}
// to handle isbn / asin
q = slug;
}
return Search(q, getAllEditions);
}
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);
}
} }
private Author PollAuthor(string foreignAuthorId, bool includeBooks) public List<Book> SearchByIsbn(string isbn)
{
return Search(isbn, true);
}
public List<Book> SearchByAsin(string asin)
{
return Search(asin, true);
}
private List<Book> Search(string query, bool getAllEditions)
{
var result = _goodreadsSearchProxy.Search(query);
var books = new List<Book>();
if (getAllEditions)
{
// Slower but more exhaustive, less intensive on metadata API
var bookIds = result.Select(x => x.WorkId).ToList();
var idMap = result.Select(x => new { AuthorId = x.Author.Id, BookId = x.WorkId })
.GroupBy(x => x.AuthorId)
.ToDictionary(x => x.Key, x => x.Select(i => i.BookId.ToString()).ToList());
List<Book> authorBooks;
foreach (var author in idMap.Keys)
{
authorBooks = SearchByGoodreadsAuthorId(author);
books.AddRange(authorBooks.Where(b => idMap[author].Contains(b.ForeignBookId)));
}
var missingBooks = bookIds.ExceptBy(x => x.ToString(), books, x => x.ForeignBookId, StringComparer.Ordinal).ToList();
foreach (var book in missingBooks)
{
books.AddRange(SearchByGoodreadsWorkId(book));
}
return books;
}
else
{
// Use sparingly, hits metadata API quite hard
var ids = result.Select(x => x.BookId).ToList();
if (ids.Count == 0)
{
return new List<Book>();
}
if (ids.Count == 1)
{
try
{
return SearchByGoodreadsBookId(ids[0], false);
}
catch (BookNotFoundException)
{
_logger.Debug($"Couldn't fetch book info for {ids[0]}");
return new List<Book>();
}
}
return MapSearchResult(ids);
}
}
private List<Book> SearchByGoodreadsAuthorId(int id)
{
try
{
var authorId = id.ToString();
var result = GetAuthorInfo(authorId);
var books = result.Books.Value;
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, bool getAllEditions)
{
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
.SetSegment("route", $"book/{id}")
.Build();
httpRequest.SuppressHttpError = true;
// we expect a redirect
var httpResponse = _httpClient.Get(httpRequest);
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
return new List<Book>();
}
if (!httpResponse.HasHttpRedirect)
{
throw new BookInfoException($"Unexpected response from {httpRequest.Url}");
}
var location = httpResponse.Headers.GetSingleValue("Location");
var split = location.Split('/');
var type = split[0];
var newId = split[1];
Book book;
List<AuthorMetadata> authors;
if (type == "author")
{
var author = PollAuthor(newId);
book = author.Books.Value.Where(b => b.Editions.Value.Any(e => e.ForeignEditionId == id.ToString())).FirstOrDefault();
authors = new List<AuthorMetadata> { author.Metadata.Value };
}
else if (type == "book")
{
var tuple = PollBook(newId);
book = tuple.Item2;
authors = tuple.Item3;
}
else
{
throw new NotImplementedException($"Unexpected response from {httpResponse.Request.Url}");
}
if (book == null)
{
return new List<Book>();
}
if (!getAllEditions)
{
var trimmed = new Book();
trimmed.UseMetadataFrom(book);
trimmed.SeriesLinks = book.SeriesLinks;
var edition = book.Editions.Value.SingleOrDefault(e => e.ForeignEditionId == id.ToString());
if (edition == null)
{
return new List<Book>();
}
trimmed.Editions = new List<Edition> { edition };
return new List<Book> { trimmed };
}
var authorDict = authors.ToDictionary(x => x.ForeignAuthorId);
AddDbIds(book.AuthorMetadata.Value.ForeignAuthorId, book, authorDict);
return new List<Book> { book };
}
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 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 Author PollAuthor(string foreignAuthorId)
{
return _authorCache.Get(foreignAuthorId, () => PollAuthorUncached(foreignAuthorId));
}
private Author PollAuthorUncached(string foreignAuthorId)
{ {
AuthorResource resource = null; AuthorResource resource = null;
var useCache = true;
for (var i = 0; i < 60; i++) for (var i = 0; i < 60; i++)
{ {
var httpRequest = _requestBuilder.GetRequestBuilder().Create() var httpRequest = _requestBuilder.GetRequestBuilder().Create()
@ -71,7 +474,7 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
httpRequest.AllowAutoRedirect = true; httpRequest.AllowAutoRedirect = true;
httpRequest.SuppressHttpError = true; httpRequest.SuppressHttpError = true;
var httpResponse = _httpClient.Get<AuthorResource>(httpRequest); var httpResponse = _cachedHttpClient.Get(httpRequest, useCache, TimeSpan.FromMinutes(30));
if (httpResponse.HasHttpError) if (httpResponse.HasHttpError)
{ {
@ -89,15 +492,16 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
} }
} }
resource = httpResponse.Resource; resource = JsonSerializer.Deserialize<AuthorResource>(httpResponse.Content, SerializerSettings);
if (resource.Works != null || !includeBooks) if (resource.Works != null)
{ {
resource.Works ??= new List<WorkResource>(); resource.Works ??= new List<WorkResource>();
resource.Series ??= new List<SeriesResource>(); resource.Series ??= new List<SeriesResource>();
break; break;
} }
useCache = false;
Thread.Sleep(2000); Thread.Sleep(2000);
} }
@ -109,27 +513,91 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return MapAuthor(resource); return MapAuthor(resource);
} }
public Author GetAuthorAndBooks(string foreignAuthorId, double minPopularity = 0) private Tuple<string, Book, List<AuthorMetadata>> PollBook(string foreignBookId)
{ {
return GetAuthorInfo(foreignAuthorId); WorkResource resource = null;
}
public HashSet<string> GetChangedBooks(DateTime startTime) for (var i = 0; i < 60; i++)
{ {
return _cache.Get("ChangedBooks", () => GetChangedBooksUncached(startTime), TimeSpan.FromMinutes(30)); var httpRequest = _requestBuilder.GetRequestBuilder().Create()
} .SetSegment("route", $"work/{foreignBookId}")
.Build();
private HashSet<string> GetChangedBooksUncached(DateTime startTime) httpRequest.SuppressHttpError = true;
{
return null;
}
public Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string foreignBookId) // this may redirect to an author
{ var httpResponse = _httpClient.Get(httpRequest);
return null;
if (httpResponse.StatusCode == HttpStatusCode.NotFound)
{
throw new BookNotFoundException(foreignBookId);
}
if (httpResponse.HasHttpRedirect)
{
var location = httpResponse.Headers.GetSingleValue("Location");
var split = location.Split('/');
var type = split[0];
var newId = split[1];
if (type == "author")
{
var author = PollAuthor(newId);
var authorBook = author.Books.Value.SingleOrDefault(x => x.ForeignBookId == foreignBookId);
if (authorBook == null)
{
throw new BookNotFoundException(foreignBookId);
}
var authorMetadata = new List<AuthorMetadata> { author.Metadata.Value };
return Tuple.Create(author.ForeignAuthorId, authorBook, authorMetadata);
}
else
{
throw new NotImplementedException($"Unexpected response from {httpResponse.Request.Url}");
}
}
if (httpResponse.HasHttpError)
{
if (httpResponse.StatusCode == HttpStatusCode.BadRequest)
{
throw new BadRequestException(foreignBookId);
}
else
{
throw new HttpException(httpRequest, httpResponse);
}
}
resource = JsonSerializer.Deserialize<WorkResource>(httpResponse.Content, SerializerSettings);
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 Author MapAuthor(AuthorResource resource) private static AuthorMetadata MapAuthorMetadata(AuthorResource resource)
{ {
var metadata = new AuthorMetadata var metadata = new AuthorMetadata
{ {
@ -159,6 +627,13 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
metadata.Links.Add(new Links { Url = resource.Url, Name = "Goodreads" }); 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 var books = resource.Works
.Where(x => x.ForeignId > 0 && GetAuthorId(x) == resource.ForeignId) .Where(x => x.ForeignId > 0 && GetAuthorId(x) == resource.ForeignId)
.Select(MapBook) .Select(MapBook)
@ -168,7 +643,7 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
var series = resource.Series.Select(MapSeries).ToList(); var series = resource.Series.Select(MapSeries).ToList();
MapSeriesLinks(series, books, resource); MapSeriesLinks(series, books, resource.Series);
var result = new Author var result = new Author
{ {
@ -181,17 +656,22 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return result; 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 bookDict = books.ToDictionary(x => x.ForeignBookId);
var seriesDict = series.ToDictionary(x => x.ForeignSeriesId); 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 // 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)) if (seriesDict.TryGetValue(s.ForeignId.ToString(), out var curr))
{ {
curr.LinkItems = s.LinkItems.Where(x => x.ForeignWorkId.IsNotNullOrWhiteSpace() && bookDict.ContainsKey(x.ForeignWorkId.ToString())).Select(l => new SeriesBookLink curr.LinkItems = s.LinkItems.Where(x => x.ForeignWorkId != 0 && bookDict.ContainsKey(x.ForeignWorkId.ToString())).Select(l => new SeriesBookLink
{ {
Book = bookDict[l.ForeignWorkId.ToString()], Book = bookDict[l.ForeignWorkId.ToString()],
Series = curr, Series = curr,
@ -199,6 +679,11 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
Position = l.PositionInSeries, Position = l.PositionInSeries,
SeriesPosition = l.SeriesPosition SeriesPosition = l.SeriesPosition
}).ToList(); }).ToList();
foreach (var l in curr.LinkItems.Value)
{
l.Book.Value.SeriesLinks.Value.Add(l);
}
} }
} }
} }
@ -234,8 +719,8 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
{ {
book.Editions = resource.Books.Select(x => MapEdition(x)).ToList(); book.Editions = resource.Books.Select(x => MapEdition(x)).ToList();
// monitor the most rated release // monitor the most popular release
var mostPopular = book.Editions.Value.OrderByDescending(x => x.Ratings.Votes).FirstOrDefault(); var mostPopular = book.Editions.Value.OrderByDescending(x => x.Ratings.Popularity).FirstOrDefault();
if (mostPopular != null) if (mostPopular != null)
{ {
mostPopular.Monitored = true; mostPopular.Monitored = true;
@ -252,17 +737,24 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
book.Editions = new List<Edition>(); book.Editions = new List<Edition>();
} }
// sometimes the work release date is after the earliest good edition release // If we are missing the book release date, set as the earliest edition release date
var editionReleases = book.Editions.Value if (!book.ReleaseDate.HasValue)
.Where(x => x.ReleaseDate.HasValue && x.ReleaseDate.Value.Month != 1 && x.ReleaseDate.Value.Day != 1)
.ToList();
if (editionReleases.Any())
{ {
var earliestRelease = editionReleases.Min(x => x.ReleaseDate.Value); var editionReleases = book.Editions.Value
if (earliestRelease < book.ReleaseDate) .Where(x => x.ReleaseDate.HasValue && x.ReleaseDate.Value.Month != 1 && x.ReleaseDate.Value.Day != 1)
.ToList();
if (editionReleases.Any())
{ {
book.ReleaseDate = earliestRelease; book.ReleaseDate = editionReleases.Min(x => x.ReleaseDate.Value);
}
else
{
editionReleases = book.Editions.Value.Where(x => x.ReleaseDate.HasValue).ToList();
if (editionReleases.Any())
{
book.ReleaseDate = editionReleases.Min(x => x.ReleaseDate.Value);
}
} }
} }
@ -322,7 +814,7 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
return edition; return edition;
} }
private int GetAuthorId(WorkResource b) private static int GetAuthorId(WorkResource b)
{ {
return b.Books.OrderByDescending(x => x.RatingCount * x.AverageRating).First().Contributors.FirstOrDefault()?.ForeignId ?? 0; return b.Books.OrderByDescending(x => x.RatingCount * x.AverageRating).First().Contributors.FirstOrDefault()?.ForeignId ?? 0;
} }

@ -7,22 +7,12 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
{ {
public int ForeignId { get; set; } public int ForeignId { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string TitleSlug { get; set; }
public string Description { get; set; } public string Description { get; set; }
public string ImageUrl { get; set; } public string ImageUrl { get; set; }
public string Url { get; set; } public string Url { get; set; }
public int ReviewCount { get; set; }
public int RatingCount { get; set; } public int RatingCount { get; set; }
public double AverageRating { get; set; } public double AverageRating { get; set; }
public DateTime LastChange { get; set; }
public DateTime LastRefresh { get; set; }
public List<WorkResource> Works { get; set; } public List<WorkResource> Works { get; set; }
public List<SeriesResource> Series { get; set; } public List<SeriesResource> Series { get; set; }
} }
} }

@ -6,7 +6,6 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
public class BookResource public class BookResource
{ {
public int ForeignId { get; set; } public int ForeignId { get; set; }
public string TitleSlug { get; set; }
public string Asin { get; set; } public string Asin { get; set; }
public string Description { get; set; } public string Description { get; set; }
public string Isbn13 { get; set; } public string Isbn13 { get; set; }
@ -18,7 +17,6 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
public string ImageUrl { get; set; } public string ImageUrl { get; set; }
public bool IsEbook { get; set; } public bool IsEbook { get; set; }
public int? NumPages { get; set; } public int? NumPages { get; set; }
public int ReviewsCount { get; set; }
public int RatingCount { get; set; } public int RatingCount { get; set; }
public double AverageRating { get; set; } public double AverageRating { get; set; }
public string Url { get; set; } public string Url { get; set; }

@ -0,0 +1,11 @@
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; }
}
}

@ -13,8 +13,7 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
public class SeriesWorkLinkResource public class SeriesWorkLinkResource
{ {
public string ForeignSeriesId { get; set; } public int ForeignWorkId { get; set; }
public string ForeignWorkId { get; set; }
public string PositionInSeries { get; set; } public string PositionInSeries { get; set; }
public int SeriesPosition { get; set; } public int SeriesPosition { get; set; }
public bool Primary { get; set; } public bool Primary { get; set; }

@ -7,11 +7,12 @@ namespace NzbDrone.Core.MetadataSource.BookInfo
{ {
public int ForeignId { get; set; } public int ForeignId { get; set; }
public string Title { get; set; } public string Title { get; set; }
public string TitleSlug { get; set; }
public string Url { get; set; } public string Url { get; set; }
public DateTime? ReleaseDate { get; set; } public DateTime? ReleaseDate { get; set; }
public List<string> Genres { get; set; } public List<string> Genres { get; set; }
public List<int> RelatedWorks { 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>();
} }
} }

@ -3,54 +3,30 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Text.RegularExpressions;
using NLog; using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.Books; using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions; using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Http; using NzbDrone.Core.Http;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.MetadataSource.Goodreads namespace NzbDrone.Core.MetadataSource.Goodreads
{ {
public class GoodreadsProxy : IProvideBookInfo, IProvideSeriesInfo, IProvideListInfo public interface IGoodreadsProxy
{ {
private static readonly RegexReplace FullSizeImageRegex = new RegexReplace(@"\._[SU][XY]\d+_.jpg$", Book GetBookInfo(string foreignEditionId, bool useCache = true);
".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 class GoodreadsProxy : IGoodreadsProxy, IProvideSeriesInfo, IProvideListInfo
{
private readonly ICachedHttpResponseService _cachedHttpClient; private readonly ICachedHttpResponseService _cachedHttpClient;
private readonly Logger _logger; private readonly Logger _logger;
private readonly IAuthorService _authorService;
private readonly IEditionService _editionService;
private readonly IHttpRequestBuilderFactory _requestBuilder; private readonly IHttpRequestBuilderFactory _requestBuilder;
private readonly ICached<HashSet<string>> _cache;
public GoodreadsProxy(ICachedHttpResponseService cachedHttpClient, public GoodreadsProxy(ICachedHttpResponseService cachedHttpClient,
IAuthorService authorService, Logger logger)
IEditionService editionService,
Logger logger,
ICacheManager cacheManager)
{ {
_cachedHttpClient = cachedHttpClient; _cachedHttpClient = cachedHttpClient;
_authorService = authorService;
_editionService = editionService;
_cache = cacheManager.GetCache<HashSet<string>>(GetType());
_logger = logger; _logger = logger;
_requestBuilder = new HttpRequestBuilder("https://www.goodreads.com/{route}") _requestBuilder = new HttpRequestBuilder("https://www.goodreads.com/{route}")
@ -61,252 +37,6 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
.CreateFactory(); .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) public SeriesResource GetSeriesInfo(int foreignSeriesId, bool useCache = true)
{ {
_logger.Debug("Getting Series with GoodreadsId of {0}", foreignSeriesId); _logger.Debug("Getting Series with GoodreadsId of {0}", foreignSeriesId);
@ -380,22 +110,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
return httpResponse.Deserialize<ListResource>(); return httpResponse.Deserialize<ListResource>();
} }
private bool TryGetBookInfo(string foreignEditionId, bool useCache, out Tuple<string, Book, List<AuthorMetadata>> result) public Book GetBookInfo(string foreignEditionId, bool useCache = true)
{
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); _logger.Debug("Getting Book with GoodreadsId of {0}", foreignEditionId);
@ -433,40 +148,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
var authors = resource.Authors.SelectList(MapAuthor); var authors = resource.Authors.SelectList(MapAuthor);
book.AuthorMetadata = authors.First(); book.AuthorMetadata = authors.First();
return new Tuple<string, Book, List<AuthorMetadata>>(resource.Authors.First().Id.ToString(), book, authors); return book;
}
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) private static AuthorMetadata MapAuthor(AuthorSummaryResource resource)
@ -491,33 +173,9 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
}; };
} }
if (!NoPhotoRegex.IsMatch(resource.ImageUrl))
{
author.Images.Add(new MediaCover.MediaCover
{
Url = FullSizeImageRegex.Replace(resource.ImageUrl),
CoverType = MediaCoverTypes.Poster
});
}
return author; 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) private static Book MapBook(BookResource resource)
{ {
var book = new Book var book = new Book
@ -554,58 +212,13 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
Monitored = true 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" }); edition.Links.Add(new Links { Url = resource.Url, Name = "Goodreads Book" });
book.Editions = new List<Edition> { edition }; book.Editions = new List<Edition> { edition };
Debug.Assert(!book.Editions.Value.Any() || book.Editions.Value.Count(x => x.Monitored) == 1, "one edition monitored"); 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; 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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using NLog; using NLog;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Core.Books;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.Http; using NzbDrone.Core.Http;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.MetadataSource.Goodreads 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$", public List<SearchJsonResource> Search(string query);
".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 class GoodreadsSearchProxy : IGoodreadsSearchProxy
{
private readonly ICachedHttpResponseService _cachedHttpClient; private readonly ICachedHttpResponseService _cachedHttpClient;
private readonly Logger _logger; 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 IHttpRequestBuilderFactory _searchBuilder;
private readonly ICached<HashSet<string>> _cache;
public GoodreadsSearchProxy(ICachedHttpResponseService cachedHttpClient, public GoodreadsSearchProxy(ICachedHttpResponseService cachedHttpClient,
IProvideBookInfo bookInfo, Logger logger)
IAuthorService authorService,
IBookService bookService,
IEditionService editionService,
Logger logger,
ICacheManager cacheManager)
{ {
_cachedHttpClient = cachedHttpClient; _cachedHttpClient = cachedHttpClient;
_bookInfo = bookInfo;
_authorService = authorService;
_bookService = bookService;
_editionService = editionService;
_cache = cacheManager.GetCache<HashSet<string>>(GetType());
_logger = logger; _logger = logger;
_searchBuilder = new HttpRequestBuilder("https://www.goodreads.com/book/auto_complete") _searchBuilder = new HttpRequestBuilder("https://www.goodreads.com/book/auto_complete")
@ -64,127 +31,7 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
.CreateFactory(); .CreateFactory();
} }
public List<Author> SearchForNewAuthor(string title) public List<SearchJsonResource> Search(string query)
{
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)
{ {
try try
{ {
@ -194,125 +41,17 @@ namespace NzbDrone.Core.MetadataSource.Goodreads
var response = _cachedHttpClient.Get<List<SearchJsonResource>>(httpRequest, true, TimeSpan.FromDays(5)); var response = _cachedHttpClient.Get<List<SearchJsonResource>>(httpRequest, true, TimeSpan.FromDays(5));
return response.Resource.SelectList(x => return response.Resource;
MapJsonSearchResult(x, response.Resource.Count == 1 ? applyData : null));
} }
catch (HttpException) 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) catch (Exception ex)
{ {
_logger.Warn(ex, ex.Message); _logger.Warn(ex, ex.Message);
throw new GoodreadsException("Search for {0} '{1}' failed. Invalid response received from Goodreads.", field, query); throw new GoodreadsException("Search for '{0}' failed. Invalid response received from Goodreads.", 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
});
} }
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;
} }
} }
} }

@ -6,8 +6,7 @@ namespace NzbDrone.Core.MetadataSource
{ {
public interface IProvideAuthorInfo public interface IProvideAuthorInfo
{ {
Author GetAuthorInfo(string readarrId, bool useCache = true, bool includeBooks = true); Author GetAuthorInfo(string readarrId, bool useCache = true);
Author GetAuthorAndBooks(string readarrId, double minPopularity = 0);
HashSet<string> GetChangedAuthors(DateTime startTime); HashSet<string> GetChangedAuthors(DateTime startTime);
} }
} }

@ -6,6 +6,6 @@ namespace NzbDrone.Core.MetadataSource
{ {
public interface IProvideBookInfo public interface IProvideBookInfo
{ {
Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string id, bool useCache = true); Tuple<string, Book, List<AuthorMetadata>> GetBookInfo(string id);
} }
} }

@ -5,9 +5,9 @@ namespace NzbDrone.Core.MetadataSource
{ {
public interface ISearchForNewBook public interface ISearchForNewBook
{ {
List<Book> SearchForNewBook(string title, string author); List<Book> SearchForNewBook(string title, string author, bool getAllEditions = true);
List<Book> SearchByIsbn(string isbn); List<Book> SearchByIsbn(string isbn);
List<Book> SearchByAsin(string asin); List<Book> SearchByAsin(string asin);
List<Book> SearchByGoodreadsId(int goodreadsId); List<Book> SearchByGoodreadsBookId(int goodreadsId, bool getAllEditions);
} }
} }

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

@ -19,7 +19,7 @@ namespace NzbDrone.Integration.Test.ApiTests
[Test] [Test]
public void lookup_new_author_by_goodreads_book_id() 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().NotBeEmpty();
author.Should().Contain(c => c.AuthorName == "J.K. Rowling"); author.Should().Contain(c => c.AuthorName == "J.K. Rowling");

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

Loading…
Cancel
Save