mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-04 14:14:39 -04:00
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:
parent
a293500f42
commit
6be9ee39f4
@ -79,6 +79,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<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" Version="2.3.0" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.14.2" />
|
<PackageReference Include="NetVips.Native" Version="8.14.2" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
|
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
|
||||||
|
@ -173,6 +173,7 @@ public class ImageController : BaseApiController
|
|||||||
{
|
{
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
if (userId == 0) return BadRequest();
|
if (userId == 0) return BadRequest();
|
||||||
|
if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null");
|
||||||
|
|
||||||
// Check if the domain exists
|
// Check if the domain exists
|
||||||
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url));
|
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url));
|
||||||
|
@ -97,4 +97,9 @@ public class ChapterDto : IHasReadTimeEstimate, IEntityDate
|
|||||||
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string WebLinks { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
@ -121,6 +121,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||||||
builder.Entity<SeriesMetadata>()
|
builder.Entity<SeriesMetadata>()
|
||||||
.Property(b => b.WebLinks)
|
.Property(b => b.WebLinks)
|
||||||
.HasDefaultValue(string.Empty);
|
.HasDefaultValue(string.Empty);
|
||||||
|
|
||||||
|
builder.Entity<Chapter>()
|
||||||
|
.Property(b => b.ISBN)
|
||||||
|
.HasDefaultValue(string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Services;
|
||||||
using Kavita.Common.Extensions;
|
using Kavita.Common.Extensions;
|
||||||
|
using Nager.ArticleNumber;
|
||||||
|
|
||||||
namespace API.Data.Metadata;
|
namespace API.Data.Metadata;
|
||||||
|
|
||||||
@ -35,6 +37,17 @@ public class ComicInfo
|
|||||||
/// IETF BCP 47 Code to represent the language of the content
|
/// IETF BCP 47 Code to represent the language of the content
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string LanguageISO { get; set; } = string.Empty;
|
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>
|
/// <summary>
|
||||||
/// This is the link to where the data was scraped from
|
/// This is the link to where the data was scraped from
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -138,6 +151,22 @@ public class ComicInfo
|
|||||||
info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters);
|
info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters);
|
||||||
info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator);
|
info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator);
|
||||||
info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist);
|
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>
|
/// <summary>
|
||||||
|
1927
API/Data/Migrations/20230512004545_ChapterISBN.Designer.cs
generated
Normal file
1927
API/Data/Migrations/20230512004545_ChapterISBN.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20230512004545_ChapterISBN.cs
Normal file
29
API/Data/Migrations/20230512004545_ChapterISBN.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -413,6 +413,11 @@ namespace API.Data.Migrations
|
|||||||
b.Property<DateTime>("CreatedUtc")
|
b.Property<DateTime>("CreatedUtc")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ISBN")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<bool>("IsSpecial")
|
b.Property<bool>("IsSpecial")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
@ -104,6 +104,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
|
|||||||
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string WebLinks { get; set; } = string.Empty;
|
public string WebLinks { get; set; } = string.Empty;
|
||||||
|
public string ISBN { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
||||||
|
@ -11,6 +11,7 @@ using API.Data.Metadata;
|
|||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Helpers;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using Docnet.Core;
|
using Docnet.Core;
|
||||||
using Docnet.Core.Converters;
|
using Docnet.Core.Converters;
|
||||||
@ -21,6 +22,7 @@ using HtmlAgilityPack;
|
|||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.IO;
|
using Microsoft.IO;
|
||||||
|
using Nager.ArticleNumber;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using VersOne.Epub;
|
using VersOne.Epub;
|
||||||
@ -433,6 +435,14 @@ public class BookService : IBookService
|
|||||||
};
|
};
|
||||||
ComicInfo.CleanComicInfo(info);
|
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
|
// Parse tags not exposed via Library
|
||||||
foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
|
foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
|
||||||
{
|
{
|
||||||
|
@ -1,18 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
|
||||||
using System.Drawing.Imaging;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Flurl;
|
using Flurl;
|
||||||
using Flurl.Http;
|
using Flurl.Http;
|
||||||
|
using HtmlAgilityPack;
|
||||||
|
using Kavita.Common;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
using SixLabors.ImageSharp;
|
|
||||||
using SixLabors.ImageSharp.Formats.Png;
|
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
|
||||||
using Image = NetVips.Image;
|
using Image = NetVips.Image;
|
||||||
using Size = SixLabors.ImageSharp.Size;
|
|
||||||
|
|
||||||
namespace API.Services;
|
namespace API.Services;
|
||||||
|
|
||||||
@ -194,17 +191,46 @@ public class ImageService : IImageService
|
|||||||
var baseUrl = uri.Scheme + "://" + uri.Host;
|
var baseUrl = uri.Scheme + "://" + uri.Host;
|
||||||
try
|
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
|
// Download the favicon.ico file using Flurl
|
||||||
var faviconStream = await baseUrl
|
var faviconStream = await finalUrl
|
||||||
.AppendPathSegment("favicon.ico")
|
.AllowHttpStatus("2xx,304")
|
||||||
.AllowHttpStatus("2xx")
|
|
||||||
.GetStreamAsync();
|
.GetStreamAsync();
|
||||||
|
|
||||||
// Create the destination file path
|
// Create the destination file path
|
||||||
var filename = $"{domain}.png";
|
var filename = $"{domain}.png";
|
||||||
using var icon = new Icon(faviconStream);
|
using var image = Image.PngloadStream(faviconStream);
|
||||||
using var bitmap = icon.ToBitmap();
|
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||||
bitmap.Save(Path.Combine(_directoryService.FaviconDirectory, filename), ImageFormat.Png);
|
|
||||||
|
|
||||||
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
|
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
|
||||||
return filename;
|
return filename;
|
||||||
|
@ -36,7 +36,7 @@ public interface IProcessSeries
|
|||||||
void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false);
|
void UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false);
|
||||||
void UpdateChapters(Series series, Volume volume, 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 AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false);
|
||||||
void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info);
|
void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -722,6 +722,11 @@ public class ProcessSeries : IProcessSeries
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(comicInfo.Isbn))
|
||||||
|
{
|
||||||
|
chapter.ISBN = comicInfo.Isbn;
|
||||||
|
}
|
||||||
|
|
||||||
if (comicInfo.Count > 0)
|
if (comicInfo.Count > 0)
|
||||||
{
|
{
|
||||||
chapter.TotalCount = comicInfo.Count;
|
chapter.TotalCount = comicInfo.Count;
|
||||||
|
@ -42,4 +42,5 @@ export interface Chapter {
|
|||||||
*/
|
*/
|
||||||
volumeTitle?: string;
|
volumeTitle?: string;
|
||||||
webLinks: string;
|
webLinks: string;
|
||||||
|
isbn: string;
|
||||||
}
|
}
|
||||||
|
@ -85,5 +85,14 @@
|
|||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</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>
|
</ng-container>
|
||||||
</div>
|
</div>
|
@ -56,6 +56,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get WebLinks() {
|
get WebLinks() {
|
||||||
|
if (this.chapter.webLinks === '') return [];
|
||||||
return this.chapter.webLinks.split(',');
|
return this.chapter.webLinks.split(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ export class BytesPipe implements PipeTransform {
|
|||||||
*
|
*
|
||||||
* Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
|
* 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;
|
const thresh = si ? 1000 : 1024;
|
||||||
|
|
||||||
if (Math.abs(bytes) < thresh) {
|
if (Math.abs(bytes) < thresh) {
|
||||||
@ -35,8 +35,12 @@ export class BytesPipe implements PipeTransform {
|
|||||||
++u;
|
++u;
|
||||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
|
} 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -51,6 +51,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get WebLinks() {
|
get WebLinks() {
|
||||||
|
if (this.seriesMetadata?.webLinks === '') return [];
|
||||||
return this.seriesMetadata?.webLinks.split(',') || [];
|
return this.seriesMetadata?.webLinks.split(',') || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10591,6 +10591,10 @@
|
|||||||
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
|
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"isbn": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"people": {
|
"people": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@ -10750,6 +10754,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
|
"description": "Comma-separated link of urls to external services that have some relation to the Chapter",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"isbn": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISBN-13 (usually) of the Chapter",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user