Skip to content

Commit e31429c

Browse files
authored
Add Google Books external id (#80)
* Add error handling, external ID and ID search to the Google Books provider * Fix CodeQL warnings
1 parent fa45821 commit e31429c

10 files changed

+313
-61
lines changed

Diff for: Jellyfin.Plugin.Bookshelf.sln

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
Microsoft Visual Studio Solution File, Format Version 12.00
32
# Visual Studio 15
43
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Bookshelf", "Jellyfin.Plugin.Bookshelf\Jellyfin.Plugin.Bookshelf.csproj", "{8D744D83-5403-4BA4-8794-760AF69DAC06}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Globalization;
2+
using System.Net.Http;
3+
using System.Net.Http.Json;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using Jellyfin.Extensions.Json;
7+
using MediaBrowser.Common.Net;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
11+
{
12+
/// <summary>
13+
/// Base class for the Google Books providers.
14+
/// </summary>
15+
public abstract class BaseGoogleBooksProvider
16+
{
17+
private readonly ILogger<BaseGoogleBooksProvider> _logger;
18+
private readonly IHttpClientFactory _httpClientFactory;
19+
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="BaseGoogleBooksProvider"/> class.
22+
/// </summary>
23+
/// <param name="logger">Instance of the <see cref="ILogger{GoogleBooksProvider}"/> interface.</param>
24+
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
25+
protected BaseGoogleBooksProvider(ILogger<BaseGoogleBooksProvider> logger, IHttpClientFactory httpClientFactory)
26+
{
27+
_logger = logger;
28+
_httpClientFactory = httpClientFactory;
29+
}
30+
31+
/// <summary>
32+
/// Get a result from the Google Books API.
33+
/// </summary>
34+
/// <typeparam name="T">Type of expected result.</typeparam>
35+
/// <param name="url">API URL to call.</param>
36+
/// <param name="cancellationToken">The cancellation token.</param>
37+
/// <returns>The API result.</returns>
38+
protected async Task<T?> GetResultFromAPI<T>(string url, CancellationToken cancellationToken)
39+
where T : class
40+
{
41+
var response = await _httpClientFactory
42+
.CreateClient(NamedClient.Default)
43+
.GetAsync(url, cancellationToken)
44+
.ConfigureAwait(false);
45+
46+
if (!response.IsSuccessStatusCode)
47+
{
48+
var errorResponse = await response.Content.ReadFromJsonAsync<ErrorResponse>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
49+
50+
if (errorResponse != null)
51+
{
52+
_logger.LogError("Error response from Google Books API: {ErrorMessage} (status code: {StatusCode})", errorResponse.Error.Message, response.StatusCode);
53+
}
54+
55+
return null;
56+
}
57+
58+
return await response.Content.ReadFromJsonAsync<T>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
59+
}
60+
61+
/// <summary>
62+
/// Fetch book data from the Google Books API.
63+
/// </summary>
64+
/// <param name="googleBookId">The volume id.</param>
65+
/// <param name="cancellationToken">The cancellation token.</param>
66+
/// <returns>The API result.</returns>
67+
protected async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
68+
{
69+
cancellationToken.ThrowIfCancellationRequested();
70+
71+
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.DetailsUrl, googleBookId);
72+
73+
return await GetResultFromAPI<BookResult>(url, cancellationToken).ConfigureAwait(false);
74+
}
75+
}
76+
}
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Net;
4+
5+
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
6+
{
7+
internal class Error
8+
{
9+
public HttpStatusCode Code { get; set; }
10+
11+
public string Message { get; set; } = string.Empty;
12+
13+
public IEnumerable<ErrorDetails> Errors { get; set; } = Enumerable.Empty<ErrorDetails>();
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
2+
{
3+
internal class ErrorDetails
4+
{
5+
public string Message { get; set; } = string.Empty;
6+
7+
public string Domain { get; set; } = string.Empty;
8+
9+
public string Reason { get; set; } = string.Empty;
10+
}
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
2+
{
3+
internal class ErrorResponse
4+
{
5+
public Error Error { get; set; } = new Error();
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using MediaBrowser.Controller.Entities;
2+
using MediaBrowser.Controller.Providers;
3+
using MediaBrowser.Model.Entities;
4+
using MediaBrowser.Model.Providers;
5+
6+
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
7+
{
8+
/// <inheritdoc />
9+
public class GoogleBooksExternalId : IExternalId
10+
{
11+
/// <inheritdoc />
12+
public string ProviderName => GoogleBooksConstants.ProviderName;
13+
14+
/// <inheritdoc />
15+
public string Key => GoogleBooksConstants.ProviderId;
16+
17+
/// <inheritdoc />
18+
public ExternalIdMediaType? Type => null; // TODO: No ExternalIdMediaType value for book
19+
20+
/// <inheritdoc />
21+
public string? UrlFormatString => "https://books.google.com/books?id={0}";
22+
23+
/// <inheritdoc />
24+
public bool Supports(IHasProviderIds item) => item is Book;
25+
}
26+
}

Diff for: Jellyfin.Plugin.Bookshelf/Providers/GoogleBooks/GoogleBooksImageProvider.cs

+7-17
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,35 @@
11
using System.Collections.Generic;
2-
using System.Globalization;
32
using System.Linq;
43
using System.Net.Http;
54
using System.Net.Http.Json;
65
using System.Threading;
76
using System.Threading.Tasks;
8-
using Jellyfin.Extensions.Json;
97
using MediaBrowser.Common.Net;
108
using MediaBrowser.Controller.Entities;
119
using MediaBrowser.Controller.Providers;
1210
using MediaBrowser.Model.Entities;
1311
using MediaBrowser.Model.Providers;
12+
using Microsoft.Extensions.Logging;
1413

1514
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
1615
{
1716
/// <summary>
1817
/// Google books image provider.
1918
/// </summary>
20-
public class GoogleBooksImageProvider : IRemoteImageProvider
19+
public class GoogleBooksImageProvider : BaseGoogleBooksProvider, IRemoteImageProvider
2120
{
21+
private readonly ILogger<GoogleBooksImageProvider> _logger;
2222
private readonly IHttpClientFactory _httpClientFactory;
2323

2424
/// <summary>
2525
/// Initializes a new instance of the <see cref="GoogleBooksImageProvider"/> class.
2626
/// </summary>
27+
/// <param name="logger">Instance of the <see cref="ILogger{GoogleBooksProvider}"/> interface.</param>
2728
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
28-
public GoogleBooksImageProvider(IHttpClientFactory httpClientFactory)
29+
public GoogleBooksImageProvider(ILogger<GoogleBooksImageProvider> logger, IHttpClientFactory httpClientFactory)
30+
: base(logger, httpClientFactory)
2931
{
32+
_logger = logger;
3033
_httpClientFactory = httpClientFactory;
3134
}
3235

@@ -74,19 +77,6 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell
7477
return list;
7578
}
7679

77-
private async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
78-
{
79-
cancellationToken.ThrowIfCancellationRequested();
80-
81-
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.DetailsUrl, googleBookId);
82-
83-
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
84-
85-
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
86-
87-
return await response.Content.ReadFromJsonAsync<BookResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
88-
}
89-
9080
private List<string> ProcessBookImage(BookResult bookResult)
9181
{
9282
var images = new List<string>();

Diff for: Jellyfin.Plugin.Bookshelf/Providers/GoogleBooks/GoogleBooksProvider.cs

+43-40
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@
44
using System.Linq;
55
using System.Net;
66
using System.Net.Http;
7-
using System.Net.Http.Json;
87
using System.Text;
98
using System.Text.RegularExpressions;
109
using System.Threading;
1110
using System.Threading.Tasks;
12-
using Jellyfin.Extensions.Json;
1311
using MediaBrowser.Common.Net;
1412
using MediaBrowser.Controller.Entities;
1513
using MediaBrowser.Controller.Providers;
@@ -22,7 +20,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
2220
/// <summary>
2321
/// Google books provider.
2422
/// </summary>
25-
public class GoogleBooksProvider : IRemoteMetadataProvider<Book, BookInfo>
23+
public class GoogleBooksProvider : BaseGoogleBooksProvider, IRemoteMetadataProvider<Book, BookInfo>
2624
{
2725
// convert these characters to whitespace for better matching
2826
// there are two dashes with different char codes
@@ -69,6 +67,7 @@ public class GoogleBooksProvider : IRemoteMetadataProvider<Book, BookInfo>
6967
public GoogleBooksProvider(
7068
ILogger<GoogleBooksProvider> logger,
7169
IHttpClientFactory httpClientFactory)
70+
: base(logger, httpClientFactory)
7271
{
7372
_httpClientFactory = httpClientFactory;
7473
_logger = logger;
@@ -81,38 +80,59 @@ public GoogleBooksProvider(
8180
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BookInfo searchInfo, CancellationToken cancellationToken)
8281
{
8382
cancellationToken.ThrowIfCancellationRequested();
84-
var list = new List<RemoteSearchResult>();
8583

86-
var searchResults = await GetSearchResultsInternal(searchInfo, cancellationToken).ConfigureAwait(false);
87-
if (searchResults is null)
84+
Func<BookResult, RemoteSearchResult> getSearchResultFromBook = (BookResult info) =>
8885
{
89-
return Enumerable.Empty<RemoteSearchResult>();
90-
}
86+
var remoteSearchResult = new RemoteSearchResult();
9187

92-
foreach (var result in searchResults.Items)
93-
{
94-
if (result.VolumeInfo is null)
88+
remoteSearchResult.SetProviderId(GoogleBooksConstants.ProviderId, info.Id);
89+
remoteSearchResult.SearchProviderName = GoogleBooksConstants.ProviderName;
90+
remoteSearchResult.Name = info.VolumeInfo?.Title;
91+
remoteSearchResult.Overview = WebUtility.HtmlDecode(info.VolumeInfo?.Description);
92+
remoteSearchResult.ProductionYear = GetYearFromPublishedDate(info.VolumeInfo?.PublishedDate);
93+
94+
if (info.VolumeInfo?.ImageLinks?.Thumbnail != null)
9595
{
96-
continue;
96+
remoteSearchResult.ImageUrl = info.VolumeInfo.ImageLinks.Thumbnail;
9797
}
9898

99-
var remoteSearchResult = new RemoteSearchResult();
99+
return remoteSearchResult;
100+
};
100101

101-
remoteSearchResult.SetProviderId(GoogleBooksConstants.ProviderId, result.Id);
102-
remoteSearchResult.SearchProviderName = GoogleBooksConstants.ProviderName;
103-
remoteSearchResult.Name = result.VolumeInfo.Title;
104-
remoteSearchResult.Overview = WebUtility.HtmlDecode(result.VolumeInfo.Description);
105-
remoteSearchResult.ProductionYear = GetYearFromPublishedDate(result.VolumeInfo.PublishedDate);
102+
var googleBookId = searchInfo.GetProviderId(GoogleBooksConstants.ProviderId);
106103

107-
if (result.VolumeInfo.ImageLinks?.Thumbnail != null)
104+
if (!string.IsNullOrWhiteSpace(googleBookId))
105+
{
106+
var bookData = await FetchBookData(googleBookId, cancellationToken).ConfigureAwait(false);
107+
108+
if (bookData == null || bookData.VolumeInfo == null)
108109
{
109-
remoteSearchResult.ImageUrl = result.VolumeInfo.ImageLinks.Thumbnail;
110+
return Enumerable.Empty<RemoteSearchResult>();
110111
}
111112

112-
list.Add(remoteSearchResult);
113+
return new[] { getSearchResultFromBook(bookData) };
113114
}
115+
else
116+
{
117+
var searchResults = await GetSearchResultsInternal(searchInfo, cancellationToken).ConfigureAwait(false);
118+
if (searchResults is null)
119+
{
120+
return Enumerable.Empty<RemoteSearchResult>();
121+
}
122+
123+
var list = new List<RemoteSearchResult>();
124+
foreach (var result in searchResults.Items)
125+
{
126+
if (result.VolumeInfo is null)
127+
{
128+
continue;
129+
}
114130

115-
return list;
131+
list.Add(getSearchResultFromBook(result));
132+
}
133+
134+
return list;
135+
}
116136
}
117137

118138
/// <inheritdoc />
@@ -173,11 +193,7 @@ public async Task<HttpResponseMessage> GetImageResponse(string url, Cancellation
173193
var searchString = GetSearchString(item);
174194
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.SearchUrl, WebUtility.UrlEncode(searchString), 0, 20);
175195

176-
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
177-
178-
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
179-
180-
return await response.Content.ReadFromJsonAsync<SearchResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
196+
return await GetResultFromAPI<SearchResult>(url, cancellationToken).ConfigureAwait(false);
181197
}
182198

183199
private async Task<string?> FetchBookId(BookInfo item, CancellationToken cancellationToken)
@@ -240,19 +256,6 @@ public async Task<HttpResponseMessage> GetImageResponse(string url, Cancellation
240256
return bookReleaseYear;
241257
}
242258

243-
private async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
244-
{
245-
cancellationToken.ThrowIfCancellationRequested();
246-
247-
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.DetailsUrl, googleBookId);
248-
249-
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
250-
251-
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
252-
253-
return await response.Content.ReadFromJsonAsync<BookResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
254-
}
255-
256259
private Book? ProcessBookData(BookResult bookResult, CancellationToken cancellationToken)
257260
{
258261
if (bookResult.VolumeInfo is null)

Diff for: tests/Jellyfin.Plugin.Bookshelf.Tests/GoogleBooksImageProviderTests.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Jellyfin.Plugin.Bookshelf.Tests.Http;
44
using MediaBrowser.Controller.Entities;
55
using MediaBrowser.Controller.Providers;
6+
using Microsoft.Extensions.Logging.Abstractions;
67
using NSubstitute;
78

89
namespace Jellyfin.Plugin.Bookshelf.Tests
@@ -21,7 +22,7 @@ public async Task GetImages_WithAllLinks_PicksLargestAndThumbnail()
2122
using var client = new HttpClient(mockedMessageHandler);
2223
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
2324

24-
IRemoteImageProvider provider = new GoogleBooksImageProvider(mockedHttpClientFactory);
25+
IRemoteImageProvider provider = new GoogleBooksImageProvider(NullLogger<GoogleBooksImageProvider>.Instance, mockedHttpClientFactory);
2526

2627
var images = await provider.GetImages(new Book()
2728
{

0 commit comments

Comments
 (0)