Add Comic Vine metadata provider (#81)

* Move file name parsing logic to a separate class

* Add the Comic Vine API key to the configuration page

* Add models for the Comic Vine API

* Update the Comic Vine providers

* Add test fixtures and some empty tests for the Comic Vine provider.

* Update issue search and complete GetSearchResults tests

* Complete GetMetadata tests for the Comic Vine provider

* Add test for the Comic Vine image provider

* Add Comic Vine information to the readme

* Fix typo in Comic Vine provider

* Fix some CodeQL warnings

* Fix some more CodeQL issues and add more tests

* Change displayed image in Comic Vine search results

* Update regex number match

* Update fixture files inclusion
This commit is contained in:
Pithaya 2023-11-02 14:47:38 +01:00 committed by GitHub
parent e31429c87e
commit a4cfe0009a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 3162 additions and 777 deletions

View File

@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Bookshelf.Common
{
/// <summary>
/// Helper class to retrieve name, year, index and series name from a book name and parent.
/// </summary>
public static class BookFileNameParser
{
// convert these characters to whitespace for better matching
// there are two dashes with different char codes
private const string Spacers = "/,.:;\\(){}[]+-_=*";
private const string Remove = "\"'!`?";
private const string NameMatchGroup = "name";
private const string SeriesNameMatchGroup = "seriesName";
private const string IndexMatchGroup = "index";
private const string YearMatchGroup = "year";
private static readonly Regex[] _nameMatches =
{
// seriesName (seriesYear) #index (of count) (year), with only seriesName and index required
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
// name (seriesName, #index) (year), with year optional
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
// index - name (year), with year optional
new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
// name (year)
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
};
private static readonly Dictionary<string, string> _replaceEndNumerals = new ()
{
{ " i", " 1" },
{ " ii", " 2" },
{ " iii", " 3" },
{ " iv", " 4" },
{ " v", " 5" },
{ " vi", " 6" },
{ " vii", " 7" },
{ " viii", " 8" },
{ " ix", " 9" },
{ " x", " 10" }
};
/// <summary>
/// Parse the book name and parent folder name to retrieve the book name, series name, index and year.
/// </summary>
/// <param name="name">Book file name.</param>
/// <param name="seriesName">Book series name.</param>
/// <returns>The parsing result.</returns>
public static BookFileNameParserResult Parse(string name, string seriesName)
{
BookFileNameParserResult result = default;
foreach (var regex in _nameMatches)
{
var match = regex.Match(name);
if (!match.Success)
{
continue;
}
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
{
result.Name = nameGroup.Value.Trim();
}
if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success)
{
result.SeriesName = seriesGroup.Value.Trim();
}
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup)
&& indexGroup.Success
&& int.TryParse(indexGroup.Value, out var index))
{
result.Index = index;
}
if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup)
&& yearGroup.Success
&& int.TryParse(yearGroup.Value, out var year))
{
result.Year = year;
}
break;
}
// If the book is in a folder, the folder's name will be set as the series name
// If it's not in a folder, the series name will be set to the name of the collection
// So if we couldn't find the series name in the book name, use the folder name instead
if (string.IsNullOrWhiteSpace(result.SeriesName) && seriesName != CollectionType.Books)
{
result.SeriesName = seriesName;
}
return result;
}
/// <summary>
/// Format a string to make it easier to compare.
/// </summary>
/// <param name="value">Value to format.</param>
/// <param name="replaceEndNumerals">Whether end numerals should be replaced.</param>
/// <returns>The formatted string.</returns>
public static string GetComparableString(string value, bool replaceEndNumerals)
{
value = value.ToLower(CultureInfo.InvariantCulture);
value = value.Normalize(NormalizationForm.FormC);
if (replaceEndNumerals)
{
string? endNumerals = _replaceEndNumerals.Keys.FirstOrDefault(key => value.EndsWith(key, StringComparison.OrdinalIgnoreCase));
if (endNumerals != null)
{
var replacement = _replaceEndNumerals[endNumerals];
value = value.Remove(value.Length - endNumerals.Length, endNumerals.Length);
value += replacement;
}
}
var sb = new StringBuilder();
foreach (var c in value)
{
if (c >= 0x2B0 && c <= 0x0333)
{
// skip char modifier and diacritics
}
else if (Remove.IndexOf(c, StringComparison.Ordinal) > -1)
{
// skip chars we are removing
}
else if (Spacers.IndexOf(c, StringComparison.Ordinal) > -1)
{
sb.Append(' ');
}
else if (c == '&')
{
sb.Append(" and ");
}
else
{
sb.Append(c);
}
}
value = sb.ToString();
value = value.Replace("the", " ", StringComparison.OrdinalIgnoreCase);
value = value.Replace(" - ", ": ", StringComparison.Ordinal);
var regex = new Regex(@"\s+");
value = regex.Replace(value, " ");
return value.Trim();
}
/// <summary>
/// Gets the extracted metadata from the item's properties.
/// </summary>
/// <param name="item">The info item.</param>
/// <returns>The extracted metadata.</returns>
public static BookInfo GetBookMetadata(BookInfo item)
{
var nameParserResult = Parse(item.Name, item.SeriesName);
return new BookInfo()
{
Name = nameParserResult.Name ?? string.Empty,
SeriesName = nameParserResult.SeriesName ?? string.Empty,
IndexNumber = nameParserResult.Index,
Year = nameParserResult.Year,
};
}
}
}

View File

@ -0,0 +1,89 @@
using System;
namespace Jellyfin.Plugin.Bookshelf.Common
{
/// <summary>
/// Data object used to pass result of the book name parsing.
/// </summary>
public struct BookFileNameParserResult : IEquatable<BookFileNameParserResult>
{
/// <summary>
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> struct.
/// </summary>
public BookFileNameParserResult()
{
Name = null;
SeriesName = null;
Index = null;
Year = null;
}
/// <summary>
/// Gets or sets the name of the book.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the series name.
/// </summary>
public string? SeriesName { get; set; }
/// <summary>
/// Gets or sets the book index.
/// </summary>
public int? Index { get; set; }
/// <summary>
/// Gets or sets the year.
/// </summary>
public int? Year { get; set; }
/// <summary>
/// Compare two <see cref="BookFileNameParserResult"/> objects.
/// </summary>
/// <param name="left">Left object.</param>
/// <param name="right">Right object.</param>
/// <returns>True if the objects are equal.</returns>
public static bool operator ==(BookFileNameParserResult left, BookFileNameParserResult right)
{
return left.Equals(right);
}
/// <summary>
/// Compare two <see cref="BookFileNameParserResult"/> objects.
/// </summary>
/// <param name="left">Left object.</param>
/// <param name="right">Right object.</param>
/// <returns>True if the objects are not equal.</returns>
public static bool operator !=(BookFileNameParserResult left, BookFileNameParserResult right)
{
return !(left == right);
}
/// <inheritdoc />
public override bool Equals(object? obj)
{
if (obj is null || obj is not BookFileNameParserResult)
{
return false;
}
return Equals((BookFileNameParserResult)obj);
}
/// <inheritdoc />
public override int GetHashCode()
{
return HashCode.Combine(Name, SeriesName, Index, Year);
}
/// <inheritdoc />
public bool Equals(BookFileNameParserResult other)
{
return Name == other.Name
&& SeriesName == other.SeriesName
&& Index == other.Index
&& Year == other.Year;
}
}
}

View File

@ -1,4 +1,4 @@
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Bookshelf.Configuration
{
@ -7,5 +7,10 @@ namespace Jellyfin.Plugin.Bookshelf.Configuration
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the Comic Vine API key.
/// </summary>
/// <remarks>The rate limit is 200 requests per resource, per hour.</remarks>
public string ComicVineApiKey { get; set; } = string.Empty;
}
}

View File

@ -1,65 +1,57 @@
<!DOCTYPE html>
<html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bookshelf</title>
</head>
<body>
<div id="bookshelfConfigurationPage" data-role="page" class="page type-interior pluginConfigurationPage">
<div id="bookshelfConfigurationPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input">
<div data-role="content">
<div class="content-primary">
<h1>Bookshelf</h1>
<p>To set up Bookshelf, just add a media library in the server configuration and set the content type to
books. If you want to see support for a specific provider we always welcome code
contributions!
</p>
<h1>Supported Formats</h1>
Please take in mind that this is not a complete list.
<ul>
<li>epub</li>
<li>mobi</li>
<li>pdf</li>
<li>cbz</li>
<li>cbr</li>
<li>mp3</li>
<li>m4a</li>
<li>m4b</li>
<li>flac</li>
</ul>
<h1>Offline Metadata providers</h1>
<ul>
<li>Open Packaging Format (OPF)</li>
<li>Calibre OPF</li>
<li>ComicInfo</li>
<li>ComicBookInfo</li>
</ul>
The following <b>limitations</b> apply:
<ul>
<li>
.cbr Comics tagged with ComicRack's ComicInfo format
are partially supported. Any metadata bundled with the
comic book itself will be ignored. External files will be read.
</li>
<li>
The
<a href="https://launchpad.net/acbf">Advanced Comic Book Format</a>
format is not supported.
</li>
<li>
The
<a href="https://www.denvog.com/comet/comet-specification/">CoMet</a>
format is not supported.
</li>
</ul>
<h1>Online Metadata providers</h1>
<ul>
<li>Google Books</li>
</ul>
<form id="bookshelfConfigurationForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="comicVineApiKey">Comic Vine API Key</label>
<input id="comicVineApiKey" name="ComicVineApiKey" type="text" is="emby-input" required />
<div class="fieldDescription"> Comic Vine API Key. Get one <a href="https://comicvine.gamespot.com/api/" target="_blank">here</a>.</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
var TemplateConfig = {
pluginUniqueId: '9c4e63f1-031b-4f25-988b-4f7d78a8b53e'
};
document.querySelector('#bookshelfConfigurationPage')
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
document.querySelector('#comicVineApiKey').value = config.ComicVineApiKey;
Dashboard.hideLoadingMsg();
});
});
document.querySelector('#bookshelfConfigurationForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(TemplateConfig.pluginUniqueId).then(function (config) {
config.ComicVineApiKey = document.querySelector('#comicVineApiKey').value;
ApiClient.updatePluginConfiguration(TemplateConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>

View File

@ -1,6 +1,7 @@
using Jellyfin.Plugin.Bookshelf.Providers;
using Jellyfin.Plugin.Bookshelf.Providers.ComicBookInfo;
using Jellyfin.Plugin.Bookshelf.Providers.ComicInfo;
using Jellyfin.Plugin.Bookshelf.Providers.ComicVine;
using MediaBrowser.Common.Plugins;
using Microsoft.Extensions.DependencyInjection;
@ -21,6 +22,9 @@ namespace Jellyfin.Plugin.Bookshelf
serviceCollection.AddSingleton<IComicFileProvider, ExternalComicInfoProvider>();
serviceCollection.AddSingleton<IComicFileProvider, InternalComicInfoProvider>();
serviceCollection.AddSingleton<IComicFileProvider, ComicBookInfoProvider>();
serviceCollection.AddSingleton<IComicVineMetadataCacheManager, ComicVineMetadataCacheManager>();
serviceCollection.AddSingleton<IComicVineApiKeyProvider, ComicVineApiKeyProvider>();
}
}
}

View File

