Merge pull request #78 from Pithaya/feat/google-books-provider-tests

Add unit tests for the Google Books providers
This commit is contained in:
Cody Robibero 2023-10-22 09:18:01 -06:00 committed by GitHub
commit c3b4e366af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1106 additions and 101 deletions

View File

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Jellyfin.Plugin.Bookshelf.Tests")]

View File

@ -21,7 +21,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.ComicInfo
private readonly IFileSystem _fileSystem;
private readonly ILogger<ExternalComicInfoProvider> _logger;
private readonly ComicInfoXmlUtilities _utilities = new ComicInfoXmlUtilities();
private readonly IComicInfoXmlUtilities _utilities = new ComicInfoXmlUtilities();
/// <summary>
/// Initializes a new instance of the <see cref="ExternalComicInfoProvider"/> class.

View File

@ -8,31 +8,31 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
public class BookResult
{
/// <summary>
/// Gets or sets the book kind.
/// Gets or sets the resource type for the volume.
/// </summary>
[JsonPropertyName("kind")]
public string? Kind { get; set; }
/// <summary>
/// Gets or sets the book id.
/// Gets or sets the unique identifier for the volume.
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
/// <summary>
/// Gets or sets the etag.
/// Gets or sets the opaque identifier for a specific version of the volume resource.
/// </summary>
[JsonPropertyName("etag")]
public string? Etag { get; set; }
/// <summary>
/// Gets or sets the self link.
/// Gets or sets the URL to this resource.
/// </summary>
[JsonPropertyName("selfLink")]
public string? SelfLink { get; set; }
/// <summary>
/// Gets or sets the volume info.
/// Gets or sets the general volume information.
/// </summary>
[JsonPropertyName("volumeInfo")]
public VolumeInfo? VolumeInfo { get; set; }

View File

@ -0,0 +1,18 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
/// <summary>
/// Constants for the Google Books provider.
/// </summary>
public static class GoogleBooksConstants
{
/// <summary>
/// Name of the Google Books provider.
/// </summary>
public const string ProviderName = "Google Books";
/// <summary>
/// Id of the Google Books provider.
/// </summary>
public const string ProviderId = "GoogleBooks";
}
}

View File

