ISBN Support (#1985)

* Fixed a bug where weblinks would always show

* Started to try and support ico -> png conversion by manually grabbing image data out, but it's hard as hell.

* Implemented ability to parse out ISBN codes for books and ISBN-13 codes for ComicInfo. I can't figure out ISBN-10.

* Fixed Favicon not working on anything but windows

* Implemented ISBN support into Kavita

* Don't round so much when transforming bytes
This commit is contained in:
Joe Milazzo 2023-05-11 20:13:58 -05:00 committed by GitHub
parent a293500f42
commit 6be9ee39f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 2083 additions and 15 deletions

View File

@ -79,6 +79,7 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.3.0" />
<PackageReference Include="NetVips.Native" Version="8.14.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />

View File

@ -173,6 +173,7 @@ public class ImageController : BaseApiController
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest();
if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null");
// Check if the domain exists
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url));

View File

@ -97,4 +97,9 @@ public class ChapterDto : IHasReadTimeEstimate, IEntityDate
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
public string WebLinks { get; set; }
/// <summary>
/// ISBN-13 (usually) of the Chapter
/// </summary>
/// <remarks>This is guaranteed to be Valid</remarks>
public string ISBN { get; set; }
}

View File

@ -121,6 +121,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<SeriesMetadata>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
builder.Entity<Chapter>()
.Property(b => b.ISBN)
.HasDefaultValue(string.Empty);
}

View File

@ -2,7 +2,9 @@
using System.Linq;
using API.Entities;
using API.Entities.Enums;
using API.Services;
using Kavita.Common.Extensions;
using Nager.ArticleNumber;
namespace API.Data.Metadata;
@ -35,6 +37,17 @@ public class ComicInfo
/// IETF BCP 47 Code to represent the language of the content
/// </summary>
public string LanguageISO { get; set; } = string.Empty;
// ReSharper disable once InconsistentNaming
/// <summary>
/// ISBN for the underlying document
/// </summary>
/// <remarks>ComicInfo.xml will actually output a GTIN (Global Trade Item Number) and it is the responsibility of the Parser to extract the ISBN. EPub will return ISBN.</remarks>
public string Isbn { get; set; } = string.Empty;
/// <summary>
/// This is only for deserialization and used within <see cref="ArchiveService"/>. Use <see cref="Isbn"/> for the actual value.
/// </summary>
public string GTIN { get; set; } = string.Empty;
/// <summary>
/// This is the link to where the data was scraped from
/// </summary>
@ -138,6 +151,22 @@ public class ComicInfo
info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters);
info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator);
info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist);
// We need to convert GTIN to ISBN
if (!string.IsNullOrEmpty(info.GTIN) && ArticleNumberHelper.IsValidGtin(info.GTIN))
{
// This is likely a valid ISBN
if (info.GTIN[0] == '0')
{
var potentialISBN = info.GTIN.Substring(1, info.GTIN.Length - 1);
if (ArticleNumberHelper.IsValidIsbn13(potentialISBN))
{
info.Isbn = potentialISBN;
}
}
}
}
/// <summary>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterISBN : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ISBN",
table: "Chapter",
type: "TEXT",
nullable: true,
defaultValue: string.Empty);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ISBN",
table: "Chapter");
}
}
}

View File

@ -413,6 +413,11 @@ namespace API.Data.Migrations
b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("ISBN")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");

View File

@ -104,6 +104,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
/// Comma-separated link of urls to external services that have some relation to the Chapter
/// </summary>
public string WebLinks { get; set; } = string.Empty;
public string ISBN { get; set; } = string.Empty;
/// <summary>
/// All people attached at a Chapter level. Usually Comics will have different people per issue.

View File