@ -0,0 +1,226 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models;
using MediaBrowser.Common.Net;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Base class for the Comic Vine providers.
/// </summary>
public abstract class BaseComicVineProvider
{
private const string IssueIdMatchGroup = "issueId";
private readonly ILogger<BaseComicVineProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IComicVineMetadataCacheManager _comicVineMetadataCacheManager;
private readonly IComicVineApiKeyProvider _apiKeyProvider;
private static readonly Regex[] _issueIdMatches = new[]
{
// The slug needs to be stored in the provider id for the IExternalId implementation
new Regex(@"^(?<slug>.+?)\/(?<issueId>[0-9]+-[0-9]+)$"),
// Also support the issue id on its own for manual searches
new Regex(@"^(?<issueId>[0-9]+-[0-9]+)$")
};
/// <summary>
/// Initializes a new instance of the <see cref="BaseComicVineProvider"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{BaseComicVineProvider}"/> interface.</param>
/// <param name="comicVineMetadataCacheManager">Instance of the <see cref="IComicVineMetadataCacheManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="apiKeyProvider">Instance of the <see cref="IComicVineApiKeyProvider"/> interface.</param>
protected BaseComicVineProvider(ILogger<BaseComicVineProvider> logger, IComicVineMetadataCacheManager comicVineMetadataCacheManager, IHttpClientFactory httpClientFactory, IComicVineApiKeyProvider apiKeyProvider)
{
_logger = logger;
_comicVineMetadataCacheManager = comicVineMetadataCacheManager;
_httpClientFactory = httpClientFactory;
_apiKeyProvider = apiKeyProvider;
}
/// <summary>
/// Gets the json options for deserializing the Comic Vine API responses.
/// </summary>
protected JsonSerializerOptions JsonOptions { get; } = new JsonSerializerOptions(JsonDefaults.Options)
{
PropertyNamingPolicy = new JsonSnakeCaseNamingPolicy()
};
/// <summary>
/// Get the details of a resource item from the cache.
/// If it's not already cached, fetch it from the API and add it to the cache.
/// </summary>
/// <param name="providerId">The provider id for the resource.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <returns>The resource details, or null if not found.</returns>
protected async Task<T?> GetOrAddItemDetailsFromCache<T>(string providerId, CancellationToken cancellationToken)
{
try
{
var itemApiId = GetApiIdFromProviderId(providerId);
if (string.IsNullOrWhiteSpace(itemApiId))
{
_logger.LogInformation("Couldn't get API id from provider id {ProviderId}.", providerId);
return default;
}
if (!_comicVineMetadataCacheManager.HasCache(itemApiId))
{
var itemDetails = await FetchItemDetails<T>(itemApiId, cancellationToken).ConfigureAwait(false);
if (itemDetails == null)
{
_logger.LogInformation("Resource with id {ApiId} was not found.", itemApiId);
return default;
}
_logger.LogInformation("Adding resource with id {ApiId} to the cache.", itemApiId);
await _comicVineMetadataCacheManager.AddToCache<T>(itemApiId, itemDetails, cancellationToken).ConfigureAwait(false);
return itemDetails;
}
else
{
_logger.LogInformation("Found resource with id {ApiId} in the cache.", itemApiId);
return await _comicVineMetadataCacheManager.GetFromCache<T>(itemApiId, cancellationToken).ConfigureAwait(false);
}
}
catch (FileNotFoundException fileEx)
{
_logger.LogWarning("Cannot find cache file {FileName}.", fileEx.FileName);
}
catch (DirectoryNotFoundException directoryEx)
{
_logger.LogWarning("Cannot find cache directory: {ExceptionMessage}.", directoryEx.Message);
}
return default;
}
/// <summary>
/// Get the details for a specific resource from its id.
/// </summary>
/// <param name="apiId">The id of the resource.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The resource details if found.</returns>
private async Task<T?> FetchItemDetails<T>(string apiId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var apiKey = _apiKeyProvider.GetApiKey();
if (apiKey == null)
{
return default;
}
string resourceDetailsUrl = typeof(T) switch
{
Type issue when issue == typeof(IssueDetails) => ComicVineApiUrls.IssueDetailUrl,
Type volume when volume == typeof(VolumeDetails) => ComicVineApiUrls.VolumeDetailUrl,
_ => throw new InvalidOperationException($"Unexpected resource type {typeof(T)}.")
};
var url = string.Format(CultureInfo.InvariantCulture, resourceDetailsUrl, apiKey, apiId);
var response = await _httpClientFactory
.CreateClient(NamedClient.Default)
.GetAsync(url, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Got non successful response code from Comic Vine API: {StatusCode}.", response.StatusCode);
return default;
}
var apiResponse = await response.Content.ReadFromJsonAsync<ItemApiResponse<T>>(JsonOptions, cancellationToken).ConfigureAwait(false);
if (apiResponse == null)
{
_logger.LogError("Failed to deserialize Comic Vine API response.");
return default;
}
var results = GetFromApiResponse<T>(apiResponse);
if (results.Count() != 1)
{
_logger.LogError("Unexpected number of results in Comic Vine API response.");
return default;
}
return results.Single();
}
/// <summary>
/// Get the results from the API response.
/// </summary>
/// <typeparam name="T">Type of the results.</typeparam>
/// <param name="response">API response.</param>
/// <returns>The results.</returns>
protected IEnumerable<T> GetFromApiResponse<T>(BaseApiResponse<T> response)
{
if (response.IsError)
{
_logger.LogError("Comic Vine API response received with error code {ErrorCode} : {ErrorMessage}", response.StatusCode, response.Error);
return Enumerable.Empty<T>();
}
if (response is SearchApiResponse<T> searchResponse)
{
return searchResponse.Results;
}
else if (response is ItemApiResponse<T> itemResponse)
{
return itemResponse.Results == null ? Enumerable.Empty<T>() : new[] { itemResponse.Results };
}
else
{
return Enumerable.Empty<T>();
}
}
/// <summary>
/// Gets the two part API id from the provider id ({slug}/{fixed-value}-{id}).
/// </summary>
/// <param name="providerId">Provider id.</param>
/// <returns>The API id.</returns>
protected string? GetApiIdFromProviderId(string providerId)
{
foreach (var regex in _issueIdMatches)
{
var match = regex.Match(providerId);
if (!match.Success)
{
continue;
}
if (match.Groups.TryGetValue(IssueIdMatchGroup, out Group? issueIdGroup) && issueIdGroup.Success)
{
return issueIdGroup.Value;
}
}
return null;
}
}
}

View File

@ -0,0 +1,27 @@
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
internal class ComicVineApiKeyProvider : IComicVineApiKeyProvider
{
private readonly ILogger<ComicVineApiKeyProvider> _logger;
public ComicVineApiKeyProvider(ILogger<ComicVineApiKeyProvider> logger)
{
_logger = logger;
}
public string? GetApiKey()
{
var apiKey = Plugin.Instance?.Configuration.ComicVineApiKey;
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogWarning("Comic Vine API key is not set.");
return null;
}
return apiKey;
}
}
}

View File

@ -0,0 +1,33 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Comic Vine API urls.
/// </summary>
internal static class ComicVineApiUrls
{
/// <summary>
/// Gets the base url of the website.
/// </summary>
public const string BaseWebsiteUrl = @"https://comicvine.gamespot.com";
/// <summary>
/// Gets the base url of the API.
/// </summary>
public const string BaseUrl = @$"{BaseWebsiteUrl}/api";
/// <summary>
/// Gets the URL used to search for issues.
/// </summary>
public const string IssueSearchUrl = BaseUrl + @"/search?api_key={0}&format=json&resources=issue&query={1}";
/// <summary>
/// Gets the URL used to fetch a specific issue.
/// </summary>
public const string IssueDetailUrl = BaseUrl + @"/issue/{1}?api_key={0}&format=json";
/// <summary>
/// Gets the URL used to fetch a specific volume.
/// </summary>
public const string VolumeDetailUrl = BaseUrl + @"/volume/{1}?api_key={0}&format=json&field_list=api_detail_url,id,name,site_detail_url,count_of_issues,description,publisher";
}
}

View File

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

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.ComicVine
{
/// <inheritdoc />
public class ComicVineExternalId : IExternalId
{
/// <inheritdoc />
public string ProviderName => ComicVineConstants.ProviderName;
/// <inheritdoc />
public string Key => ComicVineConstants.ProviderId;
/// <inheritdoc />
public ExternalIdMediaType? Type => null; // TODO: No ExternalIdMediaType value for book
/// <inheritdoc />
public string? UrlFormatString => ComicVineApiUrls.BaseWebsiteUrl + "/{0}";
/// <inheritdoc />
public bool Supports(IHasProviderIds item) => item is Book;
}
}

View File

@ -1,84 +1,126 @@
/*namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
public class ComicVineImageProvider : IRemoteImageProvider
{
private readonly IHttpClient _httpClient;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
public ComicVineImageProvider(IHttpClient httpClient, IJsonSerializer jsonSerializer)
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
_httpClient = httpClient;
_jsonSerializer = jsonSerializer;
/// <summary>
/// Comic Vine image provider.
/// </summary>
public class ComicVineImageProvider : BaseComicVineProvider, IRemoteImageProvider
{
private readonly ILogger<ComicVineImageProvider> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ComicVineImageProvider"/> class.
/// </summary>
/// <param name="comicVineMetadataCacheManager">Instance of the <see cref="IComicVineMetadataCacheManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{ComicVineImageProvider}"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="apiKeyProvider">Instance of the <see cref="IComicVineApiKeyProvider"/> interface.</param>
public ComicVineImageProvider(IComicVineMetadataCacheManager comicVineMetadataCacheManager, ILogger<ComicVineImageProvider> logger, IHttpClientFactory httpClientFactory, IComicVineApiKeyProvider apiKeyProvider)
: base(logger, comicVineMetadataCacheManager, httpClientFactory, apiKeyProvider)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
private readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly IJsonSerializer _jsonSerializer;
/// <inheritdoc/>
public string Name => ComicVineConstants.ProviderName;
public async Task<IEnumerable<RemoteImageInfo>> GetImages(IHasMetadata item, CancellationToken cancellationToken)
/// <inheritdoc/>
public bool Supports(BaseItem item)
{
var volumeId = item.GetProviderId(ComicVineVolumeExternalId.KeyName);
return item is Book;
}
var images = new List<RemoteImageInfo>();
if (!string.IsNullOrEmpty(volumeId))
/// <inheritdoc/>
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
var issueNumber = ComicVineMetadataProvider.GetIssueNumberFromName(item.Name).ToString(_usCulture);
yield return ImageType.Primary;
}
await ComicVineMetadataProvider.Current.EnsureCacheFile(volumeId, issueNumber, cancellationToken).ConfigureAwait(false);
var cachePath = ComicVineMetadataProvider.Current.GetCacheFilePath(volumeId, issueNumber);
try
/// <inheritdoc/>
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var issueInfo = _jsonSerializer.DeserializeFromFile<SearchResult>(cachePath);
cancellationToken.ThrowIfCancellationRequested();
if (issueInfo.results.Count > 0)
{
var result = issueInfo.results[0].image;
var issueProviderId = item.GetProviderId(ComicVineConstants.ProviderId);
if (!string.IsNullOrEmpty(result.medium_url))
if (string.IsNullOrWhiteSpace(issueProviderId))
{
images.Add(new RemoteImageInfo
return Enumerable.Empty<RemoteImageInfo>();
}
var issueDetails = await GetOrAddItemDetailsFromCache<IssueDetails>(issueProviderId, cancellationToken).ConfigureAwait(false);
if (issueDetails == null)
{
Url = result.medium_url,
ProviderName = Name
return Enumerable.Empty<RemoteImageInfo>();
}
var images = ProcessIssueImages(issueDetails)
.Select(url => new RemoteImageInfo
{
Url = url,
ProviderName = ComicVineConstants.ProviderName
});
return images;
}
}
}
catch (FileNotFoundException)
/// <summary>
/// Gets images URLs from the issue.
/// </summary>
/// <param name="issueDetails">The issue details.</param>
/// <returns>The list of images URLs.</returns>
private IEnumerable<string> ProcessIssueImages(IssueDetails issueDetails)
{
}
catch (DirectoryNotFoundException)
if (issueDetails.Image == null)
{
return Enumerable.Empty<string>();
}
var images = new List<string>();
if (!string.IsNullOrWhiteSpace(issueDetails.Image.SuperUrl))
{
images.Add(issueDetails.Image.SuperUrl);
}
else if (!string.IsNullOrWhiteSpace(issueDetails.Image.OriginalUrl))
{
images.Add(issueDetails.Image.OriginalUrl);
}
else if (!string.IsNullOrWhiteSpace(issueDetails.Image.MediumUrl))
{
images.Add(issueDetails.Image.MediumUrl);
}
else if (!string.IsNullOrWhiteSpace(issueDetails.Image.SmallUrl))
{
images.Add(issueDetails.Image.SmallUrl);
}
else if (!string.IsNullOrWhiteSpace(issueDetails.Image.ThumbUrl))
{
images.Add(issueDetails.Image.ThumbUrl);
}
return images;
}
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
/// <inheritdoc/>
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
return _httpClient.GetResponse(new HttpRequestOptions
{
CancellationToken = cancellationToken,
Url = url,
ResourcePool = Plugin.Instance.ComicVineSemiphore
});
}
public IEnumerable<ImageType> GetSupportedImages(IHasMetadata item)
{
return new List<ImageType> {ImageType.Primary};
}
public string Name
{
get { return "Comic Vine"; }
}
public bool Supports(IHasMetadata item)
{
return item is Book;
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
}
}
}
}*/

View File

