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); /// /// Creates a Thumbnail version of a base64 image /// /// base64 encoded image /// /// Convert and save as encoding format /// Width of thumbnail /// File name with extension of the file. This will always write to string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320); /// /// Writes out a thumbnail by stream input /// /// /// /// /// /// string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); /// /// Writes out a thumbnail by file path input /// /// /// /// /// /// string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); /// /// Converts the passed image to encoding and outputs it in the same directory /// /// Full path to the image to convert /// Where to output the file /// Encoding Format /// File of written encoded image Task ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat); Task IsImage(string filePath); Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); } public class ImageService : IImageService { public const string Name = "BookmarkService"; private readonly ILogger _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+"; /// /// Width of the Thumbnail generation /// private const int ThumbnailWidth = 320; /// /// Height of the Thumbnail generation /// private const int ThumbnailHeight = 455; /// /// Width of a cover for Library /// 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 }; /// /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) /// private static readonly IDictionary FaviconUrlMapper = new Dictionary { ["https://app.plex.tv"] = "https://plex.tv" }; public ImageService(ILogger 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); } } /// /// Tries to determine if there is a better mode for resizing /// /// /// /// /// public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight) { try { if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) { return Enums.Size.Force; } } catch (Exception) { /* Swallow */ } return Enums.Size.Both; } public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight) { try { if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) { return null; } } catch (Exception) { /* Swallow */ return null; } return Enums.Interesting.Attention; } public static bool WillScaleWell(Image sourceImage, int targetWidth, int targetHeight, double tolerance = 0.1) { // Calculate the aspect ratios var sourceAspectRatio = (double) sourceImage.Width / sourceImage.Height; var targetAspectRatio = (double) targetWidth / targetHeight; // Compare aspect ratios if (Math.Abs(sourceAspectRatio - targetAspectRatio) > tolerance) { return false; // Aspect ratios differ significantly } // Calculate scaling factors var widthScaleFactor = (double) targetWidth / sourceImage.Width; var heightScaleFactor = (double) targetHeight / sourceImage.Height; // Check resolution quality (example thresholds) if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0) { return false; // Scaling factor too large } return true; // Image will scale well } private static bool IsLikelyWideImage(int width, int height) { var aspectRatio = (double) width / height; return aspectRatio > 1.25; } public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { if (string.IsNullOrEmpty(path)) return string.Empty; try { var (width, height) = size.GetDimensions(); using var sourceImage = Image.NewFromFile(path, false, Enums.Access.SequentialUnbuffered); using var thumbnail = Image.Thumbnail(path, width, height: height, size: GetSizeForDimensions(sourceImage, width, height), crop: GetCropForDimensions(sourceImage, width, height)); 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; } /// /// Creates a thumbnail out of a memory stream and saves to with the passed /// fileName and .png extension. /// /// Stream to write to disk. Ensure this is rewinded. /// filename to save as without extension /// Where to output the file, defaults to covers directory /// Export the file as the passed encoding /// File name with extension of the file. This will always write to public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { var (targetWidth, targetHeight) = size.GetDimensions(); if (stream.CanSeek) stream.Position = 0; using var sourceImage = Image.NewFromStream(stream); if (stream.CanSeek) stream.Position = 0; using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight, size: GetSizeForDimensions(sourceImage, targetWidth, targetHeight), crop: GetCropForDimensions(sourceImage, targetWidth, targetHeight)); 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 (width, height) = size.GetDimensions(); using var sourceImage = Image.NewFromFile(sourceFile, false, Enums.Access.SequentialUnbuffered); using var thumbnail = Image.Thumbnail(sourceFile, width, height: height, size: GetSizeForDimensions(sourceImage, width, height), crop: GetCropForDimensions(sourceImage, width, height)); 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 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); } /// /// Performs I/O to determine if the file is a valid Image /// /// /// public async Task 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 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(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; } /// 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; } /// /// Returns the name format for a chapter cover image /// /// /// /// public static string GetChapterFormat(int chapterId, int volumeId) { return $"v{volumeId}_c{chapterId}"; } /// /// Returns the name format for a library cover image /// /// /// public static string GetLibraryFormat(int libraryId) { return $"l{libraryId}"; } /// /// Returns the name format for a series cover image /// /// /// public static string GetSeriesFormat(int seriesId) { return $"series{seriesId}"; } /// /// Returns the name format for a collection tag cover image /// /// /// public static string GetCollectionTagFormat(int tagId) { return $"tag{tagId}"; } /// /// Returns the name format for a reading list cover image /// /// /// public static string GetReadingListFormat(int readingListId) { // ReSharper disable once StringLiteralTypo return $"readinglist{readingListId}"; } /// /// Returns the name format for a thumbnail (temp thumbnail) /// /// /// 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 coverImages, CoverImageSize size, string dest) { var (width, height) = 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(width, 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); } }