@ -11,6 +11,7 @@ using API.Data.Metadata;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services.Tasks.Scanner.Parser;
using Docnet.Core;
using Docnet.Core.Converters;
@ -21,6 +22,7 @@ using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Nager.ArticleNumber;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using VersOne.Epub;
@ -433,6 +435,14 @@ public class BookService : IBookService
};
ComicInfo.CleanComicInfo(info);
foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers.Where(id => id.Scheme.Equals("ISBN")))
{
var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty);
if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn)) continue;
info.Isbn = isbn;
break;
}
// Parse tags not exposed via Library
foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
{

View File

@ -1,18 +1,15 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.Extensions.Logging;
using NetVips;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using Image = NetVips.Image;
using Size = SixLabors.ImageSharp.Size;
namespace API.Services;
@ -194,17 +191,46 @@ public class ImageService : IImageService
var baseUrl = uri.Scheme + "://" + uri.Host;
try
{
var validIconRelations = new[]
{
"icon",
"apple-touch-icon",
};
var htmlContent = url.GetStringAsync().Result;
var htmlDocument = new HtmlDocument();
htmlDocument.LoadHtml(htmlContent);
var pngLinks = htmlDocument.DocumentNode.Descendants("link")
.Where(link => validIconRelations.Contains(link.GetAttributeValue("rel", string.Empty)))
.Select(link => link.GetAttributeValue("href", string.Empty))
.Where(href => href.EndsWith(".png") || href.EndsWith(".PNG"))
.ToList();
if (pngLinks == null)
{
throw new KavitaException($"Could not grab favicon from {baseUrl}");
}
var correctSizeLink = pngLinks.FirstOrDefault(pngLink => pngLink.Contains("32")) ?? pngLinks.FirstOrDefault();
if (string.IsNullOrEmpty(correctSizeLink))
{
throw new KavitaException($"Could not grab favicon from {baseUrl}");
}
var finalUrl = correctSizeLink;
if (!correctSizeLink.StartsWith(uri.Scheme))
{
finalUrl = Url.Combine(baseUrl, correctSizeLink);
}
_logger.LogTrace("Fetching favicon from {Url}", finalUrl);
// Download the favicon.ico file using Flurl
var faviconStream = await baseUrl
.AppendPathSegment("favicon.ico")
.AllowHttpStatus("2xx")
var faviconStream = await finalUrl
.AllowHttpStatus("2xx,304")
.GetStreamAsync();
// Create the destination file path
var filename = $"{domain}.png";
using var icon = new Icon(faviconStream);
using var bitmap = icon.ToBitmap();
bitmap.Save(Path.Combine(_directoryService.FaviconDirectory, filename), ImageFormat.Png);
using var image = Image.PngloadStream(faviconStream);
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
return filename;

View File

@ -36,7 +36,7 @@ public interface IProcessSeries
void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false);
void UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false);
void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false);
void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info);
void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo);
}
/// <summary>
@ -722,6 +722,11 @@ public class ProcessSeries : IProcessSeries
);
}
if (!string.IsNullOrEmpty(comicInfo.Isbn))
{
chapter.ISBN = comicInfo.Isbn;
}
if (comicInfo.Count > 0)
{
chapter.TotalCount = comicInfo.Count;

View File

@ -42,4 +42,5 @@ export interface Chapter {
*/
volumeTitle?: string;
webLinks: string;
isbn: string;
}

View File

@ -85,5 +85,14 @@
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.isbn.length > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="ISBN" [clickable]="false" fontClasses="fa-solid fa-barcode" title="ISBN">
{{chapter.isbn}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>

View File

@ -56,6 +56,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
}
get WebLinks() {
if (this.chapter.webLinks === '') return [];
return this.chapter.webLinks.split(',');
}

View File

@ -17,7 +17,7 @@ export class BytesPipe implements PipeTransform {
*
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
*/
transform(bytes: number, si=true, dp=0): string {
transform(bytes: number, si=true, dp=1): string {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
@ -35,8 +35,12 @@ export class BytesPipe implements PipeTransform {
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
const fixed = bytes.toFixed(dp);
if ((fixed + '').endsWith('.0')) {
return bytes.toFixed(0) + ' ' + units[u];
}
return bytes.toFixed(dp) + ' ' + units[u];
return fixed + ' ' + units[u];
}
}

View File

@ -51,6 +51,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
}
get WebLinks() {
if (this.seriesMetadata?.webLinks === '') return [];
return this.seriesMetadata?.webLinks.split(',') || [];
}

View File

@ -10591,6 +10591,10 @@
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
"nullable": true
},
"isbn": {
"type": "string",
"nullable": true
},
"people": {
"type": "array",
"items": {
@ -10750,6 +10754,11 @@
"type": "string",
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
"nullable": true
},
"isbn": {
"type": "string",
"description": "ISBN-13 (usually) of the Chapter",
"nullable": true
}
},
"additionalProperties": false,