@ -0,0 +1,83 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Comic Vine metadata cache manager.
/// </summary>
internal class ComicVineMetadataCacheManager : IComicVineMetadataCacheManager
{
/// <summary>
/// Cache time in days.
/// </summary>
private const int CacheTime = 7;
private readonly ILogger<ComicVineMetadataCacheManager> _logger;
private readonly IApplicationPaths _appPaths;
private readonly IFileSystem _fileSystem;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
/// <summary>
/// Initializes a new instance of the <see cref="ComicVineMetadataCacheManager"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{ComicVineMetadataCacheManager}"/> interface.</param>
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
public ComicVineMetadataCacheManager(ILogger<ComicVineMetadataCacheManager> logger, IApplicationPaths appPaths, IFileSystem fileSystem)
{
_logger = logger;
_appPaths = appPaths;
_fileSystem = fileSystem;
// Ensure the cache directory exists
Directory.CreateDirectory(GetComicVineCachePath());
}
private string GetCacheFilePath(string apiId)
{
return Path.Combine(GetComicVineCachePath(), $"{apiId}.json");
}
private string GetComicVineCachePath()
{
return Path.Combine(_appPaths.CachePath, "comicvine");
}
/// <inheritdoc/>
public bool HasCache(string apiId)
{
var path = GetCacheFilePath(apiId);
var fileInfo = _fileSystem.GetFileSystemInfo(path);
// If it's recent don't re-download
return fileInfo.Exists && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= CacheTime;
}
/// <inheritdoc/>
public async Task AddToCache<T>(string apiId, T resource, CancellationToken cancellationToken)
{
var filePath = GetCacheFilePath(apiId);
using FileStream fileStream = AsyncFile.OpenWrite(filePath);
await JsonSerializer.SerializeAsync<T>(fileStream, resource, _jsonOptions, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<T?> GetFromCache<T>(string apiId, CancellationToken cancellationToken)
{
var filePath = GetCacheFilePath(apiId);
using FileStream fileStream = AsyncFile.OpenRead(filePath);
var resource = await JsonSerializer.DeserializeAsync<T>(fileStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
return resource;
}
}
}

View File

@ -1,384 +1,397 @@
/*namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
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.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Bookshelf.Common;
using Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
public class ComicVineMetadataProvider : IRemoteMetadataProvider<Book, BookInfo>
/// <summary>
/// Comic Vine metadata provider.
/// </summary>
public class ComicVineMetadataProvider : BaseComicVineProvider, IRemoteMetadataProvider<Book, BookInfo>
{
private const string ApiKey = "cc632e23e4b370807f4de6f0e3ba0116c734c10b";
private const string VolumeSearchUrl =
@"http:api.comicvine.com/search/?api_key={0}&format=json&resources=issue&query={1}";
private const string IssueSearchUrl =
@"http:api.comicvine.com/issues/?api_key={0}&format=json&filter=issue_number:{1},volume:{2}";
private static readonly Regex[] NameMatches =
{
new Regex(@"(?<name>.*)\((?<year>\d{4})\)"), matches "My Comic (2001)" and gives us the name and the year
new Regex(@"(?<name>.*)") last resort matches the whole string as the name
};
private readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly ILogger<ComicVineMetadataProvider> _logger;
private readonly IHttpClient _httpClient;
private readonly IJsonSerializer _jsonSerializer;
private readonly IFileSystem _fileSystem;
private readonly IApplicationPaths _appPaths;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IComicVineApiKeyProvider _apiKeyProvider;
public static ComicVineMetadataProvider Current;
public ComicVineMetadataProvider(ILogger<ComicVineMetadataProvider> logger, IHttpClient httpClient, IJsonSerializer jsonSerializer, IFileSystem fileSystem, IApplicationPaths appPaths)
/// <summary>
/// Initializes a new instance of the <see cref="ComicVineMetadataProvider"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{ComicVineMetadataProvider}"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="comicVineMetadataCacheManager">Instance of the <see cref="IComicVineMetadataCacheManager"/> interface.</param>
/// <param name="apiKeyProvider">Instance of the <see cref="IComicVineApiKeyProvider"/> interface.</param>
public ComicVineMetadataProvider(ILogger<ComicVineMetadataProvider> logger, IHttpClientFactory httpClientFactory, IComicVineMetadataCacheManager comicVineMetadataCacheManager, IComicVineApiKeyProvider apiKeyProvider)
: base(logger, comicVineMetadataCacheManager, httpClientFactory, apiKeyProvider)
{
_logger = logger;
_httpClient = httpClient;
_jsonSerializer = jsonSerializer;
_fileSystem = fileSystem;
_appPaths = appPaths;
Current = this;
_httpClientFactory = httpClientFactory;
_apiKeyProvider = apiKeyProvider;
}
/// <inheritdoc/>
public string Name => ComicVineConstants.ProviderName;
/// <inheritdoc/>
public async Task<MetadataResult<Book>> GetMetadata(BookInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Book>();
var volumeId = info.GetProviderId(ComicVineVolumeExternalId.KeyName) ??
await FetchComicVolumeId(info, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(volumeId))
{
return result;
}
var issueNumber = GetIssueNumberFromName(info.Name).ToString(_usCulture);
await EnsureCacheFile(volumeId, issueNumber, cancellationToken).ConfigureAwait(false);
var cachePath = GetCacheFilePath(volumeId, issueNumber);
try
{
var issueInfo = _jsonSerializer.DeserializeFromFile<SearchResult>(cachePath);
result.Item = new Book();
result.Item.SetProviderId(ComicVineVolumeExternalId.KeyName, volumeId);
result.HasMetadata = true;
ProcessIssueData(result.Item, issueInfo, cancellationToken);
}
catch (FileNotFoundException)
{
}
catch (DirectoryNotFoundException)
{
}
return result;
}
/ <summary>
/
/ </summary>
/ <param name="item"></param>
/ <param name="issue"></param>
/ <param name="cancellationToken"></param>
private void ProcessIssueData(Book item, SearchResult issue, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (issue.results == null || issue.results.Count == 0)
return;
var metadataResult = new MetadataResult<Book>()
{
QueriedById = true
};
var name = issue.results[0].issue_number;
var issueProviderId = info.GetProviderId(ComicVineConstants.ProviderId);
if (!string.IsNullOrEmpty(issue.results[0].name))
name += " - " + issue.results[0].name;
if (string.IsNullOrWhiteSpace(issueProviderId))
{
issueProviderId = await FetchIssueId(info, cancellationToken).ConfigureAwait(false);
metadataResult.QueriedById = false;
}
item.Name = name;
if (string.IsNullOrWhiteSpace(issueProviderId))
{
return metadataResult;
}
string sortIssueName = issue.results[0].issue_number;
var issueDetails = await GetOrAddItemDetailsFromCache<IssueDetails>(issueProviderId, cancellationToken).ConfigureAwait(false);
if (sortIssueName.Length == 1)
sortIssueName = "00" + sortIssueName;
else if (sortIssueName.Length == 2)
sortIssueName = "0" + sortIssueName;
if (issueDetails != null)
{
metadataResult.Item = new Book();
metadataResult.Item.SetProviderId(ComicVineConstants.ProviderId, issueProviderId);
metadataResult.HasMetadata = true;
sortIssueName += " - " + issue.results[0].volume.name;
VolumeDetails? volumeDetails = null;
if (!string.IsNullOrEmpty(issue.results[0].name))
sortIssueName += ", " + issue.results[0].name;
if (!string.IsNullOrWhiteSpace(issueDetails.Volume?.SiteDetailUrl))
{
volumeDetails = await GetOrAddItemDetailsFromCache<VolumeDetails>(GetProviderIdFromSiteDetailUrl(issueDetails.Volume.SiteDetailUrl), cancellationToken).ConfigureAwait(false);
}
ProcessIssueData(metadataResult.Item, issueDetails, volumeDetails, cancellationToken);
ProcessIssueMetadata(metadataResult, issueDetails, cancellationToken);
}
return metadataResult;
}
/// <summary>
/// Process the issue data.
/// </summary>
/// <param name="item">The Book item.</param>
/// <param name="issue">The issue details.</param>
/// <param name="volume">The volume details.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private void ProcessIssueData(Book item, IssueDetails issue, VolumeDetails? volume, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
item.Name = !string.IsNullOrWhiteSpace(issue.Name) ? issue.Name : $"#{issue.IssueNumber.PadLeft(3, '0')}";
string sortIssueName = issue.IssueNumber.PadLeft(3, '0');
if (!string.IsNullOrWhiteSpace(issue.Volume?.Name))
{
sortIssueName += " - " + issue.Volume?.Name;
}
if (!string.IsNullOrWhiteSpace(issue.Name))
{
sortIssueName += ", " + issue.Name;
}
item.ForcedSortName = sortIssueName;
item.SeriesName = issue.results[0].volume.name;
item.SeriesName = issue.Volume?.Name;
item.Overview = WebUtility.HtmlDecode(issue.Description);
item.ProductionYear = GetYearFromCoverDate(issue.CoverDate);
item.Overview = WebUtility.HtmlDecode(issue.results[0].description);
if (!string.IsNullOrWhiteSpace(volume?.Publisher?.Name))
{
item.AddStudio(volume.Publisher.Name);
}
}
/ <summary>
/
/ </summary>
/ <param name="item"></param>
/ <param name="cancellationToken"></param>
/ <returns></returns>
private async Task<string> FetchComicVolumeId(BookInfo item, CancellationToken cancellationToken)
/// <summary>
/// Process the issue metadata.
/// </summary>
/// <param name="item">The metadata result.</param>
/// <param name="issue">The issue details.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private void ProcessIssueMetadata(MetadataResult<Book> item, IssueDetails issue, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
/*
* Comics should be stored so that they represent the volume number and the parent represents the comic series.
#1#
var name = item.SeriesName;
var year = string.Empty;
foreach (var re in NameMatches)
foreach (var person in issue.PersonCredits)
{
Match m = re.Match(name);
if (m.Success)
var personInfo = new PersonInfo
{
name = m.Groups["name"].Value.Trim();
year = m.Groups["year"] != null ? m.Groups["year"].Value : null;
break;
}
}
if (string.IsNullOrEmpty(year) && item.Year != null)
{
year = item.Year.ToString();
}
var url = string.Format(VolumeSearchUrl, ApiKey, UrlEncode(name));
var stream = await _httpClient.Get(url, Plugin.Instance.ComicVineSemiphore, cancellationToken);
if (stream == null)
{
_logger.Info("response is null");
return null;
}
var searchResult = _jsonSerializer.DeserializeFromStream<SearchResult>(stream);
var comparableName = GetComparableName(name);
foreach (var result in searchResult.results)
{
if (result.volume.name != null &&
GetComparableName(result.volume.name).Equals(comparableName, StringComparison.OrdinalIgnoreCase))
{
_logger.Info("volume name: " + GetComparableName(result.volume.name) + ", matches: " + comparableName);
if (!string.IsNullOrEmpty(year))
{
var resultYear = result.cover_date.Substring(0, 4);
if (year == resultYear)
return result.volume.id.ToString(CultureInfo.InvariantCulture);
}
else
return result.volume.id.ToString(CultureInfo.InvariantCulture);
}
else
{
if (result.volume.name != null)
_logger.Info(comparableName + " does not match " + GetComparableName(result.volume.name));
}
}
return null;
}
/ <summary>
/
/ </summary>
/ <param name="volumeId"></param>
/ <param name="issueNumber"></param>
/ <param name="cancellationToken"></param>
/ <returns></returns>
private async Task<SearchResult> GetComicIssue(string volumeId, float issueNumber, CancellationToken cancellationToken)
{
var url = string.Format(IssueSearchUrl, ApiKey, issueNumber, volumeId);
var stream = await _httpClient.Get(url, Plugin.Instance.ComicVineSemiphore, cancellationToken);
if (stream == null)
{
_logger.Info("response is null");
return null;
}
return _jsonSerializer.DeserializeFromStream<SearchResult>(stream);
}
/ <summary>
/
/ </summary>
/ <param name="name"></param>
/ <returns></returns>
public static float GetIssueNumberFromName(string name)
{
var result = Regex.Match(name, @"\d+\.\d").Value;
if (string.IsNullOrEmpty(result))
result = Regex.Match(name, @"#\d+").Value;
if (string.IsNullOrEmpty(result))
result = Regex.Match(name, @"\d+").Value;
if (!string.IsNullOrEmpty(result))
{
result = result.Replace("#", "");
Remove any leading zeros so that 005 becomes 5
result = result.TrimStart(new[] { '0' });
var issueNumber = float.Parse(result);
return issueNumber;
}
return 0;
}
private const string Remove = "\"'!`?";
"Face/Off" support.
private const string Spacers = "/,.:;\\(){}[]+-_=*"; (there are not actually two - they are different char codes)
/ <summary>
/
/ </summary>
/ <param name="name"></param>
/ <returns></returns>
internal static string GetComparableName(string name)
{
name = name.ToLower();
name = name.Normalize(NormalizationForm.FormC);
foreach (var pair in ReplaceEndNumerals)
{
if (name.EndsWith(pair.Key))
{
name = name.Remove(name.IndexOf(pair.Key, StringComparison.InvariantCulture), pair.Key.Length);
name = name + pair.Value;
}
}
var sb = new StringBuilder();
foreach (var c in name)
{
if (c >= 0x2B0 && c <= 0x0333)
{
skip char modifier and diacritics
}
else if (Remove.IndexOf(c) > -1)
{
skip chars we are removing
}
else if (Spacers.IndexOf(c) > -1)
{
sb.Append(" ");
}
else if (c == '&')
{
sb.Append(" and ");
}
else
{
sb.Append(c);
}
}
name = sb.ToString();
name = name.Replace("the", " ");
name = name.Replace(" - ", ": ");
string prevName;
do
{
prevName = name;
name = name.Replace(" ", " ");
} while (name.Length != prevName.Length);
return name.Trim();
}
/ <summary>
/
/ </summary>
static readonly Dictionary<string, string> ReplaceEndNumerals = new Dictionary<string, string> {
{" i", " 1"},
{" ii", " 2"},
{" iii", " 3"},
{" iv", " 4"},
{" v", " 5"},
{" vi", " 6"},
{" vii", " 7"},
{" viii", " 8"},
{" ix", " 9"},
{" x", " 10"}
Name = person.Name,
Type = person.Roles.Any() ? GetPersonKindFromRole(person.Roles.First()) : "Unknown"
};
private static string UrlEncode(string name)
personInfo.SetProviderId(ComicVineConstants.ProviderId, GetProviderIdFromSiteDetailUrl(person.SiteDetailUrl));
item.AddPerson(personInfo);
}
}
private string GetPersonKindFromRole(PersonCreditRole role)
{
return WebUtility.UrlEncode(name);
}
public string Name
return role switch
{
get { return "Comic Vine"; }
PersonCreditRole.Artist => "Artist",
PersonCreditRole.Colorist => "Colorist",
PersonCreditRole.Cover => "CoverArtist",
PersonCreditRole.Editor => "Editor",
PersonCreditRole.Inker => "Inker",
PersonCreditRole.Letterer => "Letterer",
PersonCreditRole.Penciler => "Penciller",
PersonCreditRole.Translator => "Translator",
PersonCreditRole.Writer => "Writer",
PersonCreditRole.Assistant
or PersonCreditRole.Designer
or PersonCreditRole.Journalist
or PersonCreditRole.Production
or PersonCreditRole.Other => "Unknown",
_ => throw new ArgumentException($"Unknown role: {role}"),
};
}
private readonly Task _cachedResult = Task.FromResult(true);
internal Task EnsureCacheFile(string volumeId, string issueNumber, CancellationToken cancellationToken)
/// <summary>
/// Try to find the issue id from the item info.
/// </summary>
/// <param name="item">The BookInfo item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The issue id if found.</returns>
private async Task<string?> FetchIssueId(BookInfo item, CancellationToken cancellationToken)
{
var path = GetCacheFilePath(volumeId, issueNumber);
cancellationToken.ThrowIfCancellationRequested();
var fileInfo = _fileSystem.GetFileSystemInfo(path);
var parsedItem = BookFileNameParser.GetBookMetadata(item);
if (fileInfo.Exists)
var searchResults = await GetSearchResultsInternal(parsedItem, cancellationToken)
.ConfigureAwait(false);
if (!searchResults.Any())
{
If it's recent don't re-download
if ((DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 7)
return null;
}
var comparableName = BookFileNameParser.GetComparableString(parsedItem.Name, false);
var comparableSeriesName = BookFileNameParser.GetComparableString(parsedItem.SeriesName, false);
foreach (var result in searchResults)
{
return _cachedResult;
}
}
return DownloadIssueInfo(volumeId, issueNumber, cancellationToken);
}
internal async Task DownloadIssueInfo(string volumeId, string issueNumber, CancellationToken cancellationToken)
if (!int.TryParse(result.IssueNumber, out var issueNumber))
{
var url = string.Format(IssueSearchUrl, ApiKey, issueNumber, volumeId);
continue;
}
var xmlPath = GetCacheFilePath(volumeId, issueNumber);
// Match series name and issue number, and optionally the name
using (var stream = await _httpClient.Get(url, Plugin.Instance.ComicVineSemiphore, cancellationToken).ConfigureAwait(false))
var comparableVolumeName = BookFileNameParser.GetComparableString(result.Volume?.Name ?? string.Empty, false);
if (issueNumber != parsedItem.IndexNumber || !comparableSeriesName.Equals(comparableVolumeName, StringComparison.Ordinal))
{
Directory.CreateDirectory(Path.GetDirectoryName(xmlPath));
continue;
}
using (var fileStream = _fileSystem.GetFileStream(xmlPath, FileMode.Create, FileAccess.Write, FileShare.Read, true))
if (!string.IsNullOrWhiteSpace(comparableName) && !string.IsNullOrWhiteSpace(result.Name))
{
await stream.CopyToAsync(fileStream).ConfigureAwait(false);
}
}
}
internal string GetCacheFilePath(string volumeId, string issueNumber)
var comparableIssueName = BookFileNameParser.GetComparableString(result.Name, false);
if (!comparableName.Equals(comparableIssueName, StringComparison.Ordinal))
{
var gameDataPath = GetComicVineDataPath();
return Path.Combine(gameDataPath, volumeId, "issue-" + issueNumber.ToString(_usCulture) + ".json");
continue;
}
}
private string GetComicVineDataPath()
if (parsedItem.Year.HasValue)
{
var dataPath = Path.Combine(_appPaths.CachePath, "comicvine");
var resultYear = GetYearFromCoverDate(result.CoverDate);
return dataPath;
}
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
if (Math.Abs(resultYear - parsedItem.Year ?? 0) > 1)
{
throw new NotImplementedException();
continue;
}
}
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BookInfo searchInfo, CancellationToken cancellationToken)
return GetProviderIdFromSiteDetailUrl(result.SiteDetailUrl);
}
return null;
}
/// <inheritdoc />
public async Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
throw new NotImplementedException();
var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
return await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BookInfo searchInfo, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
Func<IssueSearch, RemoteSearchResult> getSearchResultFromIssue = (IssueSearch issue) =>
{
var remoteSearchResult = new RemoteSearchResult();
remoteSearchResult.SetProviderId(ComicVineConstants.ProviderId, GetProviderIdFromSiteDetailUrl(issue.SiteDetailUrl));
remoteSearchResult.SearchProviderName = ComicVineConstants.ProviderName;
remoteSearchResult.Name = string.IsNullOrWhiteSpace(issue.Name) ? $"#{issue.IssueNumber.PadLeft(3, '0')}" : issue.Name;
remoteSearchResult.Overview = string.IsNullOrWhiteSpace(issue.Description) ? string.Empty : WebUtility.HtmlDecode(issue.Description);
remoteSearchResult.ProductionYear = GetYearFromCoverDate(issue.CoverDate);
if (!string.IsNullOrWhiteSpace(issue.Image?.SmallUrl))
{
remoteSearchResult.ImageUrl = issue.Image.SmallUrl;
}
return remoteSearchResult;
};
var issueProviderId = searchInfo.GetProviderId(ComicVineConstants.ProviderId);
if (!string.IsNullOrWhiteSpace(issueProviderId))
{
var issueDetails = await GetOrAddItemDetailsFromCache<IssueDetails>(issueProviderId, cancellationToken).ConfigureAwait(false);
if (issueDetails == null)
{
return Enumerable.Empty<RemoteSearchResult>();
}
return new[] { getSearchResultFromIssue(issueDetails) };
}
else
{
var searchResults = await GetSearchResultsInternal(searchInfo, cancellationToken).ConfigureAwait(false);
return searchResults.Select(getSearchResultFromIssue);
}
}
/// <summary>
/// Get the year from the cover date.
/// </summary>
/// <param name="coverDate">The date, in the format "yyyy-MM-dd".</param>
/// <returns>The year.</returns>
private int? GetYearFromCoverDate(string coverDate)
{
if (DateTimeOffset.TryParse(coverDate, out var result))
{
return result.Year;
}
return null;
}
private async Task<IEnumerable<IssueSearch>> GetSearchResultsInternal(BookInfo item, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var apiKey = _apiKeyProvider.GetApiKey();
if (apiKey == null)
{
return Enumerable.Empty<IssueSearch>();
}
var searchString = GetSearchString(item);
var url = string.Format(CultureInfo.InvariantCulture, ComicVineApiUrls.IssueSearchUrl, apiKey, WebUtility.UrlEncode(searchString));
var response = await _httpClientFactory
.CreateClient(NamedClient.Default)
.GetAsync(url, cancellationToken)
.ConfigureAwait(false);
var apiResponse = await response.Content.ReadFromJsonAsync<SearchApiResponse<IssueSearch>>(JsonOptions, cancellationToken).ConfigureAwait(false);
if (apiResponse == null)
{
_logger.LogError("Failed to deserialize Comic Vine API response.");
return Enumerable.Empty<IssueSearch>();
}
var results = GetFromApiResponse<IssueSearch>(apiResponse);
return results;
}
/// <summary>
/// Get the search string for the item.
/// Will try to use the format "{SeriesName} {IndexNumber} {Name}".
/// </summary>
/// <param name="item">The BookInfo item.</param>
/// <returns>The search string.</returns>
internal string GetSearchString(BookInfo item)
{
string result = string.Empty;
if (!string.IsNullOrWhiteSpace(item.SeriesName))
{
result += $" {item.SeriesName}";
}
if (item.IndexNumber.HasValue)
{
result += $" {item.IndexNumber.Value}";
}
if (!string.IsNullOrWhiteSpace(item.Name))
{
result += $" {item.Name}";
}
return result.Trim();
}
/// <summary>
/// Gets the issue id from the site detail URL.
/// <para>
/// Issues have a unique Id, but also a different one used for the API.
/// The URL to the issue detail page also includes a slug before the id.
/// </para>
/// <listheader>For example:</listheader>
/// <list type="bullet">
/// <item>
/// <term>id</term>
/// <description>441467</description>
/// </item>
/// <item>
/// <term>api_detail_url</term>
/// <description>https://comicvine.gamespot.com/api/issue/4000-441467</description>
/// </item>
/// <item>
/// <term>site_detail_url</term>
/// <description>https://comicvine.gamespot.com/attack-on-titan-10-fortress-of-blood/4000-441467</description>
/// </item>
/// </list>
/// <para>
/// We need to keep the last two parts of the site detail URL (the slug and the id) as the provider id for the IExternalId implementation to work.
/// </para>
/// </summary>
/// <param name="siteDetailUrl">The site detail URL.</param>
/// <returns>The slug and id.</returns>
private static string GetProviderIdFromSiteDetailUrl(string siteDetailUrl)
{
return siteDetailUrl.Replace(ComicVineApiUrls.BaseWebsiteUrl, string.Empty, StringComparison.OrdinalIgnoreCase).Trim('/');
}
}
}
}*/

View File

@ -1,32 +0,0 @@
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Comic vine volume external id.
/// </summary>
public class ComicVineVolumeExternalId : IExternalId
{
/// <inheritdoc />
public string Key => "ComicVineVolume";
/// <inheritdoc />
public string ProviderName => "Comic Vine Volume";
/// <inheritdoc />
public ExternalIdMediaType? Type
=> null; // TODO: enum does not yet have the Book type
/// <inheritdoc />
public string UrlFormatString => string.Empty;
/// <inheritdoc />
public bool Supports(IHasProviderIds item)
{
return item is Book;
}
}
}

View File

@ -0,0 +1,14 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// API key provider for Comic Vine.
/// </summary>
public interface IComicVineApiKeyProvider
{
/// <summary>
/// Get the Comic Vine API key from the configuration.
/// </summary>
/// <returns>The API key or null.</returns>
public string? GetApiKey();
}
}