@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
@ -31,7 +31,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
/// <inheritdoc />
public string Name => "Google Books";
public string Name => GoogleBooksConstants.ProviderName;
/// <inheritdoc />
public bool Supports(BaseItem item)
@ -51,7 +51,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
cancellationToken.ThrowIfCancellationRequested();
var list = new List<RemoteImageInfo>();
var googleBookId = item.GetProviderId("GoogleBooks");
var googleBookId = item.GetProviderId(GoogleBooksConstants.ProviderId);
if (string.IsNullOrEmpty(googleBookId))
{
@ -84,11 +84,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
#pragma warning disable CA2007
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
#pragma warning restore CA2007
return await JsonSerializer.DeserializeAsync<BookResult>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<BookResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
private List<string> ProcessBookImage(BookResult bookResult)

View File

@ -1,11 +1,11 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@ -30,13 +30,17 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
private const string Remove = "\"'!`?";
// first pattern provides the name and the year
// alternate option to use series index instead of year
// last resort matches the whole string as the name
private static readonly Regex[] _nameMatches =
{
// seriesName (seriesYear) #index (of count) (year), with only seriesName and index required
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>\d{4})\))?)\s#(?<index>\d+)((\s\(of\s(?<count>\d+)\))?)((\s\((?<year>\d{4})\))?)$"),
// name (seriesName, #index) (year), with year optional
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>\d+)\)((\s\((?<year>\d{4})\))?)$"),
// index - name (year), with year optional
new Regex(@"^(?<index>\d+)\s\-\s(?<name>.+?)((\s\((?<year>\d{4})\))?)$"),
// name (year)
new Regex(@"(?<name>.*)\((?<year>\d{4})\)"),
new Regex(@"(?<index>\d*)\s\-\s(?<name>.*)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
};
@ -71,7 +75,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
/// <inheritdoc />
public string Name => "Google Books";
public string Name => GoogleBooksConstants.ProviderName;
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BookInfo searchInfo, CancellationToken cancellationToken)
@ -93,7 +97,13 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
var remoteSearchResult = new RemoteSearchResult();
remoteSearchResult.SetProviderId(GoogleBooksConstants.ProviderId, result.Id);
remoteSearchResult.SearchProviderName = GoogleBooksConstants.ProviderName;
remoteSearchResult.Name = result.VolumeInfo.Title;
remoteSearchResult.Overview = WebUtility.HtmlDecode(result.VolumeInfo.Description);
remoteSearchResult.ProductionYear = GetYearFromPublishedDate(result.VolumeInfo.PublishedDate);
if (result.VolumeInfo.ImageLinks?.Thumbnail != null)
{
remoteSearchResult.ImageUrl = result.VolumeInfo.ImageLinks.Thumbnail;
@ -109,13 +119,21 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
public async Task<MetadataResult<Book>> GetMetadata(BookInfo info, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var metadataResult = new MetadataResult<Book>();
metadataResult.HasMetadata = false;
var googleBookId = info.GetProviderId("GoogleBooks")
?? await FetchBookId(info, cancellationToken).ConfigureAwait(false);
var metadataResult = new MetadataResult<Book>()
{
QueriedById = true
};
if (string.IsNullOrEmpty(googleBookId))
var googleBookId = info.GetProviderId(GoogleBooksConstants.ProviderId);
if (string.IsNullOrWhiteSpace(googleBookId))
{
googleBookId = await FetchBookId(info, cancellationToken).ConfigureAwait(false);
metadataResult.QueriedById = false;
}
if (string.IsNullOrWhiteSpace(googleBookId))
{
return metadataResult;
}
@ -133,9 +151,11 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return metadataResult;
}
ProcessBookMetadata(metadataResult, bookResult);
metadataResult.Item = bookMetadataResult;
metadataResult.QueriedById = true;
metadataResult.HasMetadata = true;
return metadataResult;
}
@ -150,27 +170,24 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
cancellationToken.ThrowIfCancellationRequested();
// pattern match the filename
// year can be included for better results
GetBookMetadata(item);
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.SearchUrl, WebUtility.UrlEncode(item.Name), 0, 20);
var searchString = GetSearchString(item);
var url = string.Format(CultureInfo.InvariantCulture, GoogleApiUrls.SearchUrl, WebUtility.UrlEncode(searchString), 0, 20);
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
#pragma warning disable CA2007
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
#pragma warning restore CA2007
return await JsonSerializer.DeserializeAsync<SearchResult>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<SearchResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
private async Task<string?> FetchBookId(BookInfo item, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// pattern match the filename
// year can be included for better results
GetBookMetadata(item);
var searchResults = await GetSearchResultsInternal(item, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Items == null)
@ -178,7 +195,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return null;
}
var comparableName = GetComparableName(item.Name);
var comparableName = GetComparableName(item.Name, item.SeriesName, item.IndexNumber);
foreach (var i in searchResults.Items)
{
if (i.VolumeInfo is null)
@ -193,14 +210,14 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
// adjust for google yyyy-mm-dd format
var resultYear = i.VolumeInfo.PublishedDate?.Length > 4 ? i.VolumeInfo.PublishedDate[..4] : i.VolumeInfo.PublishedDate;
if (!int.TryParse(resultYear, out var bookReleaseYear))
var resultYear = GetYearFromPublishedDate(i.VolumeInfo.PublishedDate);
if (resultYear == null)
{
continue;
}
// allow a one year variance
if (Math.Abs(bookReleaseYear - item.Year ?? 0) > 1)
if (Math.Abs(resultYear - item.Year ?? 0) > 1)
{
continue;
}
@ -211,6 +228,18 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return null;
}
private int? GetYearFromPublishedDate(string? publishedDate)
{
var resultYear = publishedDate?.Length > 4 ? publishedDate[..4] : publishedDate;
if (!int.TryParse(resultYear, out var bookReleaseYear))
{
return null;
}
return bookReleaseYear;
}
private async Task<BookResult?> FetchBookData(string googleBookId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@ -221,11 +250,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
using var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
#pragma warning disable CA2007
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
#pragma warning restore CA2007
return await JsonSerializer.DeserializeAsync<BookResult>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
return await response.Content.ReadFromJsonAsync<BookResult>(JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
private Book? ProcessBookData(BookResult bookResult, CancellationToken cancellationToken)
@ -239,59 +264,89 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
cancellationToken.ThrowIfCancellationRequested();
book.Name = bookResult.VolumeInfo.Title;
book.Overview = bookResult.VolumeInfo.Description;
try
book.Overview = WebUtility.HtmlDecode(bookResult.VolumeInfo.Description);
book.ProductionYear = GetYearFromPublishedDate(bookResult.VolumeInfo.PublishedDate);
if (!string.IsNullOrWhiteSpace(bookResult.VolumeInfo.Publisher))
{
book.ProductionYear = bookResult.VolumeInfo.PublishedDate?.Length > 4
? Convert.ToInt32(bookResult.VolumeInfo.PublishedDate[..4], CultureInfo.InvariantCulture)
: Convert.ToInt32(bookResult.VolumeInfo.PublishedDate, CultureInfo.InvariantCulture);
}
catch (Exception)
{
_logger.LogError("Error parsing date");
book.AddStudio(bookResult.VolumeInfo.Publisher);
}
if (!string.IsNullOrEmpty(bookResult.VolumeInfo.Publisher))
{
book.Studios = book.Studios.Append(bookResult.VolumeInfo.Publisher).ToArray();
}
HashSet<string> categories = new HashSet<string>();
var tags = new List<string>();
if (!string.IsNullOrEmpty(bookResult.VolumeInfo.MainCategory))
// Categories are from the BISAC list (https://www.bisg.org/complete-bisac-subject-headings-list)
// Keep the first one (most general) as genre, and add the rest as tags (while dropping the "General" tag)
foreach (var category in bookResult.VolumeInfo.Categories)
{
tags.Add(bookResult.VolumeInfo.MainCategory);
}
if (bookResult.VolumeInfo.Categories is { Count: > 0 })
{
foreach (var category in bookResult.VolumeInfo.Categories)
foreach (var subCategory in category.Split('/', StringSplitOptions.TrimEntries))
{
tags.Add(category);
if (subCategory == "General")
{
continue;
}
categories.Add(subCategory);
}
}
if (tags.Count > 0)
if (categories.Count > 0)
{
tags.AddRange(book.Tags);
book.Tags = tags.ToArray();
book.AddGenre(categories.First());
foreach (var category in categories.Skip(1))
{
book.AddTag(category);
}
}
// google rates out of five so convert to ten
book.CommunityRating = bookResult.VolumeInfo.AverageRating * 2;
if (!string.IsNullOrEmpty(bookResult.Id))
if (bookResult.VolumeInfo.AverageRating.HasValue)
{
book.SetProviderId("GoogleBooks", bookResult.Id);
// google rates out of five so convert to ten
book.CommunityRating = bookResult.VolumeInfo.AverageRating.Value * 2;
}
if (!string.IsNullOrWhiteSpace(bookResult.Id))
{
book.SetProviderId(GoogleBooksConstants.ProviderId, bookResult.Id);
}
return book;
}
private string GetComparableName(string? name)
private void ProcessBookMetadata(MetadataResult<Book> metadataResult, BookResult bookResult)
{
if (string.IsNullOrEmpty(name))
if (bookResult.VolumeInfo == null)
{
return string.Empty;
return;
}
foreach (var author in bookResult.VolumeInfo.Authors)
{
metadataResult.AddPerson(new PersonInfo
{
Name = author,
Type = "Author",
});
}
if (!string.IsNullOrWhiteSpace(bookResult.VolumeInfo.Language))
{
metadataResult.ResultLanguage = bookResult.VolumeInfo.Language;
}
}
private string GetComparableName(string? name, string? seriesName = null, int? index = null)
{
if (string.IsNullOrWhiteSpace(name))
{
if (!string.IsNullOrWhiteSpace(seriesName) && index != null)
{
// We searched by series name and index, so use that
name = $"{seriesName} {index}";
}
else
{
return string.Empty;
}
}
name = name.ToLower(CultureInfo.InvariantCulture);
@ -341,7 +396,11 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return name.Trim();
}
private void GetBookMetadata(BookInfo item)
/// <summary>
/// Extract metadata from the file name.
/// </summary>
/// <param name="item">The info item.</param>
internal void GetBookMetadata(BookInfo item)
{
foreach (var regex in _nameMatches)
{
@ -351,6 +410,18 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
continue;
}
// Reset the name, since we'll get it from parsing
item.Name = string.Empty;
if (item.SeriesName == CollectionType.Books)
{
// If the book is in a folder, the folder's name will be set as the series name
// And we'll override it if we find it in the file name
// If it's not in a folder, the series name will be set to the name of the collection
// In this case reset it so it's not included in the search string
item.SeriesName = string.Empty;
}
// catch return value because user may want to index books from zero
// but zero is also the return value from int.TryParse failure
var result = int.TryParse(match.Groups["index"].Value, out var index);
@ -359,7 +430,15 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
item.IndexNumber = index;
}
item.Name = match.Groups["name"].Value.Trim();
if (match.Groups.TryGetValue("name", out Group? nameGroup))
{
item.Name = nameGroup.Value.Trim();
}
if (match.Groups.TryGetValue("seriesName", out Group? seriesGroup))
{
item.SeriesName = seriesGroup.Value.Trim();
}
// might as well catch the return value here as well
result = int.TryParse(match.Groups["year"].Value, out var year);
@ -369,5 +448,42 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
}
}
/// <summary>
/// Get the search string for the item.
/// If the item is part of a series, use the series name and the issue name or index.
/// Otherwise, use the book name and year.
/// </summary>
/// <param name="item">BookInfo item.</param>
/// <returns>The search query.</returns>
internal string GetSearchString(BookInfo item)
{
string result = string.Empty;
if (!string.IsNullOrWhiteSpace(item.SeriesName))
{
result = item.SeriesName;
if (!string.IsNullOrWhiteSpace(item.Name))
{
result = $"{result} {item.Name}";
}
else if (item.IndexNumber.HasValue)
{
result = $"{result} {item.IndexNumber.Value}";
}
}
else if (!string.IsNullOrWhiteSpace(item.Name))
{
result = item.Name;
if (item.Year.HasValue)
{
result = $"{result} {item.Year.Value}";
}
}
return result;
}
}
}

