Add Google Books external id (#80)

* Add error handling, external ID and ID search to the Google Books provider

* Fix CodeQL warnings
This commit is contained in:
Pithaya 2023-10-26 21:45:57 +02:00 committed by GitHub
parent fa45821aa9
commit e31429c87e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 316 additions and 64 deletions

View File

@ -1,4 +1,3 @@

Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15 # Visual Studio 15
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Bookshelf", "Jellyfin.Plugin.Bookshelf\Jellyfin.Plugin.Bookshelf.csproj", "{8D744D83-5403-4BA4-8794-760AF69DAC06}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Bookshelf", "Jellyfin.Plugin.Bookshelf\Jellyfin.Plugin.Bookshelf.csproj", "{8D744D83-5403-4BA4-8794-760AF69DAC06}"

View File

@ -0,0 +1,76 @@
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
/// <summary>
/// Base class for the Google Books providers.
/// </summary>
public abstract class BaseGoogleBooksProvider
{
private readonly ILogger<BaseGoogleBooksProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="BaseGoogleBooksProvider"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{GoogleBooksProvider}"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
protected BaseGoogleBooksProvider(ILogger<BaseGoogleBooksProvider> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Get a result from the Google Books API.
/// </summary>
/// <typeparam name="T">Type of expected result.</typeparam>
/// <param name="url">API URL to call.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The API result.</returns>
protected async Task<T?> GetResultFromAPI<T>(string url, CancellationToken cancellationToken)
where T : class
{
var response = await _httpClientFactory
.CreateClient(NamedClient.Default)
.GetAsync(url, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorResponse = await response.Content.ReadFromJsonAsync<ErrorResponse>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
if (errorResponse != null)
{
_logger.LogError("Error response from Google Books API: {ErrorMessage} (status code: {StatusCode})", errorResponse.Error.Message, response.StatusCode);
}
return null;
}
return await response.Content.ReadFromJsonAsync<T>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Fetch book data from the Google Books API.
/// </summary>
/// <param name="googleBookId">The volume id.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The API result.</returns>
protected async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.DetailsUrl, googleBookId);
return await GetResultFromAPI<BookResult>(url, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Linq;
using System.Net;
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
internal class Error
{
public HttpStatusCode Code { get; set; }
public string Message { get; set; } = string.Empty;
public IEnumerable<ErrorDetails> Errors { get; set; } = Enumerable.Empty<ErrorDetails>();
}
}

View File

@ -0,0 +1,11 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
internal class ErrorDetails
{
public string Message { get; set; } = string.Empty;
public string Domain { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,7 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
internal class ErrorResponse
{
public Error Error { get; set; } = new Error();
}
}

View File

@ -0,0 +1,26 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
/// <inheritdoc />
public class GoogleBooksExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => GoogleBooksConstants.ProviderName;
/// <inheritdoc />
public string Key => GoogleBooksConstants.ProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null; // TODO: No ExternalIdMediaType value for book
/// <inheritdoc />
public string? UrlFormatString => "https://books.google.com/books?id={0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Book;
}
}

View File

@ -1,32 +1,35 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities; using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers; using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{ {
/// <summary> /// <summary>
/// Google books image provider. /// Google books image provider.
/// </summary> /// </summary>
public class GoogleBooksImageProvider : IRemoteImageProvider public class GoogleBooksImageProvider : BaseGoogleBooksProvider, IRemoteImageProvider
{ {
private readonly ILogger<GoogleBooksImageProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="GoogleBooksImageProvider"/> class. /// Initializes a new instance of the <see cref="GoogleBooksImageProvider"/> class.
/// </summary> /// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{GoogleBooksProvider}"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
public GoogleBooksImageProvider(IHttpClientFactory httpClientFactory) public GoogleBooksImageProvider(ILogger<GoogleBooksImageProvider> logger, IHttpClientFactory httpClientFactory)
: base(logger, httpClientFactory)
{ {
_logger = logger;
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
} }
@ -74,19 +77,6 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return list; return list;
} }
private async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.DetailsUrl, googleBookId);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<BookResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
private List<string> ProcessBookImage(BookResult bookResult) private List<string> ProcessBookImage(BookResult bookResult)
{ {
var images = new List<string>(); var images = new List<string>();

View File

@ -4,12 +4,10 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
@ -22,7 +20,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
/// <summary> /// <summary>
/// Google books provider. /// Google books provider.
/// </summary> /// </summary>
public class GoogleBooksProvider : IRemoteMetadataProvider<Book, BookInfo> public class GoogleBooksProvider : BaseGoogleBooksProvider, IRemoteMetadataProvider<Book, BookInfo>
{ {
// convert these characters to whitespace for better matching // convert these characters to whitespace for better matching
// there are two dashes with different char codes // there are two dashes with different char codes
@ -69,6 +67,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
public GoogleBooksProvider( public GoogleBooksProvider(
ILogger<GoogleBooksProvider> logger, ILogger<GoogleBooksProvider> logger,
IHttpClientFactory httpClientFactory) IHttpClientFactory httpClientFactory)
: base(logger, httpClientFactory)
{ {
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_logger = logger; _logger = logger;
@ -81,38 +80,59 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BookInfo searchInfo, CancellationToken cancellationToken) public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BookInfo searchInfo, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var list = new List<RemoteSearchResult>();
var searchResults = await GetSearchResultsInternal(searchInfo, cancellationToken).ConfigureAwait(false); Func<BookResult, RemoteSearchResult> getSearchResultFromBook = (BookResult info) =>
if (searchResults is null)
{ {
return Enumerable.Empty<RemoteSearchResult>();
}
foreach (var result in searchResults.Items)
{
if (result.VolumeInfo is null)
{
continue;
}
var remoteSearchResult = new RemoteSearchResult(); var remoteSearchResult = new RemoteSearchResult();
remoteSearchResult.SetProviderId(GoogleBooksConstants.ProviderId, result.Id); remoteSearchResult.SetProviderId(GoogleBooksConstants.ProviderId, info.Id);
remoteSearchResult.SearchProviderName = GoogleBooksConstants.ProviderName; remoteSearchResult.SearchProviderName = GoogleBooksConstants.ProviderName;
remoteSearchResult.Name = result.VolumeInfo.Title; remoteSearchResult.Name = info.VolumeInfo?.Title;
remoteSearchResult.Overview = WebUtility.HtmlDecode(result.VolumeInfo.Description); remoteSearchResult.Overview = WebUtility.HtmlDecode(info.VolumeInfo?.Description);
remoteSearchResult.ProductionYear = GetYearFromPublishedDate(result.VolumeInfo.PublishedDate); remoteSearchResult.ProductionYear = GetYearFromPublishedDate(info.VolumeInfo?.PublishedDate);
if (result.VolumeInfo.ImageLinks?.Thumbnail != null) if (info.VolumeInfo?.ImageLinks?.Thumbnail != null)
{ {
remoteSearchResult.ImageUrl = result.VolumeInfo.ImageLinks.Thumbnail; remoteSearchResult.ImageUrl = info.VolumeInfo.ImageLinks.Thumbnail;
} }
list.Add(remoteSearchResult); return remoteSearchResult;
} };
return list; var googleBookId = searchInfo.GetProviderId(GoogleBooksConstants.ProviderId);
if (!string.IsNullOrWhiteSpace(googleBookId))
{
var bookData = await FetchBookData(googleBookId, cancellationToken).ConfigureAwait(false);
if (bookData == null || bookData.VolumeInfo == null)
{
return Enumerable.Empty<RemoteSearchResult>();
}
return new[] { getSearchResultFromBook(bookData) };
}
else
{
var searchResults = await GetSearchResultsInternal(searchInfo, cancellationToken).ConfigureAwait(false);
if (searchResults is null)
{
return Enumerable.Empty<RemoteSearchResult>();
}
var list = new List<RemoteSearchResult>();
foreach (var result in searchResults.Items)
{
if (result.VolumeInfo is null)
{
continue;
}
list.Add(getSearchResultFromBook(result));
}
return list;
}
} }
/// <inheritdoc /> /// <inheritdoc />
@ -173,11 +193,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
var searchString = GetSearchString(item); var searchString = GetSearchString(item);
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.SearchUrl, WebUtility.UrlEncode(searchString), 0, 20); var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.SearchUrl, WebUtility.UrlEncode(searchString), 0, 20);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); return await GetResultFromAPI<SearchResult>(url, cancellationToken).ConfigureAwait(false);
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<SearchResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
} }
private async Task<string?> FetchBookId(BookInfo item, CancellationToken cancellationToken) private async Task<string?> FetchBookId(BookInfo item, CancellationToken cancellationToken)
@ -240,19 +256,6 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return bookReleaseYear; return bookReleaseYear;
} }
private async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.DetailsUrl, googleBookId);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<BookResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
private Book? ProcessBookData(BookResult bookResult, CancellationToken cancellationToken) private Book? ProcessBookData(BookResult bookResult, CancellationToken cancellationToken)
{ {
if (bookResult.VolumeInfo is null) if (bookResult.VolumeInfo is null)

View File

@ -3,6 +3,7 @@ using Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks;
using Jellyfin.Plugin.Bookshelf.Tests.Http; using Jellyfin.Plugin.Bookshelf.Tests.Http;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
namespace Jellyfin.Plugin.Bookshelf.Tests namespace Jellyfin.Plugin.Bookshelf.Tests
@ -21,7 +22,7 @@ namespace Jellyfin.Plugin.Bookshelf.Tests
using var client = new HttpClient(mockedMessageHandler); using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client); mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteImageProvider provider = new GoogleBooksImageProvider(mockedHttpClientFactory); IRemoteImageProvider provider = new GoogleBooksImageProvider(NullLogger<GoogleBooksImageProvider>.Instance, mockedHttpClientFactory);
var images = await provider.GetImages(new Book() var images = await provider.GetImages(new Book()
{ {

View File

@ -27,7 +27,7 @@ namespace Jellyfin.Plugin.Bookshelf.Tests
#region GetSearchResults #region GetSearchResults
[Fact] [Fact]
public async Task GetSearchResults_Success() public async Task GetSearchResults_ByName_Success()
{ {
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)> var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{ {
@ -65,6 +65,131 @@ namespace Jellyfin.Plugin.Bookshelf.Tests
}); });
} }
[Fact]
public async Task GetSearchResults_ByProviderId_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes/49T5twEACAAJ"), new MockHttpResponse(HttpStatusCode.OK, GetEnglishTestVolumeResult()))
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, mockedHttpClientFactory);
var results = await provider.GetSearchResults(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ GoogleBooksConstants.ProviderId, "49T5twEACAAJ" }
}
}, CancellationToken.None);
Assert.True(results.All(result => result.SearchProviderName == GoogleBooksConstants.ProviderName));
Assert.Collection(
results,
first =>
{
Assert.Equal("Children of Time", first.Name);
Assert.True(HasGoogleId("49T5twEACAAJ", first.ProviderIds));
Assert.Equal("http://books.google.com/books/content?id=49T5twEACAAJ&printsec=frontcover&img=1&zoom=1&imgtk=AFLRE70U9t4z91EAYhiD2AYOR9pzNu86QDKZebNLQo4K3jMaJ748TC5LvCoZGt9ON4pZ54H8RoIRyCB5IveVDmt49QjeJlbJtWLlZoksRHXInrEVmo2476WXKcLhZOjp41Vu_5Lb05oJ&source=gbs_api", first.ImageUrl);
Assert.Equal("<b>Adrian Tchaikovksy's award-winning novel <i>Children of Time</i>, is the epic story of humanity's battle for survival on a terraformed planet.</b><b>" +
"<br></b>Who will inherit this new Earth?<br><br>" +
"The last remnants of the human race left a dying Earth, desperate to find a new home among the stars. " +
"Following in the footsteps of their ancestors, they discover the greatest treasure of the past age - a world terraformed and prepared for human life." +
"<br><br>But all is not right in this new Eden. In the long years since the planet was abandoned, the work of its architects has borne disastrous fruit. " +
"The planet is not waiting for them, pristine and unoccupied. New masters have turned it from a refuge into mankind's worst nightmare." +
"<br><br>Now two civilizations are on a collision course, both testing the boundaries of what they will do to survive. " +
"As the fate of humanity hangs in the balance, who are the true heirs of this new Earth?span", first.Overview);
Assert.Equal(2018, first.ProductionYear);
});
}
[Fact]
public async Task GetSearchResults_ByProviderId_WithInvalidId_ReturnsNoResults()
{
// API will return a 503 code if the volume id is invalid
string errorResponse = @"
{
""error"": {
""code"": 503,
""message"": ""Service temporarily unavailable."",
""errors"": [
{
""message"": ""Service temporarily unavailable."",
""domain"": ""global"",
""reason"": ""backendFailed""
}
]
}
}
";
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes/49T55wEACAA"), new MockHttpResponse(HttpStatusCode.NotFound, errorResponse))
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, mockedHttpClientFactory);
var results = await provider.GetSearchResults(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ GoogleBooksConstants.ProviderId, "49T55wEACAA" }
}
}, CancellationToken.None);
Assert.Empty(results);
}
[Fact]
public async Task GetSearchResults_ByProviderId_WithNonExistentId_ReturnsNoResults()
{
// API will return a 404 code if the volume is not found
string errorResponse = @"
{
""error"": {
""code"": 404,
""message"": ""The volume ID could not be found."",
""errors"": [
{
""message"": ""The volume ID could not be found."",
""domain"": ""global"",
""reason"": ""notFound""
}
]
}
}
";
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes/49T55wEACAAX"), new MockHttpResponse(HttpStatusCode.NotFound, errorResponse))
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, mockedHttpClientFactory);
var results = await provider.GetSearchResults(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ GoogleBooksConstants.ProviderId, "49T55wEACAAX" }
}
}, CancellationToken.None);
Assert.Empty(results);
}
#endregion #endregion
#region GetMetadata #region GetMetadata
@ -216,7 +341,6 @@ namespace Jellyfin.Plugin.Bookshelf.Tests
Assert.Equal("Children of Time", bookInfo.Name); Assert.Equal("Children of Time", bookInfo.Name);
} }
[Fact] [Fact]
public void GetBookMetadata_WithNameAndDefaultSeriesName_CorrectlyResetSeriesName() public void GetBookMetadata_WithNameAndDefaultSeriesName_CorrectlyResetSeriesName()
{ {