View File

@ -0,0 +1,37 @@
using System.Threading;
using System.Threading.Tasks;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Comic Vine metadata cache manager.
/// </summary>
public interface IComicVineMetadataCacheManager
{
/// <summary>
/// Check if the resource is already cached.
/// </summary>
/// <param name="apiId">The API id of the resource.</param>
/// <returns>Whether the resource is cached.</returns>
public bool HasCache(string apiId);
/// <summary>
/// Add an API resource to the cache.
/// </summary>
/// <param name="apiId">The API id of the resource.</param>
/// <param name="resource">The resource to add to the cache.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public Task AddToCache<T>(string apiId, T resource, CancellationToken cancellationToken);
/// <summary>
/// Get an API resource from the cache.
/// </summary>
/// <param name="apiId">The API id of the resource.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <typeparam name="T">The type of the resource.</typeparam>
/// <returns>The cached resource.</returns>
public Task<T?> GetFromCache<T>(string apiId, CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,43 @@
using System.Text;
using System.Text.Json;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
// TODO: This will no longer be needed when targeting .NET 8
internal sealed class JsonSnakeCaseNamingPolicy : JsonNamingPolicy
{
public JsonSnakeCaseNamingPolicy()
{
}
/// <summary>
/// Convert a name in camel case to snake case.
/// </summary>
/// <param name="name">The name in camel case.</param>
/// <returns>The name in snake case.</returns>
public override string ConvertName(string name)
{
var builder = new StringBuilder();
for (var i = 0; i < name.Length; i++)
{
var currentChar = name[i];
if (char.IsUpper(currentChar))
{
if (i > 0)
{
builder.Append('_');
}
builder.Append(char.ToLowerInvariant(currentChar));
}
else
{
builder.Append(currentChar);
}
}
return builder.ToString();
}
}
}

View File

@ -0,0 +1,74 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Comic Vine API response.
/// </summary>
/// <typeparam name="T">Type of object returned by the response.</typeparam>
public class BaseApiResponse<T>
{
/// <summary>
/// Gets an integer indicating the result of the request. Acceptable values are:
/// <list type="bullet">
/// <item>
/// <term>1</term>
/// <description>OK</description>
/// </item>
/// <item>
/// <term>100</term>
/// <description>Invalid API Key</description>
/// </item>
/// <item>
/// <term>101</term>
/// <description>Object Not Found</description>
/// </item>
/// <item>
/// <term>102</term>
/// <description>Error in URL Format</description>
/// </item>
/// <item>
/// <term>103</term>
/// <description>'jsonp' format requires a 'json_callback' argument</description>
/// </item>
/// <item>
/// <term>104</term>
/// <description>Filter Error</description>
/// </item>
/// <item>
/// <term>105</term>
/// <description>Subscriber only video is for subscribers only</description>
/// </item>
/// </list>
/// </summary>
public int StatusCode { get; init; }
/// <summary>
/// Gets a text string representing the StatusCode.
/// </summary>
public string Error { get; init; } = string.Empty;
/// <summary>
/// Gets the number of total results matching the filter conditions specified.
/// </summary>
public int NumberOfTotalResults { get; init; }
/// <summary>
/// Gets the number of results on this page.
/// </summary>
public int NumberOfPageResults { get; init; }
/// <summary>
/// Gets the value of the limit filter specified, or 10 if not specified.
/// </summary>
public int Limit { get; init; }
/// <summary>
/// Gets the value of the offset filter specified, or 0 if not specified.
/// </summary>
public int Offset { get; init; }
/// <summary>
/// Gets a value indicating whether the response is an error.
/// </summary>
public bool IsError => StatusCode != 1;
}
}

View File

@ -0,0 +1,53 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// List of images for an issue.
/// </summary>
public class ImageList
{
/// <summary>
/// Gets the icon image URL.
/// </summary>
public string IconUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the medium image URL.
/// </summary>
public string MediumUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the screen image URL.
/// </summary>
public string ScreenUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the large screen image URL.
/// </summary>
public string ScreenLargeUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the small image URL.
/// </summary>
public string SmallUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the super image URL.
/// </summary>
public string SuperUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the thumb image URL.
/// </summary>
public string ThumbUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the tiny image URL.
/// </summary>
public string TinyUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the original image URL.
/// </summary>
public string OriginalUrl { get; init; } = string.Empty;
}
}

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Details of an issue.
/// </summary>
public class IssueDetails : IssueSearch
{
/// <summary>
/// Gets the list of people who worked on this issue.
/// </summary>
public IReadOnlyList<PersonCredit> PersonCredits { get; init; } = Array.Empty<PersonCredit>();
}
}

View File

@ -0,0 +1,83 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Result of a search on the Issue resource.
/// </summary>
public class IssueSearch
{
/// <summary>
/// Gets the list of aliases the issue is known by. A \n (newline) seperates each alias.
/// </summary>
public string Aliases { get; init; } = string.Empty;
/// <summary>
/// Gets the URL pointing to the issue detail resource.
/// </summary>
public string ApiDetailUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the publish date printed on the cover of an issue.
/// </summary>
public string CoverDate { get; init; } = string.Empty;
/// <summary>
/// Gets the date the issue was added to Comic Vine.
/// </summary>
public string DateAdded { get; init; } = string.Empty;
/// <summary>
/// Gets the date the issue was last updated on Comic Vine.
/// </summary>
public string DateLastUpdated { get; init; } = string.Empty;
/// <summary>
/// Gets a brief summary of the issue.
/// </summary>
public string? Deck { get; init; }
/// <summary>
/// Gets the description of the issue.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Gets a value indicating whether the issue has a staff review.
/// </summary>
public bool HasStaffReview { get; init; }
/// <summary>
/// Gets the unique ID of the issue.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets the main image of the issue.
/// </summary>
public ImageList? Image { get; init; }
/// <summary>
/// Gets the number assigned to the issue within the volume set.
/// </summary>
public string IssueNumber { get; init; } = string.Empty;
/// <summary>
/// Gets the name of the issue.
/// </summary>
public string? Name { get; init; }
/// <summary>
/// Gets the URL pointing to the issue on the Comic Vine website.
/// </summary>
public string SiteDetailUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the date the issue was first sold in stores.
/// </summary>
public string StoreDate { get; init; } = string.Empty;
/// <summary>
/// Gets the volume the issue is a part of.
/// </summary>
public VolumeOverview? Volume { get; init; }
}
}

View File