View File

@ -1,4 +1,4 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
{
@ -8,40 +8,40 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
public class ImageLinks
{
/// <summary>
/// Gets or sets the small thumbnail.
/// Gets or sets the image link for small thumbnail size (width of ~80 pixels).
/// </summary>
/// <remarks>
/// // Only the 2 thumbnail images are available during the initial search.
/// Only the 2 thumbnail images are available during the initial search.
/// </remarks>
[JsonPropertyName("smallThumbnail")]
public string? SmallThumbnail { get; set; }
/// <summary>
/// Gets or sets the thumbnail.
/// Gets or sets the image link for thumbnail size (width of ~128 pixels).
/// </summary>
[JsonPropertyName("thumbnail")]
public string? Thumbnail { get; set; }
/// <summary>
/// Gets or sets the small image.
/// Gets or sets the image link for small size (width of ~300 pixels).
/// </summary>
[JsonPropertyName("small")]
public string? Small { get; set; }
/// <summary>
/// Gets or sets the medium image.
/// Gets or sets the image link for medium size (width of ~575 pixels).
/// </summary>
[JsonPropertyName("medium")]
public string? Medium { get; set; }
/// <summary>
/// Gets or sets the large image.
/// Gets or sets the image link for large size (width of ~800 pixels).
/// </summary>
[JsonPropertyName("large")]
public string? Large { get; set; }
/// <summary>
/// Gets or sets the extra large image.
/// Gets or sets the image link for extra large size (width of ~1280 pixels).
/// </summary>
[JsonPropertyName("extraLarge")]
public string? ExtraLarge { get; set; }

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
@ -10,57 +10,66 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
public class VolumeInfo
{
/// <summary>
/// Gets or sets the title.
/// Gets or sets the volume title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the list of authors.
/// Gets or sets the names of the authors and/or editors for this volume.
/// </summary>
[JsonPropertyName("authors")]
public IReadOnlyList<string> Authors { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the published date.
/// Gets or sets the date of publication.
/// </summary>
[JsonPropertyName("publishedDate")]
public string? PublishedDate { get; set; }
/// <summary>
/// Gets or sets the image links.
/// Gets or sets a list of image links for all the sizes that are available.
/// </summary>
[JsonPropertyName("imageLinks")]
public ImageLinks? ImageLinks { get; set; }
/// <summary>
/// Gets or sets the publisher.
/// Gets or sets the publisher of this volume.
/// </summary>
[JsonPropertyName("publisher")]
public string? Publisher { get; set; }
/// <summary>
/// Gets or sets the description.
/// Gets or sets the synopsis of the volume.
/// The text of the description is formatted in HTML and includes simple formatting elements, such as b, i, and br tags.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Gets or sets the main category.
/// Gets or sets the main category to which this volume belongs.
/// It will be the category from the categories list that has the highest weight.
/// </summary>
[JsonPropertyName("mainCategory")]
public string? MainCategory { get; set; }
/// <summary>
/// Gets or sets the list of categories.
/// Gets or sets the list of subject categories, such as "Fiction", "Suspense", etc.
/// </summary>
[JsonPropertyName("categories")]
public IReadOnlyList<string> Categories { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the average rating.
/// Gets or sets the mean review rating for this volume. (min = 1.0, max = 5.0).
/// </summary>
[JsonPropertyName("averageRating")]
public float AverageRating { get; set; }
public float? AverageRating { get; set; }
/// <summary>
/// Gets or sets the best language for this volume (based on content).
/// It is the two-letter ISO 639-1 code such as 'fr', 'en', etc.
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; set; }
}
}

View File

@ -0,0 +1,82 @@
{
"kind": "books#volume",
"id": "49T5twEACAAJ",
"etag": "erLq86KB0Vg",
"selfLink": "https://www.googleapis.com/books/v1/volumes/49T5twEACAAJ",
"volumeInfo": {
"title": "Children of Time",
"authors": [
"Adrian Tchaikovsky"
],
"publisher": "Orbit",
"publishedDate": "2018-12-11",
"description": "\u003cb\u003eAdrian Tchaikovksy's award-winning novel \u003ci\u003eChildren of Time\u003c/i\u003e, is the epic story of humanity's battle for survival on a terraformed planet.\u003c/b\u003e\u003cb\u003e\u003cbr\u003e\u003c/b\u003eWho will inherit this new Earth?\u003cbr\u003e\u003cbr\u003eThe 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.\u003cbr\u003e\u003cbr\u003eBut 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.\u003cbr\u003e\u003cbr\u003eNow 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",
"industryIdentifiers": [
{
"type": "ISBN_10",
"identifier": "0316452505"
},
{
"type": "ISBN_13",
"identifier": "9780316452502"
}
],
"readingModes": {
"text": false,
"image": false
},
"pageCount": 640,
"printedPageCount": 640,
"dimensions": {
"height": "21.00 cm",
"width": "14.00 cm",
"thickness": "4.10 cm"
},
"printType": "BOOK",
"categories": [
"Fiction / Science Fiction / Alien Contact",
"Fiction / Science Fiction / Genetic Engineering",
"Fiction / Science Fiction / Hard Science Fiction",
"Fiction / Science Fiction / Space Exploration",
"Fiction / Science Fiction / Space Opera"
],
"averageRating": 4,
"ratingsCount": 49,
"maturityRating": "NOT_MATURE",
"allowAnonLogging": false,
"contentVersion": "preview-1.0.0",
"panelizationSummary": {
"containsEpubBubbles": false,
"containsImageBubbles": false
},
"imageLinks": {
"smallThumbnail": "http://books.google.com/books/content?id=49T5twEACAAJ&printsec=frontcover&img=1&zoom=5&imgtk=AFLRE73LHhsrURPw3qT1X-4OnfxY0-tUYmAUe4Z2GQR2R0X2Z2xMc28QU4T77PPDOHIwIk0WUy_MhXUCJH3k8TNZQEjJONM82_3R7fA1CkL57Idz9KmnDEYRLdRh5dyk0F0dgO8US2cN&source=gbs_api",
"thumbnail": "http://books.google.com/books/content?id=49T5twEACAAJ&printsec=frontcover&img=1&zoom=1&imgtk=AFLRE70U9t4z91EAYhiD2AYOR9pzNu86QDKZebNLQo4K3jMaJ748TC5LvCoZGt9ON4pZ54H8RoIRyCB5IveVDmt49QjeJlbJtWLlZoksRHXInrEVmo2476WXKcLhZOjp41Vu_5Lb05oJ&source=gbs_api"
},
"language": "en",
"previewLink": "http://books.google.fr/books?id=49T5twEACAAJ&hl=&source=gbs_api",
"infoLink": "https://play.google.com/store/books/details?id=49T5twEACAAJ&source=gbs_api",
"canonicalVolumeLink": "https://play.google.com/store/books/details?id=49T5twEACAAJ"
},
"saleInfo": {
"country": "FR",
"saleability": "NOT_FOR_SALE",
"isEbook": false
},
"accessInfo": {
"country": "FR",
"viewability": "NO_PAGES",
"embeddable": false,
"publicDomain": false,
"textToSpeechPermission": "ALLOWED",
"epub": {
"isAvailable": false
},
"pdf": {
"isAvailable": false
},
"webReaderLink": "http://play.google.com/books/reader?id=49T5twEACAAJ&hl=&source=gbs_api",
"accessViewStatus": "NONE",
"quoteSharingAllowed": false
}
}

View File

@ -0,0 +1,85 @@
{
"kind": "books#volume",
"id": "G7utDwAAQBAJ",
"etag": "CFZojkd8PV4",
"selfLink": "https://www.googleapis.com/books/v1/volumes/G7utDwAAQBAJ",
"volumeInfo": {
"title": "Dans la toile du temps",
"authors": [
"Adrian Tchaikovsky"
],
"publisher": "Editions Gallimard",
"publishedDate": "2019-10-03T00:00:00+02:00",
"description": "La Terre est au plus mal... Ses derniers habitants nont plus quun seul espoir : coloniser le \"Monde de Kern\", une planète lointaine, spécialement terraformée pour lespèce humaine. Mais sur ce \"monde vert\" paradisiaque, tout ne sest pas déroulé comme les scientifiques sy attendaient. Une autre espèce que celle qui était prévue, aidée par un nanovirus, sest parfaitement adaptée à ce nouvel environnement et elle na pas du tout lintention de laisser sa place. Le choc de deux civilisations aussi différentes que possible semble inévitable. Qui seront donc les héritiers de lancienne Terre ? Qui sortira vainqueur du piège tendu par la toile du temps ? Premier roman de lauteur paru en France, Dans la toile du temps sinscrit dans la lignée du cycle Élévation de David Brin. Il nous fait découvrir lévolution dune civilisation radicalement autre et sa confrontation inévitable avec lespèce humaine. Le roman a reçu le prix Arthur C. Clarke en 2016",
"industryIdentifiers": [
{
"type": "ISBN_10",
"identifier": "2072853311"
},
{
"type": "ISBN_13",
"identifier": "9782072853319"
}
],
"readingModes": {
"text": true,
"image": true
},
"pageCount": 704,
"printedPageCount": 478,
"printType": "BOOK",
"categories": [
"Fiction / Science Fiction / General"
],
"maturityRating": "NOT_MATURE",
"allowAnonLogging": true,
"contentVersion": "1.1.1.0.preview.3",
"panelizationSummary": {
"containsEpubBubbles": false,
"containsImageBubbles": false
},
"imageLinks": {
"smallThumbnail": "http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=5&edge=curl&imgtk=AFLRE71Zhj6v9aMLeOtTnqEjCcUstOUtv8pU3R3ipmb2zOe8ga2XZcKEUNYKzKQhSjwhdXCCWH8j0357XzhN2xCGo4X44XVZ6QadsAKgv-I8bNBmRbie1tmcIKn8huVrjJLGh3ytd5iO&source=gbs_api",
"thumbnail": "http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&imgtk=AFLRE73iXAAA6Bipi-q6HwR1kz5-XegugreP1A2Mbu63gh2TQKdI1lOCoRg9EuW7sFt2RjQgDbAXaHQlBPe8TBY2mo0i2ngWotY1eAvIusIEaCLRD18wl0baMruHUs4b3QvBF56gznpu&source=gbs_api",
"small": "http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=2&edge=curl&imgtk=AFLRE71Xief9ehNn38lxcE4zm0gkG9A2v_B7DVXF-dG4rm9ZWM7eZLLbt7hKOikmOBnt_2yClMeKTcTRawMX1Vyk0aK3wn5ynUUpMf9NF39gN-HQvGgI_zCOTHI666iNvDfhUyZL8sIB&source=gbs_api",
"medium": "http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=3&edge=curl&imgtk=AFLRE70WLksH5tj5q8gFMNUenY-rFrrr5Ff3r5TDt9Uhx6fiPQxm8lE2rhwr-HxUvbIMtXP-CHpV5JAagggBeyO8UjEjuRNZDSkIfABBLneNh9eLdIcBXS-esCeTXc-AyI76tQ0PAsPS&source=gbs_api",
"large": "http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=4&edge=curl&imgtk=AFLRE701An2VOoqqEQ7XZre802y67seosY2vxTgZtotvslfTUGuq6Z1zb_HtCnezKKh2PZuonHdeOjLCIpNn6Ns8GtwGgEdQPeLUoZhL-AZqqJShKvVyc5CVRXjeE9cl1oubIT7c_Tis&source=gbs_api",
"extraLarge": "http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=6&edge=curl&imgtk=AFLRE70zvRYbN6L3AM1H-SFdT_b8RDDGh6SfKIC_erPvfkI3QnpI_sFSIyOjXKgLJqbxVttwKVw12OUkxkPGjlAekXU7tTbpS7OcUQ_XbxhKaIsoC6ekr32GtMzZ5WkHbGu6rRpdIYVQ&source=gbs_api"
},
"language": "fr",
"previewLink": "http://books.google.fr/books?id=G7utDwAAQBAJ&hl=&source=gbs_api",
"infoLink": "https://play.google.com/store/books/details?id=G7utDwAAQBAJ&source=gbs_api",
"canonicalVolumeLink": "https://play.google.com/store/books/details?id=G7utDwAAQBAJ"
},
"layerInfo": {
"layers": [
{
"layerId": "geo",
"volumeAnnotationsVersion": "3"
}
]
},
"saleInfo": {
"country": "FR",
"saleability": "NOT_FOR_SALE",
"isEbook": false
},
"accessInfo": {
"country": "FR",
"viewability": "PARTIAL",
"embeddable": true,
"publicDomain": false,
"textToSpeechPermission": "ALLOWED",
"epub": {
"isAvailable": true,
"acsTokenLink": "http://books.google.fr/books/download/Dans_la_toile_du_temps-sample-epub.acsm?id=G7utDwAAQBAJ&format=epub&output=acs4_fulfillment_token&dl_type=sample&source=gbs_api"
},
"pdf": {
"isAvailable": true,
"acsTokenLink": "http://books.google.fr/books/download/Dans_la_toile_du_temps-sample-pdf.acsm?id=G7utDwAAQBAJ&format=pdf&output=acs4_fulfillment_token&dl_type=sample&source=gbs_api"
},
"webReaderLink": "http://play.google.com/books/reader?id=G7utDwAAQBAJ&hl=&source=gbs_api",
"accessViewStatus": "SAMPLE",
"quoteSharingAllowed": false
}
}

View File

@ -0,0 +1,156 @@
{
"kind": "books#volumes",
"totalItems": 1499,
"items": [
{
"kind": "books#volume",
"id": "49T5twEACAAJ",
"etag": "xf91z5Lu3rE",
"selfLink": "https://www.googleapis.com/books/v1/volumes/49T5twEACAAJ",
"volumeInfo": {
"title": "Children of Time",
"authors": [
"Adrian Tchaikovsky"
],
"publisher": "Orbit",
"publishedDate": "2018-12-11",
"description": "Adrian Tchaikovksy's award-winning novel Children of Time, is the epic story of humanity's battle for survival on a terraformed planet. Who will inherit this new Earth? 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. 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. 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",
"industryIdentifiers": [
{
"type": "ISBN_10",
"identifier": "0316452505"
},
{
"type": "ISBN_13",
"identifier": "9780316452502"
}
],
"readingModes": {
"text": false,
"image": false
},
"pageCount": 0,
"printType": "BOOK",
"categories": [
"Fiction"
],
"averageRating": 4,
"ratingsCount": 49,
"maturityRating": "NOT_MATURE",
"allowAnonLogging": false,
"contentVersion": "preview-1.0.0",
"panelizationSummary": {
"containsEpubBubbles": false,
"containsImageBubbles": false
},
"imageLinks": {
"smallThumbnail": "http://books.google.com/books/content?id=49T5twEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
"thumbnail": "http://books.google.com/books/content?id=49T5twEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
},
"language": "en",
"previewLink": "http://books.google.fr/books?id=49T5twEACAAJ&dq=children+of+time+2015&hl=&cd=1&source=gbs_api",
"infoLink": "http://books.google.fr/books?id=49T5twEACAAJ&dq=children+of+time+2015&hl=&source=gbs_api",
"canonicalVolumeLink": "https://books.google.com/books/about/Children_of_Time.html?hl=&id=49T5twEACAAJ"
},
"saleInfo": {
"country": "FR",
"saleability": "NOT_FOR_SALE",
"isEbook": false
},
"accessInfo": {
"country": "FR",
"viewability": "NO_PAGES",
"embeddable": false,
"publicDomain": false,
"textToSpeechPermission": "ALLOWED",
"epub": {
"isAvailable": false
},
"pdf": {
"isAvailable": false
},
"webReaderLink": "http://play.google.com/books/reader?id=49T5twEACAAJ&hl=&source=gbs_api",
"accessViewStatus": "NONE",
"quoteSharingAllowed": false
},
"searchInfo": {
"textSnippet": "Adrian Tchaikovksy&#39;s award-winning novel Children of Time, is the epic story of humanity&#39;s battle for survival on a terraformed planet."
}
},
{
"kind": "books#volume",
"id": "G7utDwAAQBAJ",
"etag": "CD3eFflJQF0",
"selfLink": "https://www.googleapis.com/books/v1/volumes/G7utDwAAQBAJ",
"volumeInfo": {
"title": "Dans la toile du temps",
"authors": [
"Adrian Tchaikovsky"
],
"publisher": "Editions Gallimard",
"publishedDate": "2019-10-03T00:00:00+02:00",
"description": "La Terre est au plus mal... Ses derniers habitants nont plus quun seul espoir : coloniser le \"Monde de Kern\", une planète lointaine, spécialement terraformée pour lespèce humaine. Mais sur ce \"monde vert\" paradisiaque, tout ne sest pas déroulé comme les scientifiques sy attendaient. Une autre espèce que celle qui était prévue, aidée par un nanovirus, sest parfaitement adaptée à ce nouvel environnement et elle na pas du tout lintention de laisser sa place. Le choc de deux civilisations aussi différentes que possible semble inévitable. Qui seront donc les héritiers de lancienne Terre ? Qui sortira vainqueur du piège tendu par la toile du temps ? Premier roman de lauteur paru en France, Dans la toile du temps sinscrit dans la lignée du cycle Élévation de David Brin. Il nous fait découvrir lévolution dune civilisation radicalement autre et sa confrontation inévitable avec lespèce humaine. Le roman a reçu le prix Arthur C. Clarke en 2016",
"industryIdentifiers": [
{
"type": "ISBN_13",
"identifier": "9782072853319"
},
{
"type": "ISBN_10",
"identifier": "2072853311"
}
],
"readingModes": {
"text": true,
"image": true
},
"pageCount": 478,
"printType": "BOOK",
"categories": [
"Fiction"
],
"maturityRating": "NOT_MATURE",
"allowAnonLogging": true,
"contentVersion": "1.1.1.0.preview.3",
"panelizationSummary": {
"containsEpubBubbles": false,
"containsImageBubbles": false
},
"imageLinks": {
"smallThumbnail": "http://books.google.com/books/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=5&edge=curl&source=gbs_api",
"thumbnail": "http://books.google.com/books/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api"
},
"language": "fr",
"previewLink": "http://books.google.fr/books?id=G7utDwAAQBAJ&printsec=frontcover&dq=children+of+time+2015&hl=&cd=2&source=gbs_api",
"infoLink": "http://books.google.fr/books?id=G7utDwAAQBAJ&dq=children+of+time+2015&hl=&source=gbs_api",
"canonicalVolumeLink": "https://books.google.com/books/about/Dans_la_toile_du_temps.html?hl=&id=G7utDwAAQBAJ"
},
"saleInfo": {
"country": "FR",
"saleability": "NOT_FOR_SALE",
"isEbook": false
},
"accessInfo": {
"country": "FR",
"viewability": "PARTIAL",
"embeddable": true,
"publicDomain": false,
"textToSpeechPermission": "ALLOWED",
"epub": {
"isAvailable": true,
"acsTokenLink": "http://books.google.fr/books/download/Dans_la_toile_du_temps-sample-epub.acsm?id=G7utDwAAQBAJ&format=epub&output=acs4_fulfillment_token&dl_type=sample&source=gbs_api"
},
"pdf": {
"isAvailable": true,
"acsTokenLink": "http://books.google.fr/books/download/Dans_la_toile_du_temps-sample-pdf.acsm?id=G7utDwAAQBAJ&format=pdf&output=acs4_fulfillment_token&dl_type=sample&source=gbs_api"
},
"webReaderLink": "http://play.google.com/books/reader?id=G7utDwAAQBAJ&hl=&source=gbs_api",
"accessViewStatus": "SAMPLE",
"quoteSharingAllowed": false
},
"searchInfo": {
"textSnippet": "Qui sortira vainqueur du piège tendu par la toile du temps ? Premier roman de lauteur paru en France, Dans la toile du temps sinscrit dans la lignée du cycle Élévation de David Brin."
}
}
]
}

View File

@ -0,0 +1,37 @@
using System.Net;
using Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks;
using Jellyfin.Plugin.Bookshelf.Tests.Http;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using NSubstitute;
namespace Jellyfin.Plugin.Bookshelf.Tests
{
public class GoogleBooksImageProviderTests
{
[Fact]
public async Task GetImages_WithAllLinks_PicksLargestAndThumbnail()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes/G7utDwAAQBAJ"), new MockHttpResponse(HttpStatusCode.OK, TestHelpers.GetFixture("google-books-single-volume-fr.json")))
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteImageProvider provider = new GoogleBooksImageProvider(mockedHttpClientFactory);
var images = await provider.GetImages(new Book()
{
ProviderIds = { { GoogleBooksConstants.ProviderId, "G7utDwAAQBAJ" } }
}, CancellationToken.None);
Assert.Collection(
images,
largest => Assert.Equal("http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=6&edge=curl&imgtk=AFLRE70zvRYbN6L3AM1H-SFdT_b8RDDGh6SfKIC_erPvfkI3QnpI_sFSIyOjXKgLJqbxVttwKVw12OUkxkPGjlAekXU7tTbpS7OcUQ_XbxhKaIsoC6ekr32GtMzZ5WkHbGu6rRpdIYVQ&source=gbs_api", largest.Url),
thumbnail => Assert.Equal("http://books.google.com/books/publisher/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&imgtk=AFLRE73iXAAA6Bipi-q6HwR1kz5-XegugreP1A2Mbu63gh2TQKdI1lOCoRg9EuW7sFt2RjQgDbAXaHQlBPe8TBY2mo0i2ngWotY1eAvIusIEaCLRD18wl0baMruHUs4b3QvBF56gznpu&source=gbs_api", thumbnail.Url));
}
}
}

View File

@ -0,0 +1,423 @@
using System.Net;
using Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks;
using Jellyfin.Plugin.Bookshelf.Tests.Http;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Jellyfin.Plugin.Bookshelf.Tests
{
public class GoogleBooksProviderTests
{
// From the query 'https://www.googleapis.com/books/v1/volumes?q=children+of+time+2015'
private string GetTestSearchResult() => TestHelpers.GetFixture("google-books-volume-search.json");
private string GetEnglishTestVolumeResult() => TestHelpers.GetFixture("google-books-single-volume-en.json");
private string GetFrenchTestVolumeResult() => TestHelpers.GetFixture("google-books-single-volume-fr.json");
private bool HasGoogleId(string id, Dictionary<string, string> providerIds)
{
return providerIds.Count == 1
&& providerIds.TryGetValue(GoogleBooksConstants.ProviderId, out var providerId)
&& providerId == id;
}
#region GetSearchResults
[Fact]
public async Task GetSearchResults_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes?q="), new MockHttpResponse(HttpStatusCode.OK, GetTestSearchResult())),
});
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() { Name = "Children of Time" }, 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&source=gbs_api", first.ImageUrl);
Assert.Equal("Adrian Tchaikovksy's award-winning novel Children of Time, is the epic story of humanity's battle for survival on a terraformed planet. Who will inherit this new Earth? 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. 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. 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);
},
second =>
{
Assert.Equal("Dans la toile du temps", second.Name);
Assert.True(HasGoogleId("G7utDwAAQBAJ", second.ProviderIds));
Assert.Equal("http://books.google.com/books/content?id=G7utDwAAQBAJ&printsec=frontcover&img=1&zoom=1&edge=curl&source=gbs_api", second.ImageUrl);
Assert.Equal("La Terre est au plus mal... Ses derniers habitants nont plus quun seul espoir : coloniser le \"Monde de Kern\", une planète lointaine, spécialement terraformée pour lespèce humaine. Mais sur ce \"monde vert\" paradisiaque, tout ne sest pas déroulé comme les scientifiques sy attendaient. Une autre espèce que celle qui était prévue, aidée par un nanovirus, sest parfaitement adaptée à ce nouvel environnement et elle na pas du tout lintention de laisser sa place. Le choc de deux civilisations aussi différentes que possible semble inévitable. Qui seront donc les héritiers de lancienne Terre ? Qui sortira vainqueur du piège tendu par la toile du temps ? Premier roman de lauteur paru en France, Dans la toile du temps sinscrit dans la lignée du cycle Élévation de David Brin. Il nous fait découvrir lévolution dune civilisation radicalement autre et sa confrontation inévitable avec lespèce humaine. Le roman a reçu le prix Arthur C. Clarke en 2016", second.Overview);
Assert.Equal(2019, second.ProductionYear);
});
}
#endregion
#region GetMetadata
[Fact]
public async Task GetMetadata_MatchesByName_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes?q="), new MockHttpResponse(HttpStatusCode.OK, GetTestSearchResult())),
((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 metadataResult = await provider.GetMetadata(new BookInfo() { Name = "Children of Time" }, CancellationToken.None);
Assert.False(metadataResult.QueriedById);
Assert.True(metadataResult.HasMetadata);
Assert.Equal("en", metadataResult.ResultLanguage);
Assert.Collection(metadataResult.People,
p =>
{
Assert.Equal("Adrian Tchaikovsky", p.Name);
Assert.Equal("Author", p.Type);
});
Assert.True(HasGoogleId("49T5twEACAAJ", metadataResult.Item.ProviderIds));
Assert.Equal("Children of Time", metadataResult.Item.Name);
Assert.Collection(metadataResult.Item.Studios,
s =>
{
Assert.Equal("Orbit", s);
});
Assert.Equal(2018, metadataResult.Item.ProductionYear);
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", metadataResult.Item.Overview);
Assert.Collection(metadataResult.Item.Genres,
genre => Assert.Equal("Fiction", genre));
Assert.Collection(metadataResult.Item.Tags,
tag => Assert.Equal("Science Fiction", tag),
tag => Assert.Equal("Alien Contact", tag),
tag => Assert.Equal("Genetic Engineering", tag),
tag => Assert.Equal("Hard Science Fiction", tag),
tag => Assert.Equal("Space Exploration", tag),
tag => Assert.Equal("Space Opera", tag)
);
Assert.Equal(8, metadataResult.Item.CommunityRating);
}
[Fact]
public async Task GetMetadata_MatchesByProviderId_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes/G7utDwAAQBAJ"), new MockHttpResponse(HttpStatusCode.OK, GetFrenchTestVolumeResult()))
});
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 metadataResult = await provider.GetMetadata(new BookInfo()
{
Name = "Children of Time",
ProviderIds = { { GoogleBooksConstants.ProviderId, "G7utDwAAQBAJ" } }
}, CancellationToken.None);
Assert.True(metadataResult.QueriedById);
Assert.True(metadataResult.HasMetadata);
Assert.Equal("fr", metadataResult.ResultLanguage);
Assert.Collection(metadataResult.People,
p =>
{
Assert.Equal("Adrian Tchaikovsky", p.Name);
Assert.Equal("Author", p.Type);
});
Assert.True(HasGoogleId("G7utDwAAQBAJ", metadataResult.Item.ProviderIds));
Assert.Equal("Dans la toile du temps", metadataResult.Item.Name);
Assert.Collection(metadataResult.Item.Studios,
s =>
{
Assert.Equal("Editions Gallimard", s);
});
Assert.Equal(2019, metadataResult.Item.ProductionYear);
Assert.Equal("La Terre est au plus mal... Ses derniers habitants nont plus quun seul espoir : coloniser le \"Monde de Kern\", une planète lointaine, spécialement terraformée pour lespèce humaine. " +
"Mais sur ce \"monde vert\" paradisiaque, tout ne sest pas déroulé comme les scientifiques sy attendaient. " +
"Une autre espèce que celle qui était prévue, aidée par un nanovirus, sest parfaitement adaptée à ce nouvel environnement et elle na pas du tout lintention de laisser sa place. " +
"Le choc de deux civilisations aussi différentes que possible semble inévitable. " +
"Qui seront donc les héritiers de lancienne Terre ? Qui sortira vainqueur du piège tendu par la toile du temps ? " +
"Premier roman de lauteur paru en France, Dans la toile du temps sinscrit dans la lignée du cycle Élévation de David Brin. " +
"Il nous fait découvrir lévolution dune civilisation radicalement autre et sa confrontation inévitable avec lespèce humaine. " +
"Le roman a reçu le prix Arthur C. Clarke en 2016", metadataResult.Item.Overview);
Assert.Collection(metadataResult.Item.Genres,
genre => Assert.Equal("Fiction", genre));
Assert.Collection(metadataResult.Item.Tags,
tag => Assert.Equal("Science Fiction", tag));
Assert.Null(metadataResult.Item.CommunityRating);
}
[Fact]
public async Task GetMetadata_MatchesByNameWithYearVariance_SkipsResult()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("volumes?q="), new MockHttpResponse(HttpStatusCode.OK, GetTestSearchResult())),
((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 metadataResult = await provider.GetMetadata(new BookInfo() { Name = "Children of Time (2015)" }, CancellationToken.None);
Assert.False(metadataResult.HasMetadata);
Assert.Null(metadataResult.Item);
}
#endregion
#region GetBookMetadata
[Fact]
public void GetBookMetadata_WithName_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo() { Name = "Children of Time" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Time", bookInfo.Name);
}
[Fact]
public void GetBookMetadata_WithNameAndDefaultSeriesName_CorrectlyResetSeriesName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo() { SeriesName = CollectionType.Books, Name = "Children of Time" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Time", bookInfo.Name);
Assert.Equal(string.Empty, bookInfo.SeriesName);
}
[Fact]
public void GetBookMetadata_WithNameAndYear_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo() { Name = "Children of Time (2015)" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Time", bookInfo.Name);
Assert.Equal(2015, bookInfo.Year);
}
[Fact]
public void GetBookMetadata_WithIndexAndName_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo() { Name = "1 - Children of Time" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Time", bookInfo.Name);
Assert.Equal(1, bookInfo.IndexNumber);
}
[Fact]
public void GetBookMetadata_WithIndexAndNameInFolder_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
// The series can already be identified from the folder name
var bookInfo = new BookInfo() { SeriesName = "Children of Time", Name = "2 - Children of Ruin" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal("Children of Ruin", bookInfo.Name);
Assert.Equal(2, bookInfo.IndexNumber);
}
[Fact]
public void GetBookMetadata_WithIndexNameAndYear_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo() { Name = "1 - Children of Time (2015)" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Time", bookInfo.Name);
Assert.Equal(1, bookInfo.IndexNumber);
Assert.Equal(2015, bookInfo.Year);
}
[Fact]
public void GetBookMetadata_WithComicFormat_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
// Complete format
var bookInfo = new BookInfo() { Name = "Children of Time (2015) #2 (of 3) (2019)" };
provider.GetBookMetadata(bookInfo);
Assert.Empty(bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
Assert.Equal(2019, bookInfo.Year);
// Without series year
bookInfo = new BookInfo() { Name = "Children of Time #2 (of 3) (2019)" };
provider.GetBookMetadata(bookInfo);
Assert.Empty(bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
Assert.Equal(2019, bookInfo.Year);
// Without total count
bookInfo = new BookInfo() { Name = "Children of Time #2 (2019)" };
provider.GetBookMetadata(bookInfo);
Assert.Empty(bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
Assert.Equal(2019, bookInfo.Year);
// With only issue number
bookInfo = new BookInfo() { Name = "Children of Time #2" };
provider.GetBookMetadata(bookInfo);
Assert.Empty(bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
}
[Fact]
public void GetBookMetadata_WithGoodreadsFormat_CorrectlyMatchesFileName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
// Goodreads format
var bookInfo = new BookInfo() { Name = "Children of Ruin (Children of Time, #2)" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Ruin", bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
// Goodreads format with year added
bookInfo = new BookInfo() { Name = "Children of Ruin (Children of Time, #2) (2019)" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Ruin", bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
Assert.Equal(2019, bookInfo.Year);
}
[Fact]
public void GetBookMetadata_WithSeriesAndName_OverridesSeriesName()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo() { SeriesName = "Adrian Tchaikovsky", Name = "Children of Ruin (Children of Time, #2)" };
provider.GetBookMetadata(bookInfo);
Assert.Equal("Children of Ruin", bookInfo.Name);
Assert.Equal("Children of Time", bookInfo.SeriesName);
Assert.Equal(2, bookInfo.IndexNumber);
}
#endregion
#region GetSearchString
[Fact]
public void GetSearchString_WithSeriesAndName_ReturnsCorrectString()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo()
{
SeriesName = "Invincible",
Name = "Eight is Enough",
IndexNumber = 2,
Year = 2004
};
var searchString = provider.GetSearchString(bookInfo);
Assert.Equal("Invincible Eight is Enough", searchString);
}
[Fact]
public void GetSearchString_WithSeriesAndIndex_ReturnsCorrectString()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo()
{
SeriesName = "Invincible",
IndexNumber = 2
};
var searchString = provider.GetSearchString(bookInfo);
Assert.Equal("Invincible 2", searchString);
}
[Fact]
public void GetSearchString_WithNameAndYear_ReturnsCorrectString()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo()
{
Name = "Eight is Enough",
Year = 2004
};
var searchString = provider.GetSearchString(bookInfo);
Assert.Equal("Eight is Enough 2004", searchString);
}
[Fact]
public void GetSearchString_WithOnlyName_ReturnsCorrectString()
{
GoogleBooksProvider provider = new GoogleBooksProvider(NullLogger<GoogleBooksProvider>.Instance, Substitute.For<IHttpClientFactory>());
var bookInfo = new BookInfo()
{
Name = "Eight is Enough",
};
var searchString = provider.GetSearchString(bookInfo);
Assert.Equal("Eight is Enough", searchString);
}
#endregion
}
}

