Kavita/API/Services/ImageService.cs
Joe Milazzo b50fa0fd1e
Lots of Bugfixes (#2960)
Co-authored-by: Samuel Martins <s@smartins.ch>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
2024-05-22 04:58:23 -07:00

478 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.Replace(Environment.NewLine, string.Empty);
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 = ImageService.GetWebLinkFormat(baseUrl, encodeFormat);
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.Replace("www.", string.Empty)}{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
{
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++)
{
if (!File.Exists(coverImages[i])) continue;
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);
}
}