@ -0,0 +1,14 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models
{
/// <summary>
/// Comic Vine API response for a specific resource item.
/// </summary>
/// <typeparam name="T">Type of object returned by the response.</typeparam>
public sealed class ItemApiResponse<T> : BaseApiResponse<T>
{
/// <summary>
/// Gets the item returned by the response.
/// </summary>
public T? Results { get; init; }
}
}

View File

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Credit for a person that worked on an issue.
/// </summary>
public class PersonCredit
{
/// <summary>
/// Gets the URL pointing to the person detail resource.
/// </summary>
public string ApiDetailUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the unique ID of the person.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets the name of the person.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Gets the URL pointing to the person on the Comic Vine website.
/// </summary>
public string SiteDetailUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the roles for this person (ex: "artist", "writer", etc), separated by commas.
/// </summary>
public string Role { get; init; } = string.Empty;
/// <summary>
/// Gets the list of roles for this person.
/// </summary>
public IEnumerable<PersonCreditRole> Roles => Role.Split(", ").Select(r => Enum.Parse<PersonCreditRole>(r, true));
}
}

View File

@ -0,0 +1,78 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models
{
/// <summary>
/// Role for a person credit.
/// </summary>
public enum PersonCreditRole
{
/// <summary>
/// Artist.
/// </summary>
Artist,
/// <summary>
/// Assistant.
/// </summary>
Assistant,
/// <summary>
/// Colorist.
/// </summary>
Colorist,
/// <summary>
/// Cover artist.
/// </summary>
Cover,
/// <summary>
/// Character designer.
/// </summary>
Designer,
/// <summary>
/// Editor.
/// </summary>
Editor,
/// <summary>
/// Inker.
/// </summary>
Inker,
/// <summary>
/// Journalist.
/// </summary>
Journalist,
/// <summary>
/// Letterer.
/// </summary>
Letterer,
/// <summary>
/// Penciller.
/// </summary>
Penciler,
/// <summary>
/// Production.
/// </summary>
Production,
/// <summary>
/// Translator.
/// </summary>
Translator,
/// <summary>
/// Writer.
/// </summary>
Writer,
/// <summary>
/// Other.
/// </summary>
Other
}
}

View File

@ -0,0 +1,23 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models
{
/// <summary>
/// Overview of a publisher.
/// </summary>
public class PublisherOverview
{
/// <summary>
/// Gets the URL pointing to the publisher detail resource.
/// </summary>
public string ApiDetailUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the unique ID of the publisher.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets the name of the publisher.
/// </summary>
public string Name { get; init; } = string.Empty;
}
}

View File

@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models
{
/// <summary>
/// Comic Vine API response for a search on a resource.
/// </summary>
/// <typeparam name="T">Type of object returned by the response.</typeparam>
public sealed class SearchApiResponse<T> : BaseApiResponse<T>
{
/// <summary>
/// Gets zero or more items that match the filters specified.
/// </summary>
public IEnumerable<T> Results { get; init; } = Enumerable.Empty<T>();
}
}

View File

@ -0,0 +1,24 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine.Models
{
/// <summary>
/// Details of a volume.
/// </summary>
/// <remarks>The API's volume resource contains more fields but we don't need them for now.</remarks>
public class VolumeDetails : VolumeOverview
{
/// <summary>
/// Gets the number of issues included in this volume.
/// </summary>
public int CountOfIssues { get; init; }
/// <summary>
/// Gets the description of the volume.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Gets the primary publisher a volume is attached to.
/// </summary>
public PublisherOverview? Publisher { get; init; }
}
}

View File

@ -0,0 +1,28 @@
namespace Jellyfin.Plugin.Bookshelf.Providers.ComicVine
{
/// <summary>
/// Overview of a volume.
/// </summary>
public class VolumeOverview
{
/// <summary>
/// Gets the URL pointing to the volume detail resource.
/// </summary>
public string ApiDetailUrl { get; init; } = string.Empty;
/// <summary>
/// Gets the unique ID of the volume.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets the name of the volume.
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Gets the URL pointing to the volume on the Comic Vine website.
/// </summary>
public string SiteDetailUrl { get; init; } = string.Empty;
}
}

View File

@ -4,10 +4,9 @@ using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Bookshelf.Common;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
@ -22,40 +21,6 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
/// </summary>
public class GoogleBooksProvider : BaseGoogleBooksProvider, IRemoteMetadataProvider<Book, BookInfo>
{
// convert these characters to whitespace for better matching
// there are two dashes with different char codes
private const string Spacers = "/,.:;\\(){}[]+-_=*";
private const string Remove = "\"'!`?";
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})\)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
};
private readonly Dictionary<string, string> _replaceEndNumerals = new ()
{
{ " i", " 1" },
{ " ii", " 2" },
{ " iii", " 3" },
{ " iv", " 4" },
{ " v", " 5" },
{ " vi", " 6" },
{ " vii", " 7" },
{ " viii", " 8" },
{ " ix", " 9" },
{ " x", " 10" }
};
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<GoogleBooksProvider> _logger;
@ -202,16 +167,16 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
// pattern match the filename
// year can be included for better results
GetBookMetadata(item);
var parsedItem = BookFileNameParser.GetBookMetadata(item);
var searchResults = await GetSearchResultsInternal(item, cancellationToken)
var searchResults = await GetSearchResultsInternal(parsedItem, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Items == null)
{
return null;
}
var comparableName = GetComparableName(item.Name, item.SeriesName, item.IndexNumber);
var comparableName = GetComparableName(parsedItem.Name, parsedItem.SeriesName, parsedItem.IndexNumber);
foreach (var i in searchResults.Items)
{
if (i.VolumeInfo is null)
@ -233,7 +198,7 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
// allow a one year variance
if (Math.Abs(resultYear - item.Year ?? 0) > 1)
if (Math.Abs(resultYear - parsedItem.Year ?? 0) > 1)
{
continue;
}
@ -337,121 +302,6 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
}
}
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);
name = name.Normalize(NormalizationForm.FormC);
foreach (var pair in _replaceEndNumerals)
{
if (name.EndsWith(pair.Key, StringComparison.OrdinalIgnoreCase))
{
name = name.Remove(name.IndexOf(pair.Key, StringComparison.InvariantCulture), pair.Key.Length);
name += pair.Value;
}
}
var sb = new StringBuilder();
foreach (var c in name)
{
if (c >= 0x2B0 && c <= 0x0333)
{
// skip char modifier and diacritics
}
else if (Remove.IndexOf(c, StringComparison.Ordinal) > -1)
{
// skip chars we are removing
}
else if (Spacers.IndexOf(c, StringComparison.Ordinal) > -1)
{
sb.Append(' ');
}
else if (c == '&')
{
sb.Append(" and ");
}
else
{
sb.Append(c);
}
}
name = sb.ToString();
name = name.Replace("the", " ", StringComparison.OrdinalIgnoreCase);
name = name.Replace(" - ", ": ", StringComparison.Ordinal);
var regex = new Regex(@"\s+");
name = regex.Replace(name, " ");
return name.Trim();
}
/// <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)
{
var match = regex.Match(item.Name);
if (!match.Success)
{
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);
if (result)
{
item.IndexNumber = index;
}
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);
if (result)
{
item.Year = year;
}
}
}
/// <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.
@ -488,5 +338,30 @@ namespace Jellyfin.Plugin.Bookshelf.Providers.GoogleBooks
return result;
}
/// <summary>
/// Format information about a book to a comparable name string.
/// </summary>
/// <param name="name">Name of the book.</param>
/// <param name="seriesName">Name of the book series.</param>
/// <param name="index">Index of the book in the series.</param>
/// <returns>The book name as a string.</returns>
private static string GetComparableName(string? name, string? seriesName = null, int? index = null)
{
if (string.IsNullOrWhiteSpace(name))
{
if (!string.IsNullOrWhiteSpace(seriesName) && index != null)
{
// We have series name and index, so use that
name = $"{BookFileNameParser.GetComparableString(seriesName, false)} {index}";
}
else
{
return string.Empty;
}
}
return BookFileNameParser.GetComparableString(name, true);
}
}
}

View File

@ -58,6 +58,9 @@ The following **limitations** apply:
These Metadata providers will check online services for metadata.
- Google Books
- Comic Vine
To use the Comic Vine metadata provider, you will need to set an API key on the plugin's configuration page.
## Build & Installation Process

View File