View File

@ -0,0 +1,37 @@
namespace Jellyfin.Plugin.Bookshelf.Tests.Http
{
/// <summary>
/// HttpMessageHandler that returns a mocked response.
/// </summary>
internal class MockHttpMessageHandler : HttpMessageHandler
{
private readonly List<(Func<Uri, bool> RequestMatcher, MockHttpResponse Response)> _messageHandlers;
public MockHttpMessageHandler(List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)> messageHandlers)
{
_messageHandlers = messageHandlers;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri == null)
{
throw new ArgumentNullException(nameof(request.RequestUri));
}
var response = _messageHandlers.FirstOrDefault(x => x.RequestMatcher(request.RequestUri)).Response;
if (response == null)
{
throw new InvalidOperationException($"No response found for request {request.RequestUri}");
}
return Task.FromResult(new HttpResponseMessage
{
StatusCode = response.StatusCode,
Content = new StringContent(response.Response)
});
}
}
}

View File

@ -0,0 +1,6 @@
using System.Net;
namespace Jellyfin.Plugin.Bookshelf.Tests.Http
{
internal record MockHttpResponse(HttpStatusCode StatusCode, string Response);
}

View File

@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@ -31,4 +32,16 @@
<ProjectReference Include="..\..\Jellyfin.Plugin.Bookshelf\Jellyfin.Plugin.Bookshelf.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\google-books-single-volume-en.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Fixtures\google-books-single-volume-fr.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Fixtures\google-books-volume-search.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -0,0 +1,23 @@
namespace Jellyfin.Plugin.Bookshelf.Tests
{
internal static class TestHelpers
{
/// <summary>
/// Get the content of a fixture file.
/// </summary>
/// <param name="fileName">Name of the fixture file.</param>
/// <returns>The file's content.</returns>
/// <exception cref="FileNotFoundException">If the file does not exist.</exception>
public static string GetFixture(string fileName)
{
var filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Fixtures", fileName);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"The fixture file '{filePath}' was not found.");
}
return File.ReadAllText(filePath);
}
}
}

View File

@ -0,0 +1 @@
global using Xunit;