mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-23 15:30:34 -04:00
Changed how covers are merged together. Now a cover image will always be generated for reading list and collections. Fixed reading list page being a bit laggy due to a missing trackby function. Reading list page will now show the cover image always. Collection detail page will only hide the image if there is no summary on the collection.
482 lines
18 KiB
C#
482 lines
18 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using API.Constants;
|
|
using API.Entities.Enums;
|
|
using API.Extensions;
|
|
using EasyCaching.Core;
|
|
using Flurl;
|
|
using Flurl.Http;
|
|
using HtmlAgilityPack;
|
|
using Kavita.Common;
|
|
using Microsoft.Extensions.Logging;
|
|
using NetVips;
|
|
using Image = NetVips.Image;
|
|
|
|
namespace API.Services;
|
|
#nullable enable
|
|
|
|
public interface IImageService
|
|
{
|
|
void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1);
|
|
string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size);
|
|
|
|
/// <summary>
|
|
/// Creates a Thumbnail version of a base64 image
|
|
/// </summary>
|
|
/// <param name="encodedImage">base64 encoded image</param>
|
|
/// <param name="fileName"></param>
|
|
/// <param name="encodeFormat">Convert and save as encoding format</param>
|
|
/// <param name="thumbnailWidth">Width of thumbnail</param>
|
|
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
|
string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320);
|
|
/// <summary>
|
|
/// Writes out a thumbnail by stream input
|
|
/// </summary>
|
|
/// <param name="stream"></param>
|
|
/// <param name="fileName"></param>
|
|
/// <param name="outputDirectory"></param>
|
|
/// <param name="encodeFormat"></param>
|
|
/// <returns></returns>
|
|
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
|
|
/// <summary>
|
|
/// Writes out a thumbnail by file path input
|
|
/// </summary>
|
|
/// <param name="sourceFile"></param>
|
|
/// <param name="fileName"></param>
|
|
/// <param name="outputDirectory"></param>
|
|
/// <param name="encodeFormat"></param>
|
|
/// <returns></returns>
|
|
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
|
|
/// <summary>
|
|
/// Converts the passed image to encoding and outputs it in the same directory
|
|
/// </summary>
|
|
/// <param name="filePath">Full path to the image to convert</param>
|
|
/// <param name="outputPath">Where to output the file</param>
|
|
/// <param name="encodeFormat">Encoding Format</param>
|
|
/// <returns>File of written encoded image</returns>
|
|
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
|
Task<bool> IsImage(string filePath);
|
|
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
|
}
|
|
|
|
public class ImageService : IImageService
|
|
{
|
|
public const string Name = "BookmarkService";
|
|
private readonly ILogger<ImageService> _logger;
|
|
private readonly IDirectoryService _directoryService;
|
|
private readonly IEasyCachingProviderFactory _cacheFactory;
|
|
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
|
|
public const string SeriesCoverImageRegex = @"series\d+";
|
|
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
|
public const string ReadingListCoverImageRegex = @"readinglist\d+";
|
|
|
|
|
|
/// <summary>
|
|
/// Width of the Thumbnail generation
|
|
/// </summary>
|
|
private const int ThumbnailWidth = 320;
|
|
/// <summary>
|
|
/// Height of the Thumbnail generation
|
|
/// </summary>
|
|
private const int ThumbnailHeight = 455;
|
|
/// <summary>
|
|
/// Width of a cover for Library
|
|
/// </summary>
|
|
public const int LibraryThumbnailWidth = 32;
|
|
|
|
|
|
private static readonly string[] ValidIconRelations = {
|
|
"icon",
|
|
"apple-touch-icon",
|
|
"apple-touch-icon-precomposed",
|
|
"apple-touch-icon icon-precomposed" // ComicVine has it combined
|
|
};
|
|
|
|
/// <summary>
|
|
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
|
|
/// </summary>
|
|
private static readonly IDictionary<string, string> FaviconUrlMapper = new Dictionary<string, string>
|
|
{
|
|
["https://app.plex.tv"] = "https://plex.tv"
|
|
};
|
|
|
|
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory)
|
|
{
|
|
_logger = logger;
|
|
_directoryService = directoryService;
|
|
_cacheFactory = cacheFactory;
|
|
}
|
|
|
|
public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
|
|
{
|
|
if (string.IsNullOrEmpty(fileFilePath)) return;
|
|
_directoryService.ExistOrCreate(targetDirectory);
|
|
if (fileCount == 1)
|
|
{
|
|
_directoryService.CopyFileToDirectory(fileFilePath, targetDirectory);
|
|
}
|
|
else
|
|
{
|
|
_directoryService.CopyDirectoryToDirectory(_directoryService.FileSystem.Path.GetDirectoryName(fileFilePath), targetDirectory,
|
|
Tasks.Scanner.Parser.Parser.ImageFileExtensions);
|
|
}
|
|
}
|
|
|
|
public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
|
|
{
|
|
if (string.IsNullOrEmpty(path)) return string.Empty;
|
|
|
|
try
|
|
{
|
|
var dims = size.GetDimensions();
|
|
using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force);
|
|
var filename = fileName + encodeFormat.GetExtension();
|
|
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
|
|
return filename;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {ImageFile}. Defaulting to no cover image", path);
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a thumbnail out of a memory stream and saves to <see cref="DirectoryService.CoverImageDirectory"/> with the passed
|
|
/// fileName and .png extension.
|
|
/// </summary>
|
|
/// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param>
|
|
/// <param name="fileName">filename to save as without extension</param>
|
|
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
|
|
/// <param name="encodeFormat">Export the file as the passed encoding</param>
|
|
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
|
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
|
|
{
|
|
var dims = size.GetDimensions();
|
|
using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force);
|
|
var filename = fileName + encodeFormat.GetExtension();
|
|
_directoryService.ExistOrCreate(outputDirectory);
|
|
try
|
|
{
|
|
_directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
|
|
} catch (Exception) {/* Swallow exception */}
|
|
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
|
|
return filename;
|
|
}
|
|
|
|
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
|
|
{
|
|
var dims = size.GetDimensions();
|
|
using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force);
|
|
var filename = fileName + encodeFormat.GetExtension();
|
|
_directoryService.ExistOrCreate(outputDirectory);
|
|
try
|
|
{
|
|
_directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
|
|
} catch (Exception) {/* Swallow exception */}
|
|
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
|
|
return filename;
|
|
}
|
|
|
|
public Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat)
|
|
{
|
|
var file = _directoryService.FileSystem.FileInfo.New(filePath);
|
|
var fileName = file.Name.Replace(file.Extension, string.Empty);
|
|
var outputFile = Path.Join(outputPath, fileName + encodeFormat.GetExtension());
|
|
|
|
using var sourceImage = Image.NewFromFile(filePath, false, Enums.Access.SequentialUnbuffered);
|
|
sourceImage.WriteToFile(outputFile);
|
|
return Task.FromResult(outputFile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs I/O to determine if the file is a valid Image
|
|
/// </summary>
|
|
/// <param name="filePath"></param>
|
|
/// <returns></returns>
|
|
public async Task<bool> IsImage(string filePath)
|
|
{
|
|
try
|
|
{
|
|
var info = await SixLabors.ImageSharp.Image.IdentifyAsync(filePath);
|
|
if (info == null) return false;
|
|
|
|
return true;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
/* Swallow Exception */
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
|
|
{
|
|
// Parse the URL to get the domain (including subdomain)
|
|
var uri = new Uri(url);
|
|
var domain = uri.Host;
|
|
var baseUrl = uri.Scheme + "://" + uri.Host;
|
|
|
|
|
|
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon);
|
|
var res = await provider.GetAsync<string>(baseUrl);
|
|
if (res.HasValue)
|
|
{
|
|
_logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl);
|
|
throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check");
|
|
}
|
|
|
|
await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
|
|
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
|
|
{
|
|
url = value;
|
|
}
|
|
|
|
var correctSizeLink = string.Empty;
|
|
|
|
try
|
|
{
|
|
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.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase))
|
|
.ToList();
|
|
|
|
correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain);
|
|
}
|
|
|
|
try
|
|
{
|
|
if (string.IsNullOrEmpty(correctSizeLink))
|
|
{
|
|
correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl);
|
|
}
|
|
if (string.IsNullOrEmpty(correctSizeLink))
|
|
{
|
|
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
|
}
|
|
|
|
var finalUrl = correctSizeLink;
|
|
|
|
// If starts with //, it's coming usually from an offsite cdn
|
|
if (correctSizeLink.StartsWith("//"))
|
|
{
|
|
finalUrl = "https:" + correctSizeLink;
|
|
}
|
|
else 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 finalUrl
|
|
.AllowHttpStatus("2xx,304")
|
|
.GetStreamAsync();
|
|
|
|
// Create the destination file path
|
|
using var image = Image.PngloadStream(faviconStream);
|
|
var filename = $"{domain}{encodeFormat.GetExtension()}";
|
|
switch (encodeFormat)
|
|
{
|
|
case EncodeFormat.PNG:
|
|
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
|
break;
|
|
case EncodeFormat.WEBP:
|
|
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
|
break;
|
|
case EncodeFormat.AVIF:
|
|
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
|
|
break;
|
|
default:
|
|
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
|
}
|
|
|
|
|
|
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
|
|
return filename;
|
|
}catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static string FallbackToKavitaReaderFavicon(string baseUrl)
|
|
{
|
|
var correctSizeLink = string.Empty;
|
|
var allOverrides = "https://kavitareader.com/assets/favicons/urls.txt".GetStringAsync().Result;
|
|
if (!string.IsNullOrEmpty(allOverrides))
|
|
{
|
|
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
|
var externalFile = allOverrides
|
|
.Split("\n")
|
|
.FirstOrDefault(url =>
|
|
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
|
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
|
));
|
|
if (string.IsNullOrEmpty(externalFile))
|
|
{
|
|
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
|
}
|
|
|
|
correctSizeLink = "https://kavitareader.com/assets/favicons/" + externalFile;
|
|
}
|
|
|
|
return correctSizeLink;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
|
|
{
|
|
try
|
|
{
|
|
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth);
|
|
fileName += encodeFormat.GetExtension();
|
|
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName));
|
|
return fileName;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError(e, "Error creating thumbnail from url");
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the name format for a chapter cover image
|
|
/// </summary>
|
|
/// <param name="chapterId"></param>
|
|
/// <param name="volumeId"></param>
|
|
/// <returns></returns>
|
|
public static string GetChapterFormat(int chapterId, int volumeId)
|
|
{
|
|
return $"v{volumeId}_c{chapterId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the name format for a library cover image
|
|
/// </summary>
|
|
/// <param name="libraryId"></param>
|
|
/// <returns></returns>
|
|
public static string GetLibraryFormat(int libraryId)
|
|
{
|
|
return $"l{libraryId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the name format for a series cover image
|
|
/// </summary>
|
|
/// <param name="seriesId"></param>
|
|
/// <returns></returns>
|
|
public static string GetSeriesFormat(int seriesId)
|
|
{
|
|
return $"series{seriesId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the name format for a collection tag cover image
|
|
/// </summary>
|
|
/// <param name="tagId"></param>
|
|
/// <returns></returns>
|
|
public static string GetCollectionTagFormat(int tagId)
|
|
{
|
|
return $"tag{tagId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the name format for a reading list cover image
|
|
/// </summary>
|
|
/// <param name="readingListId"></param>
|
|
/// <returns></returns>
|
|
public static string GetReadingListFormat(int readingListId)
|
|
{
|
|
// ReSharper disable once StringLiteralTypo
|
|
return $"readinglist{readingListId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the name format for a thumbnail (temp thumbnail)
|
|
/// </summary>
|
|
/// <param name="chapterId"></param>
|
|
/// <returns></returns>
|
|
public static string GetThumbnailFormat(int chapterId)
|
|
{
|
|
return $"thumbnail{chapterId}";
|
|
}
|
|
|
|
public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat)
|
|
{
|
|
return $"{new Uri(url).Host}{encodeFormat.GetExtension()}";
|
|
}
|
|
|
|
|
|
public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
|
|
{
|
|
var dims = size.GetDimensions();
|
|
int rows, cols;
|
|
|
|
if (coverImages.Count == 1)
|
|
{
|
|
rows = 1;
|
|
cols = 1;
|
|
}
|
|
else if (coverImages.Count == 2)
|
|
{
|
|
rows = 1;
|
|
cols = 2;
|
|
}
|
|
else if (coverImages.Count == 3)
|
|
{
|
|
rows = 2;
|
|
cols = 2;
|
|
}
|
|
else
|
|
{
|
|
// Default to 2x2 layout for more than 3 images
|
|
rows = 2;
|
|
cols = 2;
|
|
}
|
|
|
|
var image = Image.Black(dims.Width, dims.Height);
|
|
|
|
var thumbnailWidth = image.Width / cols;
|
|
var thumbnailHeight = image.Height / rows;
|
|
|
|
for (var i = 0; i < coverImages.Count; i++)
|
|
{
|
|
var tile = Image.NewFromFile(coverImages[i], access: Enums.Access.Sequential);
|
|
tile = tile.ThumbnailImage(thumbnailWidth, height: thumbnailHeight);
|
|
|
|
var row = i / cols;
|
|
var col = i % cols;
|
|
|
|
var x = col * thumbnailWidth;
|
|
var y = row * thumbnailHeight;
|
|
|
|
if (coverImages.Count == 3 && i == 2)
|
|
{
|
|
x = (image.Width - thumbnailWidth) / 2;
|
|
y = thumbnailHeight;
|
|
}
|
|
|
|
image = image.Insert(tile, x, y);
|
|
}
|
|
|
|
image.WriteToFile(dest);
|
|
}
|
|
}
|