@ -0,0 +1,213 @@
using Jellyfin.Plugin.Bookshelf.Common;
using MediaBrowser.Model.Entities;
namespace Jellyfin.Plugin.Bookshelf.Tests
{
public class BookFileNameParserTests
{
#region Parse
[Fact]
public void Parse_WithNameAndDefaultSeriesName_CorrectlyResetSeriesName()
{
var expected = new BookFileNameParserResult
{
Name = "Children of Time"
};
var result = BookFileNameParser.Parse("Children of Time", CollectionType.Books);
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithNameAndYear_CorrectlyMatchesFileName()
{
var expected = new BookFileNameParserResult
{
Name = "Children of Time",
Year = 2015
};
var result = BookFileNameParser.Parse("Children of Time (2015)", CollectionType.Books);
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithIndexAndName_CorrectlyMatchesFileName()
{
var expected = new BookFileNameParserResult
{
Name = "Children of Time",
Index = 1
};
var result = BookFileNameParser.Parse("1 - Children of Time", CollectionType.Books);
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithIndexAndNameInFolder_CorrectlyMatchesFileName()
{
var expected = new BookFileNameParserResult
{
Name = "Children of Ruin",
SeriesName = "Children of Time",
Index = 2
};
var result = BookFileNameParser.Parse("2 - Children of Ruin", "Children of Time");
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithIndexNameAndYear_CorrectlyMatchesFileName()
{
var expected = new BookFileNameParserResult
{
Name = "Children of Time",
Year = 2015,
Index = 1
};
var result = BookFileNameParser.Parse("1 - Children of Time (2015)", CollectionType.Books);
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithComicFormat_CorrectlyMatchesFileName()
{
BookFileNameParserResult expected;
BookFileNameParserResult result;
// Complete format
expected = new BookFileNameParserResult
{
SeriesName = "Children of Time",
Year = 2019,
Index = 2
};
result = BookFileNameParser.Parse("Children of Time (2015) #2 (of 3) (2019)", CollectionType.Books);
Assert.Equal(expected, result);
// Without series year
expected = new BookFileNameParserResult
{
SeriesName = "Children of Time",
Year = 2019,
Index = 2
};
result = BookFileNameParser.Parse("Children of Time #2 (of 3) (2019)", CollectionType.Books);
Assert.Equal(expected, result);
// Without total count
expected = new BookFileNameParserResult
{
SeriesName = "Children of Time",
Year = 2019,
Index = 2
};
result = BookFileNameParser.Parse("Children of Time #2 (2019)", CollectionType.Books);
Assert.Equal(expected, result);
// With only issue number
expected = new BookFileNameParserResult
{
SeriesName = "Children of Time",
Index = 2
};
result = BookFileNameParser.Parse("Children of Time #2", CollectionType.Books);
Assert.Equal(expected, result);
// With only issue number and leading zeroes
expected = new BookFileNameParserResult
{
SeriesName = "Children of Time",
Index = 2
};
result = BookFileNameParser.Parse("Children of Time #002", CollectionType.Books);
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithGoodreadsFormat_CorrectlyMatchesFileName()
{
BookFileNameParserResult expected;
BookFileNameParserResult result;
// Goodreads format
expected = new BookFileNameParserResult
{
Name = "Children of Ruin",
SeriesName = "Children of Time",
Index = 2
};
result = BookFileNameParser.Parse("Children of Ruin (Children of Time, #2)", CollectionType.Books);
Assert.Equal(expected, result);
// Goodreads format with year added
expected = new BookFileNameParserResult
{
Name = "Children of Ruin",
SeriesName = "Children of Time",
Index = 2,
Year = 2019
};
result = BookFileNameParser.Parse("Children of Ruin (Children of Time, #2) (2019)", CollectionType.Books);
Assert.Equal(expected, result);
}
[Fact]
public void Parse_WithSeriesAndName_OverridesSeriesName()
{
var expected = new BookFileNameParserResult
{
Name = "Children of Ruin",
SeriesName = "Children of Time",
Index = 2,
};
var result = BookFileNameParser.Parse("Children of Ruin (Children of Time, #2)", "Adrian Tchaikovsky");
Assert.Equal(expected, result);
}
#endregion
#region GetComparableString
[Fact]
public void GetComparableString_WithSpacers_Success()
{
var value = "L'île mystérieuse !";
var expected = "lîle mystérieuse";
var result = BookFileNameParser.GetComparableString(value, false);
Assert.Equal(expected, result);
}
[Fact]
public void GetComparableString_WithNumerals_Success()
{
var value = "Dark Knight III: The Master Race III";
var expected = "dark knight iii master race 3";
var result = BookFileNameParser.GetComparableString(value, true);
Assert.Equal(expected, result);
}
#endregion
}
}

View File

@ -0,0 +1,40 @@
using System.Net;
using Jellyfin.Plugin.Bookshelf.Providers.ComicVine;
using Jellyfin.Plugin.Bookshelf.Tests.Http;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Jellyfin.Plugin.Bookshelf.Tests
{
public class ComicVineImageProviderTests
{
[Fact]
public async Task GetImages_WithAllLinks_ReturnsLargest()
{
var mockApiKeyProvider = Substitute.For<IComicVineApiKeyProvider>();
mockApiKeyProvider.GetApiKey().Returns(Guid.NewGuid().ToString());
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("issue/4000-441467"), new MockHttpResponse(HttpStatusCode.OK, TestHelpers.GetFixture("comic-vine-single-issue.json")))
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteImageProvider provider = new ComicVineImageProvider(Substitute.For<IComicVineMetadataCacheManager>(), NullLogger<ComicVineImageProvider>.Instance, mockedHttpClientFactory, mockApiKeyProvider);
var images = await provider.GetImages(new Book()
{
ProviderIds = { { ComicVineConstants.ProviderId, "attack-on-titan-10-fortress-of-blood/4000-441467" } }
}, CancellationToken.None);
Assert.Collection(
images,
large => Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_large/6/67663/3556541-10.jpg", large.Url));
}
}
}

View File

@ -0,0 +1,553 @@
using System.Net;
using Jellyfin.Plugin.Bookshelf.Providers.ComicVine;
using Jellyfin.Plugin.Bookshelf.Tests.Http;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Jellyfin.Plugin.Bookshelf.Tests
{
public class ComicVineProviderTests
{
private readonly IComicVineApiKeyProvider _mockApiKeyProvider;
public ComicVineProviderTests()
{
_mockApiKeyProvider = Substitute.For<IComicVineApiKeyProvider>();
_mockApiKeyProvider.GetApiKey().Returns(Guid.NewGuid().ToString());
}
private string GetSearchResultWithNamedIssues() => TestHelpers.GetFixture("comic-vine-issue-search-named-issues.json");
private string GetSearchResultWithNumberedIssues() => TestHelpers.GetFixture("comic-vine-issue-search-numbered-issues.json");
private string GetSingleIssueResult() => TestHelpers.GetFixture("comic-vine-single-issue.json");
private string GetSingleUnnamedIssueResult() => TestHelpers.GetFixture("comic-vine-single-numbered-issue.json");
private string GetSingleVolumeResult() => TestHelpers.GetFixture("comic-vine-single-volume.json");
private bool HasComicVineId(string id, Dictionary<string, string> providerIds)
{
return providerIds.Count == 1
&& providerIds.TryGetValue(ComicVineConstants.ProviderId, out string? providerId)
&& providerId == id;
}
#region GetSearchResults
[Fact]
public async Task GetSearchResults_ByName_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/search"), new MockHttpResponse(HttpStatusCode.OK, GetSearchResultWithNamedIssues())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var results = await provider.GetSearchResults(new BookInfo() { Name = "Fortress of blood" }, CancellationToken.None);
Assert.True(results.All(result => result.SearchProviderName == ComicVineConstants.ProviderName));
Assert.Collection(results,
first =>
{
Assert.Equal("Fortress Of Blood", first.Name);
Assert.True(HasComicVineId("attack-on-titan-10-fortress-of-blood/4000-441467", first.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/6/67663/3556541-10.jpg", first.ImageUrl);
Assert.Equal("<p><em>FORTRESS OF BLOOD</em></p><p>" +
"<em>With no combat gear and Wall Rose breached, the 104th scrambles to evacuate the villages in the Titans' path. On their way to the safety of Wall Sheena, they decide to spend the night in Utgard Castle." +
" But their sanctuary becomes a slaughterhouse when they discover that, for some reason, these Titans attack at night!</em></p>" +
"<h2>Chapter Titles</h2><ul><li>Episode 39: Soldier</li><li>Episode 40: Ymir</li><li>Episode 41: Historia</li><li>Episode 42: Warrior</li></ul>", first.Overview);
Assert.Equal(2014, first.ProductionYear);
},
second =>
{
Assert.Equal("Titan on the Hunt", second.Name);
Assert.True(HasComicVineId("attack-on-titan-6-titan-on-the-hunt/4000-424591", second.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/6/67663/3506331-06.jpg", second.ImageUrl);
Assert.Equal("<p><em>TITAN ON THE HUNT</em></p><p><em>On the way to Erens home, deep in Titan territory, the Survey Corps ranks are broken by a charge led by a female Titan!" +
" But this Abnormal is different she kills not to eat but to protect herself, and she seems to be looking for someone." +
" Armin comes to a shocking conclusion: Shes a human in a Titans body, just like Eren!</em></p>" +
"<h2>Chapter Titles</h2><ul><li>Episode 23: The Female Titan</li><li>Episode 24: The Titan Forest</li><li>Episode 25: Bite</li><li>Episode 26: The Easy Path</li></ul>", second.Overview);
Assert.Equal(2013, second.ProductionYear);
},
third =>
{
Assert.Equal("Band 10", third.Name);
Assert.True(HasComicVineId("attack-on-titan-10-band-10/4000-546356", third.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/6/67663/5400404-10.jpg", third.ImageUrl);
Assert.Equal("<p><em>Die Erde gehört riesigen Menschenfressern: den TITANEN!</em></p><p><em>" +
"Die letzten Menschen leben zusammengepfercht in einer Festung mit fünfzig Meter hohen Mauern.</em></p><p><em>" +
"Als ein kolossaler Titan die äußere Mauer einreißt, bricht ein letzter Kampf aus um das Überleben der Menschheit!</em></p>" +
"<h2>Kapitel</h2><ul><li>39: Soldaten</li><li>40: Ymir</li><li>41: Historia</li><li>42: Krieger</li></ul>", third.Overview);
Assert.Equal(2015, third.ProductionYear);
});
}
[Fact]
public async Task GetSearchResults_ByProviderId_WithoutSlug_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/issue/4000-441467"), new MockHttpResponse(HttpStatusCode.OK, GetSingleIssueResult())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var results = await provider.GetSearchResults(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ ComicVineConstants.ProviderId, "4000-441467" }
}
}, CancellationToken.None);
Assert.True(results.All(result => result.SearchProviderName == ComicVineConstants.ProviderName));
Assert.Collection(results,
first =>
{
Assert.Equal("Fortress Of Blood", first.Name);
Assert.True(HasComicVineId("attack-on-titan-10-fortress-of-blood/4000-441467", first.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/6/67663/3556541-10.jpg", first.ImageUrl);
Assert.Equal("<p><em>FORTRESS OF BLOOD</em></p><p>" +
"<em>With no combat gear and Wall Rose breached, the 104th scrambles to evacuate the villages in the Titans' path. On their way to the safety of Wall Sheena, they decide to spend the night in Utgard Castle." +
" But their sanctuary becomes a slaughterhouse when they discover that, for some reason, these Titans attack at night!</em></p>" +
"<h2>Chapter Titles</h2><ul><li>Episode 39: Soldier</li><li>Episode 40: Ymir</li><li>Episode 41: Historia</li><li>Episode 42: Warrior</li></ul>", first.Overview);
Assert.Equal(2014, first.ProductionYear);
});
}
[Fact]
public async Task GetSearchResults_ByProviderId_WithSlug_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/issue/4000-441467"), new MockHttpResponse(HttpStatusCode.OK, GetSingleIssueResult())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var results = await provider.GetSearchResults(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ ComicVineConstants.ProviderId, "attack-on-titan-10-fortress-of-blood/4000-441467" }
}
}, CancellationToken.None);
Assert.True(results.All(result => result.SearchProviderName == ComicVineConstants.ProviderName));
Assert.Collection(results,
first =>
{
Assert.Equal("Fortress Of Blood", first.Name);
Assert.True(HasComicVineId("attack-on-titan-10-fortress-of-blood/4000-441467", first.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/6/67663/3556541-10.jpg", first.ImageUrl);
Assert.Equal("<p><em>FORTRESS OF BLOOD</em></p><p>" +
"<em>With no combat gear and Wall Rose breached, the 104th scrambles to evacuate the villages in the Titans' path. On their way to the safety of Wall Sheena, they decide to spend the night in Utgard Castle." +
" But their sanctuary becomes a slaughterhouse when they discover that, for some reason, these Titans attack at night!</em></p>" +
"<h2>Chapter Titles</h2><ul><li>Episode 39: Soldier</li><li>Episode 40: Ymir</li><li>Episode 41: Historia</li><li>Episode 42: Warrior</li></ul>", first.Overview);
Assert.Equal(2014, first.ProductionYear);
});
}
[Fact]
public async Task GetSearchResults_WithUnamedIssues_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/search"), new MockHttpResponse(HttpStatusCode.OK, GetSearchResultWithNumberedIssues())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var results = await provider.GetSearchResults(new BookInfo() { Name = "Invincible #20" }, CancellationToken.None);
Assert.True(results.All(result => result.SearchProviderName == ComicVineConstants.ProviderName));
Assert.Collection(results,
first =>
{
Assert.Equal("#020", first.Name);
Assert.True(HasComicVineId("invincible-20/4000-989412", first.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/11/110017/8943106-wwww.jpg", first.ImageUrl);
Assert.Empty(first.Overview);
Assert.Equal(2015, first.ProductionYear);
},
second =>
{
Assert.Equal("#020", second.Name);
Assert.True(HasComicVineId("invincible-20/4000-128610", second.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/6/67663/2185628-20.jpg", second.ImageUrl);
Assert.Equal("<p><em>Mark Grayson is just like everyone else his age, except that his father is the most powerful superhero on the planet." +
" And now he's begun to inherit his father's powers. It all sounds okay at first, but how do you follow in your father's footsteps when you know you will never live up to his standards?" +
" For nine years now (or however long it's been since issue #6 came out) readers have been wondering, \"What's up with that robot zombie from issue #6?\"" +
" Well, wonder no longer, because he's in this issue! Mark is on campus at his new college and something is amiss. What lurks behind...oh, wait: You already know!</em></p>" +
"<p>Atom Eve decides to retire from the superhero business and use her powers to actually make a difference in the world." +
" Amber gets mad at Mark when he mysteriously disappears to fight a Reaniman that is attacking the campus, she mistakenly thinks he ran off like a coward." +
" If only she knew Mark is actually the brave superhero, Invincible. D. A. Sinclair formulates that his next Reaniman should be constructed from a...live subject!</p>", second.Overview);
Assert.Equal(2005, second.ProductionYear);
},
third =>
{
Assert.Equal("Amici", third.Name);
Assert.True(HasComicVineId("invincible-20-amici/4000-989389", third.ProviderIds));
Assert.Equal("https://comicvine.gamespot.com/a/uploads/scale_small/11/110017/8943006-invincible-20-0001.jpg", third.ImageUrl);
Assert.Empty(third.Overview);
Assert.Equal(2016, third.ProductionYear);
});
}
[Fact]
public async Task GetSearchResults_WithErrorResponse_ReturnsNoResults()
{
var errorResponse = @"
{
""error"": ""Invalid API Key"",
""limit"": 0,
""offset"": 0,
""number_of_page_results"": 0,
""number_of_total_results"": 0,
""status_code"": 100,
""results"": []
}";
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/search"), new MockHttpResponse(HttpStatusCode.Unauthorized, errorResponse)),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var results = await provider.GetSearchResults(new BookInfo() { Name = "Fortress of Blood" }, CancellationToken.None);
Assert.Empty(results);
}
#endregion
#region GetMetadata
private void AssertMetadata(MetadataResult<Book> metadataResult, bool queriedById)
{
Assert.Equal(queriedById, metadataResult.QueriedById);
Assert.True(metadataResult.HasMetadata);
Assert.Collection(metadataResult.People,
p =>
{
Assert.Equal("Ben Applegate", p.Name);
Assert.Equal("Editor", p.Type);
Assert.True(HasComicVineId("ben-applegate/4040-74578", p.ProviderIds));
},
p =>
{
Assert.Equal("Hajime Isayama", p.Name);
Assert.Equal("Writer", p.Type);
Assert.True(HasComicVineId("hajime-isayama/4040-64651", p.ProviderIds));
},
p =>
{
Assert.Equal("Ko Ransom", p.Name);
Assert.Equal("Unknown", p.Type);
Assert.True(HasComicVineId("ko-ransom/4040-74576", p.ProviderIds));
},
p =>
{
Assert.Equal("Steve Wands", p.Name);
Assert.Equal("Letterer", p.Type);
Assert.True(HasComicVineId("steve-wands/4040-47630", p.ProviderIds));
},
p =>
{
Assert.Equal("Takashi Shimoyama", p.Name);
Assert.Equal("CoverArtist", p.Type);
Assert.True(HasComicVineId("takashi-shimoyama/4040-74571", p.ProviderIds));
});
Assert.True(HasComicVineId("attack-on-titan-10-fortress-of-blood/4000-441467", metadataResult.Item.ProviderIds));
Assert.Equal("Fortress Of Blood", metadataResult.Item.Name);
Assert.Equal("010 - Attack on Titan, Fortress Of Blood", metadataResult.Item.ForcedSortName);
Assert.Collection(metadataResult.Item.Studios,
s =>
{
Assert.Equal("Kodansha Comics USA", s);
});
Assert.Equal(2014, metadataResult.Item.ProductionYear);
Assert.Equal("<p><em>FORTRESS OF BLOOD</em></p>" +
"<p><em>With no combat gear and Wall Rose breached, the 104th scrambles to evacuate the villages in the Titans' path." +
" On their way to the safety of Wall Sheena, they decide to spend the night in Utgard Castle." +
" But their sanctuary becomes a slaughterhouse when they discover that, for some reason, these Titans attack at night!</em></p>" +
"<h2>Chapter Titles</h2><ul><li>Episode 39: Soldier</li><li>Episode 40: Ymir</li><li>Episode 41: Historia</li><li>Episode 42: Warrior</li></ul>", metadataResult.Item.Overview);
Assert.Empty(metadataResult.Item.Genres);
Assert.Empty(metadataResult.Item.Tags);
Assert.Null(metadataResult.Item.CommunityRating);
}
[Fact]
public async Task GetMetadata_MatchesByName_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/search"), new MockHttpResponse(HttpStatusCode.OK, GetSearchResultWithNamedIssues())),
((Uri uri) => uri.AbsoluteUri.Contains("/issue/4000-441467"), new MockHttpResponse(HttpStatusCode.OK, GetSingleIssueResult())),
((Uri uri) => uri.AbsoluteUri.Contains("/volume/4050-49866"), new MockHttpResponse(HttpStatusCode.OK, GetSingleVolumeResult())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var metadataResult = await provider.GetMetadata(new BookInfo()
{
SeriesName = "Attack on Titan",
Name = "10 - Fortress of Blood"
}, CancellationToken.None);
AssertMetadata(metadataResult, false);
}
[Fact]
public async Task GetMetadata_MatchesByProviderId_Success()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/issue/4000-441467"), new MockHttpResponse(HttpStatusCode.OK, GetSingleIssueResult())),
((Uri uri) => uri.AbsoluteUri.Contains("/volume/4050-49866"), new MockHttpResponse(HttpStatusCode.OK, GetSingleVolumeResult())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var metadataResult = await provider.GetMetadata(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ ComicVineConstants.ProviderId, "attack-on-titan-10-fortress-of-blood/4000-441467" }
}
}, CancellationToken.None);
AssertMetadata(metadataResult, true);
}
[Fact]
public async Task GetMetadata_WithNoCache_AddsToCache()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/issue/4000-441467"), new MockHttpResponse(HttpStatusCode.OK, GetSingleIssueResult())),
((Uri uri) => uri.AbsoluteUri.Contains("/volume/4050-49866"), new MockHttpResponse(HttpStatusCode.OK, GetSingleVolumeResult())),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
var cache = Substitute.For<IComicVineMetadataCacheManager>();
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
cache,
_mockApiKeyProvider);
await provider.GetMetadata(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ ComicVineConstants.ProviderId, "attack-on-titan-10-fortress-of-blood/4000-441467" }
}
}, CancellationToken.None);
await cache.Received().AddToCache<IssueDetails>("4000-441467", Arg.Any<IssueDetails>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetMetadata_WithValidCache_GetsFromCache()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>());
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
var cache = Substitute.For<IComicVineMetadataCacheManager>();
cache.HasCache("4000-441467").Returns(true);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
cache,
_mockApiKeyProvider);
await provider.GetMetadata(new BookInfo()
{
ProviderIds = new Dictionary<string, string>()
{
{ ComicVineConstants.ProviderId, "attack-on-titan-10-fortress-of-blood/4000-441467" }
}
}, CancellationToken.None);
await cache.DidNotReceive().AddToCache<IssueDetails>("4000-441467", Arg.Any<IssueDetails>(), Arg.Any<CancellationToken>());
await cache.Received().GetFromCache<IssueDetails>("4000-441467",Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetMetadata_MatchesByIssueNumber_PicksCorrectResult()
{
var mockedMessageHandler = new MockHttpMessageHandler(new List<(Func<Uri, bool> requestMatcher, MockHttpResponse response)>
{
((Uri uri) => uri.AbsoluteUri.Contains("/search"), new MockHttpResponse(HttpStatusCode.OK, GetSearchResultWithNumberedIssues())),
((Uri uri) => uri.AbsoluteUri.Contains("/issue/4000-128610"), new MockHttpResponse(HttpStatusCode.OK, GetSingleUnnamedIssueResult())),
((Uri uri) => uri.AbsoluteUri.Contains("/volume/"), new MockHttpResponse(HttpStatusCode.NotFound, string.Empty)),
});
var mockedHttpClientFactory = Substitute.For<IHttpClientFactory>();
using var client = new HttpClient(mockedMessageHandler);
mockedHttpClientFactory.CreateClient(Arg.Any<string>()).Returns(client);
IRemoteMetadataProvider<Book, BookInfo> provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
mockedHttpClientFactory,
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
// Only one search result matches the provided year
var metadataResult = await provider.GetMetadata(new BookInfo() { Name = "Invincible #20 (2005)" }, CancellationToken.None);
Assert.False(metadataResult.QueriedById);
Assert.True(metadataResult.HasMetadata);
Assert.True(HasComicVineId("invincible-20/4000-128610", metadataResult.Item.ProviderIds));
Assert.Equal("#020", metadataResult.Item.Name);
Assert.Equal(2005, metadataResult.Item.ProductionYear);
Assert.Equal("<p><em>Mark Grayson is just like everyone else his age, except that his father is the most powerful superhero on the planet." +
" And now he's begun to inherit his father's powers. It all sounds okay at first, but how do you follow in your father's footsteps when you know you will never live up to his standards?" +
" For nine years now (or however long it's been since issue #6 came out) readers have been wondering, \"What's up with that robot zombie from issue #6?\"" +
" Well, wonder no longer, because he's in this issue! Mark is on campus at his new college and something is amiss." +
" What lurks behind...oh, wait: You already know!</em></p>" +
"<p>Atom Eve decides to retire from the superhero business and use her powers to actually make a difference in the world." +
" Amber gets mad at Mark when he mysteriously disappears to fight a Reaniman that is attacking the campus, she mistakenly thinks he ran off like a coward." +
" If only she knew Mark is actually the brave superhero, Invincible. D. A. Sinclair formulates that his next Reaniman should be constructed from a...live subject!</p>", metadataResult.Item.Overview);
}
#endregion
#region GetSearchString
[Fact]
public void GetSearchString_WithSeriesAndName_ReturnsCorrectString()
{
ComicVineMetadataProvider provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
Substitute.For<IHttpClientFactory>(),
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var bookInfo = new BookInfo()
{
SeriesName = "Invincible",
Name = "Eight is Enough",
};
var searchString = provider.GetSearchString(bookInfo);
Assert.Equal("Invincible Eight is Enough", searchString);
}
[Fact]
public void GetSearchString_WithSeriesAndIndex_ReturnsCorrectString()
{
ComicVineMetadataProvider provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
Substitute.For<IHttpClientFactory>(),
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var bookInfo = new BookInfo()
{
SeriesName = "Invincible",
IndexNumber = 2
};
var searchString = provider.GetSearchString(bookInfo);
Assert.Equal("Invincible 2", searchString);
}
[Fact]
public void GetSearchString_WithAllValues_ReturnsCorrectString()
{
ComicVineMetadataProvider provider = new ComicVineMetadataProvider(
NullLogger<ComicVineMetadataProvider>.Instance,
Substitute.For<IHttpClientFactory>(),
Substitute.For<IComicVineMetadataCacheManager>(),
_mockApiKeyProvider);
var bookInfo = new BookInfo()
{
SeriesName = "Invincible",
Name = "Eight is Enough",
IndexNumber = 2,
Year = 2004
};
var searchString = provider.GetSearchString(bookInfo);
// Year should be ignored
Assert.Equal("Invincible 2 Eight is Enough", searchString);
}
#endregion
}
}

View File

@ -0,0 +1,130 @@
{
"error": "OK",
"limit": 10,
"offset": 0,
"number_of_page_results": 10,
"number_of_total_results": 42697,
"status_code": 1,
"results": [
{
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-441467\/",
"cover_date": "2014-01-31",
"date_added": "2014-01-07 01:14:07",
"date_last_updated": "2016-04-04 02:41:39",
"deck": null,
"description": "<p><em>FORTRESS OF BLOOD<\/em><\/p><p><em>With no combat gear and Wall Rose breached, the 104th scrambles to evacuate the villages in the Titans' path. On their way to the safety of Wall Sheena, they decide to spend the night in Utgard Castle. But their sanctuary becomes a slaughterhouse when they discover that, for some reason, these Titans attack at night!<\/em><\/p><h2>Chapter Titles<\/h2><ul><li>Episode 39: Soldier<\/li><li>Episode 40: Ymir<\/li><li>Episode 41: Historia<\/li><li>Episode 42: Warrior<\/li><\/ul>",
"has_staff_review": false,
"id": 441467,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/3556541-10.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/3556541-10.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/3556541-10.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/3556541-10.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/3556541-10.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/3556541-10.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/3556541-10.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/3556541-10.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3556541-10.jpg",
"image_tags": "All Images"
},
"associated_images": [
{
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3558136-10-full.jpg",
"id": 3558136,
"caption": null,
"image_tags": "All Images"
}
],
"issue_number": "10",
"name": "Fortress Of Blood",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan-10-fortress-of-blood\/4000-441467\/",
"store_date": "2014-01-08",
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-49866\/",
"id": 49866,
"name": "Attack on Titan",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan\/4050-49866\/"
},
"resource_type": "issue"
},
{
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-424591\/",
"cover_date": "2013-09-30",
"date_added": "2013-09-03 00:05:11",
"date_last_updated": "2016-04-04 02:41:17",
"deck": null,
"description": "<p><em>TITAN ON THE HUNT<\/em><\/p><p><em>On the way to Erens home, deep in Titan territory, the Survey Corps ranks are broken by a charge led by a female Titan! But this Abnormal is different she kills not to eat but to protect herself, and she seems to be looking for someone. Armin comes to a shocking conclusion: Shes a human in a Titans body, just like Eren!<\/em><\/p><h2>Chapter Titles<\/h2><ul><li>Episode 23: The Female Titan<\/li><li>Episode 24: The Titan Forest<\/li><li>Episode 25: Bite<\/li><li>Episode 26: The Easy Path<\/li><\/ul>",
"has_staff_review": false,
"id": 424591,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/3506331-06.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/3506331-06.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/3506331-06.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/3506331-06.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/3506331-06.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/3506331-06.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/3506331-06.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/3506331-06.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3506331-06.jpg",
"image_tags": "All Images"
},
"associated_images": [
{
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3286532-06-full.jpg",
"id": 3286532,
"caption": null,
"image_tags": "All Images"
}
],
"issue_number": "6",
"name": "Titan on the Hunt",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan-6-titan-on-the-hunt\/4000-424591\/",
"store_date": "2013-09-04",
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-49866\/",
"id": 49866,
"name": "Attack on Titan",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan\/4050-49866\/"
},
"resource_type": "issue"
},
{
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-546356\/",
"cover_date": "2015-09-30",
"date_added": "2016-08-25 08:27:10",
"date_last_updated": "2020-05-27 15:30:16",
"deck": null,
"description": "<p><em>Die Erde gehört riesigen Menschenfressern: den TITANEN!<\/em><\/p><p><em>Die letzten Menschen leben zusammengepfercht in einer Festung mit fünfzig Meter hohen Mauern.<\/em><\/p><p><em>Als ein kolossaler Titan die äußere Mauer einreißt, bricht ein letzter Kampf aus um das Überleben der Menschheit!<\/em><\/p><h2>Kapitel<\/h2><ul><li>39: Soldaten<\/li><li>40: Ymir<\/li><li>41: Historia<\/li><li>42: Krieger<\/li><\/ul>",
"has_staff_review": false,
"id": 546356,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/5400404-10.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/5400404-10.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/5400404-10.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/5400404-10.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/5400404-10.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/5400404-10.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/5400404-10.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/5400404-10.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/5400404-10.jpg",
"image_tags": "All Images"
},
"associated_images": [],
"issue_number": "10",
"name": "Band 10",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan-10-band-10\/4000-546356\/",
"store_date": "2015-09-29",
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-72459\/",
"id": 72459,
"name": "Attack on Titan",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan\/4050-72459\/"
},
"resource_type": "issue"
}
],
"version": "1.0"
}

View File

@ -0,0 +1,116 @@
{
"error": "OK",
"limit": 10,
"offset": 0,
"number_of_page_results": 10,
"number_of_total_results": 8785,
"status_code": 1,
"results": [
{
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-989412\/",
"cover_date": "2015-08-01",
"date_added": "2023-05-14 09:20:45",
"date_last_updated": "2023-05-14 10:35:56",
"deck": null,
"description": null,
"has_staff_review": false,
"id": 989412,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/11\/110017\/8943106-wwww.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/11\/110017\/8943106-wwww.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/11\/110017\/8943106-wwww.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/11\/110017\/8943106-wwww.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/11\/110017\/8943106-wwww.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/11\/110017\/8943106-wwww.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/11\/110017\/8943106-wwww.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/11\/110017\/8943106-wwww.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/11\/110017\/8943106-wwww.jpg",
"image_tags": "All Images"
},
"associated_images": [],
"issue_number": "20",
"name": null,
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible-20\/4000-989412\/",
"store_date": null,
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-150392\/",
"id": 150392,
"name": "Invincible",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible\/4050-150392\/"
},
"resource_type": "issue"
},
{
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-128610\/",
"cover_date": "2005-01-01",
"date_added": "2008-06-06 11:20:58",
"date_last_updated": "2015-06-18 10:35:43",
"deck": null,
"description": "<p><em>Mark Grayson is just like everyone else his age, except that his father is the most powerful superhero on the planet. And now he's begun to inherit his father's powers. It all sounds okay at first, but how do you follow in your father's footsteps when you know you will never live up to his standards? For nine years now (or however long it's been since issue #6 came out) readers have been wondering, \"What's up with that robot zombie from issue #6?\" Well, wonder no longer, because he's in this issue! Mark is on campus at his new college and something is amiss. What lurks behind...oh, wait: You already know!<\/em><\/p><p>Atom Eve decides to retire from the superhero business and use her powers to actually make a difference in the world. Amber gets mad at Mark when he mysteriously disappears to fight a Reaniman that is attacking the campus, she mistakenly thinks he ran off like a coward. If only she knew Mark is actually the brave superhero, Invincible. D. A. Sinclair formulates that his next Reaniman should be constructed from a...live subject!<\/p>",
"has_staff_review": false,
"id": 128610,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/2185628-20.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/2185628-20.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/2185628-20.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/2185628-20.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/2185628-20.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/2185628-20.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/2185628-20.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/2185628-20.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/2185628-20.jpg",
"image_tags": "All Images"
},
"associated_images": [],
"issue_number": "20",
"name": null,
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible-20\/4000-128610\/",
"store_date": null,
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-17993\/",
"id": 17993,
"name": "Invincible",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible\/4050-17993\/"
},
"resource_type": "issue"
},
{
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-989389\/",
"cover_date": "2016-04-01",
"date_added": "2023-05-14 08:33:23",
"date_last_updated": "2023-05-14 08:34:36",
"deck": null,
"description": null,
"has_staff_review": false,
"id": 989389,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/11\/110017\/8943006-invincible-20-0001.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/11\/110017\/8943006-invincible-20-0001.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/11\/110017\/8943006-invincible-20-0001.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/11\/110017\/8943006-invincible-20-0001.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/11\/110017\/8943006-invincible-20-0001.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/11\/110017\/8943006-invincible-20-0001.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/11\/110017\/8943006-invincible-20-0001.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/11\/110017\/8943006-invincible-20-0001.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/11\/110017\/8943006-invincible-20-0001.jpg",
"image_tags": "All Images"
},
"associated_images": [],
"issue_number": "20",
"name": "Amici",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible-20-amici\/4000-989389\/",
"store_date": null,
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-150390\/",
"id": 150390,
"name": "Invincible",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible\/4050-150390\/"
},
"resource_type": "issue"
}
],
"version": "1.0"
}

View File

@ -0,0 +1,231 @@
{
"error": "OK",
"limit": 1,
"offset": 0,
"number_of_page_results": 1,
"number_of_total_results": 1,
"status_code": 1,
"results": {
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-441467\/",
"associated_images": [
{
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3558136-10-full.jpg",
"id": 3558136,
"caption": null,
"image_tags": "All Images"
}
],
"character_credits": [
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94028\/",
"id": 94028,
"name": "Armin Arlert",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/armin-arlert\/4005-94028\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94955\/",
"id": 94955,
"name": "Bertolt Hoover",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/bertolt-hoover\/4005-94955\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94951\/",
"id": 94951,
"name": "Connie Springer",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/connie-springer\/4005-94951\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94030\/",
"id": 94030,
"name": "Eren Yeager",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/eren-yeager\/4005-94030\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96722\/",
"id": 96722,
"name": "Gelgar",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/gelgar\/4005-96722\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94942\/",
"id": 94942,
"name": "Hannes",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/hannes\/4005-94942\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96729\/",
"id": 96729,
"name": "Henning",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/henning\/4005-96729\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94953\/",
"id": 94953,
"name": "Jean Kirstein",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/jean-kirstein\/4005-94953\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96726\/",
"id": 96726,
"name": "Keiji",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/keiji\/4005-96726\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94949\/",
"id": 94949,
"name": "Krista Lenz",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/krista-lenz\/4005-94949\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96731\/",
"id": 96731,
"name": "Lynne",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/lynne\/4005-96731\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94029\/",
"id": 94029,
"name": "Mikasa Ackerman",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/mikasa-ackerman\/4005-94029\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96735\/",
"id": 96735,
"name": "Nanaba",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/nanaba\/4005-96735\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94956\/",
"id": 94956,
"name": "Reiner Braun",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/reiner-braun\/4005-94956\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94950\/",
"id": 94950,
"name": "Sasha Blouse",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/sasha-blouse\/4005-94950\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-94960\/",
"id": 94960,
"name": "Ymir",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/ymir\/4005-94960\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96715\/",
"id": 96715,
"name": "Zoe Hange",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/zoe-hange\/4005-96715\/"
}
],
"character_died_in": [
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96729\/",
"id": 96729,
"name": "Henning",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/henning\/4005-96729\/"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/character\/4005-96731\/",
"id": 96731,
"name": "Lynne",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/lynne\/4005-96731\/"
}
],
"concept_credits": [],
"cover_date": "2014-01-31",
"date_added": "2014-01-07 01:14:07",
"date_last_updated": "2016-04-04 02:41:39",
"deck": null,
"description": "<p><em>FORTRESS OF BLOOD<\/em><\/p><p><em>With no combat gear and Wall Rose breached, the 104th scrambles to evacuate the villages in the Titans' path. On their way to the safety of Wall Sheena, they decide to spend the night in Utgard Castle. But their sanctuary becomes a slaughterhouse when they discover that, for some reason, these Titans attack at night!<\/em><\/p><h2>Chapter Titles<\/h2><ul><li>Episode 39: Soldier<\/li><li>Episode 40: Ymir<\/li><li>Episode 41: Historia<\/li><li>Episode 42: Warrior<\/li><\/ul>",
"first_appearance_characters": null,
"first_appearance_concepts": null,
"first_appearance_locations": null,
"first_appearance_objects": null,
"first_appearance_storyarcs": null,
"first_appearance_teams": null,
"has_staff_review": false,
"id": 441467,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/3556541-10.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/3556541-10.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/3556541-10.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/3556541-10.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/3556541-10.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/3556541-10.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/3556541-10.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/3556541-10.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3556541-10.jpg",
"image_tags": "All Images"
},
"issue_number": "10",
"location_credits": [
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/location\/4020-59082\/",
"id": 59082,
"name": "Utgard",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/utgard\/4020-59082\/"
}
],
"name": "Fortress Of Blood",
"object_credits": [],
"person_credits": [
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/person\/4040-74578\/",
"id": 74578,
"name": "Ben Applegate",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/ben-applegate\/4040-74578\/",
"role": "editor"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/person\/4040-64651\/",
"id": 64651,
"name": "Hajime Isayama",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/hajime-isayama\/4040-64651\/",
"role": "writer, artist, cover"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/person\/4040-74576\/",
"id": 74576,
"name": "Ko Ransom",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/ko-ransom\/4040-74576\/",
"role": "other"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/person\/4040-47630\/",
"id": 47630,
"name": "Steve Wands",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/steve-wands\/4040-47630\/",
"role": "letterer"
},
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/person\/4040-74571\/",
"id": 74571,
"name": "Takashi Shimoyama",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/takashi-shimoyama\/4040-74571\/",
"role": "cover, other"
}
],
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan-10-fortress-of-blood\/4000-441467\/",
"store_date": "2014-01-08",
"story_arc_credits": [],
"team_credits": [
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/team\/4060-60528\/",
"id": 60528,
"name": "Survey Corps",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/survey-corps\/4060-60528\/"
}
],
"team_disbanded_in": [],
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-49866\/",
"id": 49866,
"name": "Attack on Titan",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan\/4050-49866\/"
}
},
"version": "1.0"
}

View File

@ -0,0 +1,58 @@
{
"error": "OK",
"limit": 1,
"offset": 0,
"number_of_page_results": 1,
"number_of_total_results": 1,
"status_code": 1,
"results": {
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-128610\/",
"associated_images": [],
"character_credits": [],
"character_died_in": [],
"concept_credits": [],
"cover_date": "2005-01-01",
"date_added": "2008-06-06 11:20:58",
"date_last_updated": "2015-06-18 10:35:43",
"deck": null,
"description": "<p><em>Mark Grayson is just like everyone else his age, except that his father is the most powerful superhero on the planet. And now he's begun to inherit his father's powers. It all sounds okay at first, but how do you follow in your father's footsteps when you know you will never live up to his standards? For nine years now (or however long it's been since issue #6 came out) readers have been wondering, \"What's up with that robot zombie from issue #6?\" Well, wonder no longer, because he's in this issue! Mark is on campus at his new college and something is amiss. What lurks behind...oh, wait: You already know!<\/em><\/p><p>Atom Eve decides to retire from the superhero business and use her powers to actually make a difference in the world. Amber gets mad at Mark when he mysteriously disappears to fight a Reaniman that is attacking the campus, she mistakenly thinks he ran off like a coward. If only she knew Mark is actually the brave superhero, Invincible. D. A. Sinclair formulates that his next Reaniman should be constructed from a...live subject!<\/p>",
"first_appearance_characters": null,
"first_appearance_concepts": null,
"first_appearance_locations": null,
"first_appearance_objects": null,
"first_appearance_storyarcs": null,
"first_appearance_teams": null,
"has_staff_review": false,
"id": 128610,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/2185628-20.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/2185628-20.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/2185628-20.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/2185628-20.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/2185628-20.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/2185628-20.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/2185628-20.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/2185628-20.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/2185628-20.jpg",
"image_tags": "All Images"
},
"issue_number": "20",
"location_credits": [],
"name": null,
"object_credits": [],
"person_credits": [],
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible-20\/4000-128610\/",
"store_date": null,
"story_arc_credits": [],
"team_credits": [],
"team_disbanded_in": [],
"volume": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-17993\/",
"id": 17993,
"name": "Invincible",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/invincible\/4050-17993\/"
}
},
"version": "1.0"
}

View File

@ -0,0 +1,65 @@
{
"error": "OK",
"limit": 1,
"offset": 0,
"number_of_page_results": 1,
"number_of_total_results": 1,
"status_code": 1,
"results": {
"aliases": null,
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/volume\/4050-49866\/",
"characters": [],
"concepts": [
{
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/concept\/4015-56579\/",
"id": 56579,
"name": "Translated Comic",
"site_detail_url": "https:\/\/comicvine.gamespot.com\/translated-comic\/4015-56579\/",
"count": "15"
}
],
"count_of_issues": 34,
"date_added": "2012-06-20 17:37:38",
"date_last_updated": "2023-01-24 06:54:12",
"deck": null,
"description": "<p><em>Roughly a century before the beginning of the series, a mysterious race of giant, man-eating humanoids, known as <a title=\"Titan\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Titan\" rel=\"nofollow\">\"Titans\"<\/a>, suddenly appeared and nearly exterminated humanity. In order to protect themselves from this threat, the few survivors built three concentric <a title=\"Walls\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Walls\" rel=\"nofollow\">Walls<\/a>, called Maria, Rose, and Sheena, and encased themselves in this limited territory, forgetting everything about the outside world and the history before the building of the Walls.<\/em><\/p><p><em>In the present, a boy named <a title=\"Eren Yeager\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Eren_Yeager\" rel=\"nofollow\">Eren Yeager<\/a> and his childhood friends <a title=\"Mikasa Ackerman\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Mikasa_Ackerman\" rel=\"nofollow\">Mikasa Ackerman<\/a> and <a title=\"Armin Arlert\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Armin_Arlert\" rel=\"nofollow\">Armin Arlert<\/a> dream about seeing the world outside the Walls some day; but their peace is abruptly interrupted when an unusual 60-meter tall <a title=\"Colossus Titan\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Colossus_Titan\" rel=\"nofollow\">Colossus Titan<\/a> and an <a title=\"Armored Titan\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Armored_Titan\" rel=\"nofollow\">Armored Titan<\/a> breach the outermost Wall, and Eren sees his mother dying in the resulting devastation. Eren vows revenge against the Titans and later enlists in the military branch <a title=\"Survey Corps\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Survey_Corps\" rel=\"nofollow\">Survey Corps<\/a>, accompanied by both Mikasa and <a title=\"Armin Arlert\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Armin_Arlert\" rel=\"nofollow\">Armin<\/a>.<\/em><\/p><p><em>As the story progresses, more mysteries surrounding the true origin and nature of the <a title=\"Titans\" href=\"https:\/\/attackontitan.fandom.com\/wiki\/Titans\" rel=\"nofollow\">Titans<\/a>, as well as the lost history of the world are addressed, and the reader gradually discovers that the Titans may not be the real enemies. Eren and the Survey Corps find themselves as the only remaining hope for their civilization, as they fight to uncover secrets from the past and stop the forces that want to bring about the destruction of mankind in the present and future.<\/em><\/p><p>English translation of the Japanese manga <a href=\"https:\/\/www.comicvine.com\/shingeki-no-kyojin\/4050-71291\/\" data-ref-id=\"4050-71291\">Shingeki no Kyojin<\/a>.<\/p><h4>Collected Editions<\/h4><ul><li><a href=\"\/attack-on-titan-colossal-edition-1-vol-1\/4000-454156\/\" data-ref-id=\"4000-454156\">Attack on Titan Colossal Edition Vol. 1<\/a> (Vol. 1-5)<\/li><li><a href=\"\/attack-on-titan-omnibus-1-vol-1-3\/4000-890058\/\" data-ref-id=\"4000-890058\">Attack on Titan Omnibus Vol. 1<\/a> (Vol. 1-3)<\/li><li><a href=\"\/attack-on-titan-colossal-edition-2-vol-2\/4000-503460\/\" data-ref-id=\"4000-503460\">Attack on Titan Colossal Edition Vol. 2<\/a> (Vol. 6-10)<\/li><li><a href=\"https:\/\/comicvine.gamespot.com\/attack-on-titan-colossal-edition-3-vol-3\/4000-551118\/\" data-ref-id=\"4000-551118\">Attack on Titan Colossal Edition Vol. 3<\/a> (Vol. 11-15)<\/li><li><a href=\"\/attack-on-titan-colossal-edition-4-vol-4\/4000-680401\/\" data-ref-id=\"4000-680401\">Attack on Titan Colossal Edition Vol. 4<\/a> (Vol. 16-20)<\/li><li><a href=\"\/attack-on-titan-colossal-edition-5-vol-5\/4000-808125\/\" data-ref-id=\"4000-808125\">Attack on Titan Colossal Edition Vol. 5<\/a> (Vol. 20-25)<\/li><li><a href=\"\/attack-on-titan-colossal-edition-6-vol-6\/4000-891982\/\" data-ref-id=\"4000-891982\">Attack on Titan Colossal Edition Vol. 6<\/a> (Vol. 26-30)<\/li><li>Attack on Titan Colossal Edition Vol. 7 (Vol. 31-34)<\/li><\/ul>",
"first_issue": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-342215\/",
"id": 342215,
"name": "The Desperate Battle Begins!",
"issue_number": "1"
},
"id": 49866,
"image": {
"icon_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_avatar\/6\/67663\/3506326-01.jpg",
"medium_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_medium\/6\/67663\/3506326-01.jpg",
"screen_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_medium\/6\/67663\/3506326-01.jpg",
"screen_large_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/screen_kubrick\/6\/67663\/3506326-01.jpg",
"small_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_small\/6\/67663\/3506326-01.jpg",
"super_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_large\/6\/67663\/3506326-01.jpg",
"thumb_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/scale_avatar\/6\/67663\/3506326-01.jpg",
"tiny_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/square_mini\/6\/67663\/3506326-01.jpg",
"original_url": "https:\/\/comicvine.gamespot.com\/a\/uploads\/original\/6\/67663\/3506326-01.jpg",
"image_tags": "All Images"
},
"issues": [],
"last_issue": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/issue\/4000-890048\/",
"id": 890048,
"name": "Freedom...",
"issue_number": "34"
},
"locations": [],
"name": "Attack on Titan",
"objects": [],
"people": [],
"publisher": {
"api_detail_url": "https:\/\/comicvine.gamespot.com\/api\/publisher\/4010-3317\/",
"id": 3317,
"name": "Kodansha Comics USA"
},
"site_detail_url": "https:\/\/comicvine.gamespot.com\/attack-on-titan\/4050-49866\/",
"start_year": "2012"
},
"version": "1.0"
}

View File

@ -3,7 +3,6 @@ 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;
@ -328,161 +327,6 @@ namespace Jellyfin.Plugin.Bookshelf.Tests
#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]

View File

@ -33,13 +33,7 @@
</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">
<None Update="Fixtures\*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>