From 32c035b5c84a36afd6969d37686954880ee7334e Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 3 May 2025 07:02:58 -0500 Subject: [PATCH] Pausing point. Refactored the code to use a better comparison method for images and to use a temp directory for the download. Optimized code for checking similarity for Person Cover image against known placeholders from AniList. There are still bugs around memory-mapped sections being opened when trying to manipulate files. --- API/Controllers/PluginController.cs | 2 +- API/Extensions/ImageExtensions.cs | 407 +++++++++++++++--- API/Services/DirectoryService.cs | 22 + API/Services/Tasks/Metadata/CoverDbService.cs | 221 +++++++--- 4 files changed, 541 insertions(+), 111 deletions(-) diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index 87cfaf2c2..c7f48cf54 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -30,7 +30,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService public async Task> Authenticate([Required] string apiKey, [Required] string pluginName) { // NOTE: In order to log information about plugins, we need some Plugin Description information for each request - // Should log into access table so we can tell the user + // Should log into the access table so we can tell the user var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString(); var userAgent = HttpContext.Request.Headers.UserAgent; var userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); diff --git a/API/Extensions/ImageExtensions.cs b/API/Extensions/ImageExtensions.cs index 720f572a9..14a90b2e3 100644 --- a/API/Extensions/ImageExtensions.cs +++ b/API/Extensions/ImageExtensions.cs @@ -1,18 +1,62 @@ using System; +using System.Collections.Generic; using System.IO; -using NetVips; +using System.Linq; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; -using Image = NetVips.Image; +using Image = SixLabors.ImageSharp.Image; namespace API.Extensions; public static class ImageExtensions { - public static int GetResolution(this Image image) + + /// + /// Structure to hold various image quality metrics + /// + private sealed class ImageQualityMetrics { - return image.Width * image.Height; + public int Width { get; set; } + public int Height { get; set; } + public bool IsColor { get; set; } + public double Colorfulness { get; set; } + public double Contrast { get; set; } + public double Sharpness { get; set; } + public double NoiseLevel { get; set; } + } + + + /// + /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// + /// Path to first image + /// Path to the second image + /// Similarity score between 0-1, where 1 is identical + public static float CalculateSimilarity(this string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Load both images as Rgba32 (consistent with the rest of the code) + using var img1 = Image.Load(imagePath1); + using var img2 = Image.Load(imagePath2); + + // Calculate resolution difference factor + var res1 = img1.Width * img1.Height; + var res2 = img2.Width * img2.Height; + var resolutionDiff = Math.Abs(res1 - res2) / (float) Math.Max(res1, res2); + + // Calculate mean squared error for pixel differences + var mse = img1.GetMeanSquaredError(img2); + + // Normalize MSE (65025 = 255² which is max possible squared difference per channel) + var normalizedMse = 1f - Math.Min(1f, mse / 65025f); + + // Final similarity score (weighted average of resolution difference and color difference) + return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); } /// @@ -43,80 +87,345 @@ public static class ImageExtensions } } - return (float)(totalDiff / (img1.Width * img1.Height)); - } - - public static float GetSimilarity(this string imagePath1, string imagePath2) - { - if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) - { - throw new FileNotFoundException("One or both image files do not exist"); - } - - // Calculate similarity score - return CalculateSimilarity(imagePath1, imagePath2); + return (float) (totalDiff / (img1.Width * img1.Height)); } /// - /// Determines which image is "better" based on similarity and resolution. + /// Determines which image is "better" based on multiple quality factors + /// using only the cross-platform ImageSharp library /// /// Path to first image - /// Path to second image - /// Minimum similarity to consider images similar + /// Path to the second image + /// Whether to prefer color images over grayscale (default: true) /// The path of the better image - public static string GetBetterImage(this string imagePath1, string imagePath2, float similarityThreshold = 0.7f) + public static string GetBetterImage(this string imagePath1, string imagePath2, bool preferColor = true) { if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) { throw new FileNotFoundException("One or both image files do not exist"); } - // Calculate similarity score - var similarity = CalculateSimilarity(imagePath1, imagePath2); + // Quick metadata check to get width/height without loading full pixel data + var info1 = Image.Identify(imagePath1); + var info2 = Image.Identify(imagePath2); - using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); - using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + // Calculate resolution factor + double resolutionFactor1 = info1.Width * info1.Height; + double resolutionFactor2 = info2.Width * info2.Height; - var resolution1 = img1.Width * img1.Height; - var resolution2 = img2.Width * img2.Height; + // If one image is significantly higher resolution (3x or more), just pick it + // This avoids fully loading both images when the choice is obvious + if (resolutionFactor1 > resolutionFactor2 * 3) + return imagePath1; + if (resolutionFactor2 > resolutionFactor1 * 3) + return imagePath2; - // If images are similar, choose the one with higher resolution - if (similarity >= similarityThreshold) + // Otherwise, we need to analyze the actual image data for both + var metrics1 = GetImageQualityMetrics(imagePath1); + var metrics2 = GetImageQualityMetrics(imagePath2); + + // If one is color, and one is grayscale, then we prefer color + if (preferColor && metrics1.IsColor != metrics2.IsColor) { - return resolution1 >= resolution2 ? imagePath1 : imagePath2; + return metrics1.IsColor ? imagePath1 : imagePath2; } - // If images are not similar, allow the new image - return imagePath2; + // Calculate overall quality scores + var score1 = CalculateOverallScore(metrics1); + var score2 = CalculateOverallScore(metrics2); + + return score1 >= score2 ? imagePath1 : imagePath2; + } + + + /// + /// Calculate a weighted overall score based on metrics + /// + private static double CalculateOverallScore(ImageQualityMetrics metrics) + { + // Resolution factor (normalized to HD resolution) + var resolutionFactor = Math.Min(1.0, (metrics.Width * metrics.Height) / (double)(1920 * 1080)); + + // Color factor + var colorFactor = metrics.IsColor ? (0.5 + 0.5 * metrics.Colorfulness) : 0.3; + + // Quality factors + var contrastFactor = Math.Min(1.0, metrics.Contrast); + var sharpnessFactor = Math.Min(1.0, metrics.Sharpness); + + // Noise penalty (less noise is better) + var noisePenalty = Math.Max(0, 1.0 - metrics.NoiseLevel); + + // Weighted combination + return (resolutionFactor * 0.35) + + (colorFactor * 0.3) + + (contrastFactor * 0.15) + + (sharpnessFactor * 0.15) + + (noisePenalty * 0.05); } /// - /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// Gets quality metrics for an image using only ImageSharp /// - /// - /// - /// - private static float CalculateSimilarity(string imagePath1, string imagePath2) + private static ImageQualityMetrics GetImageQualityMetrics(string imagePath) { - if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + // Optimization: Use a smaller version for analysis + using var image = Image.Load(imagePath); + + // Create a smaller version if image is large to speed up analysis + Image workingImage; + if (image.Width > 512 || image.Height > 512) { - return -1; + workingImage = image.Clone(ctx => ctx.Resize( + new ResizeOptions { + Size = new Size(512), + Mode = ResizeMode.Max + })); + } + else + { + workingImage = image.Clone(); } - using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); - using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + var metrics = new ImageQualityMetrics + { + Width = image.Width, + Height = image.Height + }; - var res1 = img1.Width * img1.Height; - var res2 = img2.Width * img2.Height; - var resolutionDiff = Math.Abs(res1 - res2) / (float)Math.Max(res1, res2); + // Color analysis (is the image color or grayscale?) + var colorInfo = AnalyzeColorfulness(workingImage); + metrics.IsColor = colorInfo.IsColor; + metrics.Colorfulness = colorInfo.Colorfulness; - using var imgSharp1 = SixLabors.ImageSharp.Image.Load(imagePath1); - using var imgSharp2 = SixLabors.ImageSharp.Image.Load(imagePath2); + // Contrast analysis + metrics.Contrast = CalculateContrast(workingImage); - var mse = imgSharp1.GetMeanSquaredError(imgSharp2); - var normalizedMse = 1f - Math.Min(1f, mse / 65025f); // Normalize based on max color diff + // Sharpness estimation + metrics.Sharpness = EstimateSharpness(workingImage); - // Final similarity score (weighted) - return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); + // Noise estimation + metrics.NoiseLevel = EstimateNoiseLevel(workingImage); + + // Clean up + if (workingImage != image) + { + workingImage.Dispose(); + } + + return metrics; + } + + /// + /// Analyzes colorfulness of an image + /// + private static (bool IsColor, double Colorfulness) AnalyzeColorfulness(Image image) + { + // For performance, sample a subset of pixels + var sampleSize = Math.Min(1000, image.Width * image.Height); + var stepSize = Math.Max(1, (image.Width * image.Height) / sampleSize); + + var colorCount = 0; + List<(int R, int G, int B)> samples = []; + + // Sample pixels + for (var i = 0; i < image.Width * image.Height; i += stepSize) + { + var x = i % image.Width; + var y = i / image.Width; + + var pixel = image[x, y]; + + // Check if RGB channels differ by a threshold + // High difference indicates color, low difference indicates grayscale + var rMinusG = Math.Abs(pixel.R - pixel.G); + var rMinusB = Math.Abs(pixel.R - pixel.B); + var gMinusB = Math.Abs(pixel.G - pixel.B); + + if (rMinusG > 15 || rMinusB > 15 || gMinusB > 15) + { + colorCount++; + } + + samples.Add((pixel.R, pixel.G, pixel.B)); + } + + // Calculate colorfulness metric based on Hasler and Süsstrunk's approach + // This measures the spread and intensity of colors + if (samples.Count <= 0) return (false, 0); + + // Calculate rg and yb opponent channels + var rg = samples.Select(p => p.R - p.G).ToList(); + var yb = samples.Select(p => 0.5 * (p.R + p.G) - p.B).ToList(); + + // Calculate standard deviation and mean of opponent channels + var rgStdDev = CalculateStdDev(rg); + var ybStdDev = CalculateStdDev(yb); + var rgMean = rg.Average(); + var ybMean = yb.Average(); + + // Combine into colorfulness metric + var stdRoot = Math.Sqrt(rgStdDev * rgStdDev + ybStdDev * ybStdDev); + var meanRoot = Math.Sqrt(rgMean * rgMean + ybMean * ybMean); + + var colorfulness = stdRoot + 0.3 * meanRoot; + + // Normalize to 0-1 range (typical colorfulness is 0-100) + colorfulness = Math.Min(1.0, colorfulness / 100.0); + + var isColor = (double)colorCount / samples.Count > 0.05; + + return (isColor, colorfulness); + + } + + /// + /// Calculate standard deviation of a list of values + /// + private static double CalculateStdDev(List values) + { + var mean = values.Average(); + var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum(); + return Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + } + + /// + /// Calculate standard deviation of a list of values + /// + private static double CalculateStdDev(List values) + { + var mean = values.Average(); + var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum(); + return Math.Sqrt(sumOfSquaresOfDifferences / values.Count); + } + + /// + /// Calculates contrast of an image + /// + private static double CalculateContrast(Image image) + { + // For performance, sample a subset of pixels + var sampleSize = Math.Min(1000, image.Width * image.Height); + var stepSize = Math.Max(1, (image.Width * image.Height) / sampleSize); + + List luminanceValues = new(); + + // Sample pixels and calculate luminance + for (var i = 0; i < image.Width * image.Height; i += stepSize) + { + var x = i % image.Width; + var y = i / image.Width; + + var pixel = image[x, y]; + + // Calculate luminance + var luminance = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + luminanceValues.Add(luminance); + } + + if (luminanceValues.Count < 2) + return 0; + + // Use RMS contrast (root-mean-square of pixel intensity) + var mean = luminanceValues.Average(); + var sumOfSquaresOfDifferences = luminanceValues.Sum(l => Math.Pow(l - mean, 2)); + var rmsContrast = Math.Sqrt(sumOfSquaresOfDifferences / luminanceValues.Count) / mean; + + // Normalize to 0-1 range + return Math.Min(1.0, rmsContrast); + } + + /// + /// Estimates sharpness using simple Laplacian-based method + /// + private static double EstimateSharpness(Image image) + { + // For simplicity, convert to grayscale + var grayImage = new int[image.Width, image.Height]; + + // Convert to grayscale + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + var pixel = image[x, y]; + grayImage[x, y] = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + } + } + + // Apply Laplacian filter (3x3) + // The Laplacian measures local variations - higher values indicate edges/details + double laplacianSum = 0; + var validPixels = 0; + + // Laplacian kernel: [0, 1, 0, 1, -4, 1, 0, 1, 0] + for (var y = 1; y < image.Height - 1; y++) + { + for (var x = 1; x < image.Width - 1; x++) + { + var laplacian = + grayImage[x, y - 1] + + grayImage[x - 1, y] - 4 * grayImage[x, y] + grayImage[x + 1, y] + + grayImage[x, y + 1]; + + laplacianSum += Math.Abs(laplacian); + validPixels++; + } + } + + if (validPixels == 0) + return 0; + + // Calculate variance of Laplacian + var laplacianVariance = laplacianSum / validPixels; + + // Normalize to 0-1 range (typical values range from 0-1000) + return Math.Min(1.0, laplacianVariance / 1000.0); + } + + /// + /// Estimates noise level using simple block-based variance method + /// + private static double EstimateNoiseLevel(Image image) + { + // Block size for noise estimation + const int blockSize = 8; + List blockVariances = new(); + + // Calculate variance in small blocks throughout the image + for (var y = 0; y < image.Height - blockSize; y += blockSize) + { + for (var x = 0; x < image.Width - blockSize; x += blockSize) + { + List blockValues = new(); + + // Sample block + for (var by = 0; by < blockSize; by++) + { + for (var bx = 0; bx < blockSize; bx++) + { + var pixel = image[x + bx, y + by]; + var value = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B); + blockValues.Add(value); + } + } + + // Calculate variance of this block + var blockMean = blockValues.Average(); + var blockVariance = blockValues.Sum(v => Math.Pow(v - blockMean, 2)) / blockValues.Count; + blockVariances.Add(blockVariance); + } + } + + if (blockVariances.Count == 0) + return 0; + + // Sort block variances and take lowest 10% (likely uniform areas where noise is most visible) + blockVariances.Sort(); + var smoothBlocksCount = Math.Max(1, blockVariances.Count / 10); + var averageNoiseVariance = blockVariances.Take(smoothBlocksCount).Average(); + + // Normalize to 0-1 range (typical noise variances are 0-100) + return Math.Min(1.0, averageNoiseVariance / 100.0); } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index ae9383c7b..c131a25db 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -69,6 +69,7 @@ public interface IDirectoryService IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); bool ExistOrCreate(string directoryPath); void DeleteFiles(IEnumerable files); + void CopyFile(string sourcePath, string destinationPath, bool overwrite = true); void RemoveNonImages(string directoryName); void Flatten(string directoryName); Task CheckWriteAccess(string directoryName); @@ -937,6 +938,27 @@ public class DirectoryService : IDirectoryService } } + public void CopyFile(string sourcePath, string destinationPath, bool overwrite = true) + { + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException("Source file not found", sourcePath); + } + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrEmpty(destinationDirectory)) + { + throw new ArgumentException("Destination path does not contain a directory", nameof(destinationPath)); + } + + if (!Directory.Exists(destinationDirectory)) + { + FileSystem.Directory.CreateDirectory(destinationDirectory); + } + + FileSystem.File.Copy(sourcePath, destinationPath, overwrite); + } + /// /// Returns the human-readable file size for an arbitrary, 64-bit file size /// The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB" diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 1b315b921..2717218fe 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -294,36 +294,46 @@ public class CoverDbService : ICoverDbService return null; } - private async Task DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url) + private async Task DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url, string? targetDirectory = null) { + // TODO: I need to unit test this to ensure it works when overwriting, etc + + // Target Directory defaults to CoverImageDirectory, but can be temp for when comparison between images is used + targetDirectory ??= _directoryService.CoverImageDirectory; + // Create the destination file path var filename = filenameWithoutExtension + encodeFormat.GetExtension(); - var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename); + var targetFile = Path.Combine(targetDirectory, filename); - // Ensure if file exists, we delete to overwrite + // if (new FileInfo(targetFile).Exists) + // { + // File.Delete(targetFile); + // } _logger.LogTrace("Fetching person image from {Url}", url.Sanitize()); // Download the file using Flurl - var personStream = await url + var imageStream = await url .AllowHttpStatus("2xx,304") .GetStreamAsync(); - using var image = Image.NewFromStream(personStream); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(targetFile); - break; - case EncodeFormat.WEBP: - image.Webpsave(targetFile); - break; - case EncodeFormat.AVIF: - image.Heifsave(targetFile); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } + + using var image = Image.NewFromStream(imageStream); + image.WriteToFile(targetFile); + // switch (encodeFormat) + // { + // case EncodeFormat.PNG: + // image.Pngsave(targetFile); + // break; + // case EncodeFormat.WEBP: + // image.Webpsave(targetFile); + // break; + // case EncodeFormat.AVIF: + // image.Heifsave(targetFile); + // break; + // default: + // throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + // } return filename; } @@ -486,33 +496,67 @@ public class CoverDbService : ICoverDbService /// Will check against all known null image placeholders to avoid writing it public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) { - // TODO: Refactor checkNoImagePlaceholder bool to an action that evaluates how to process Image if (!string.IsNullOrEmpty(url)) { - var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64); + var tempDir = _directoryService.TempDirectory; + var format = ImageService.GetPersonFormat(person.Id); + var finalFileName = format + ".webp"; + var tempFileName = format + "_new"; + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); - // Additional check to see if downloaded image is similar and we have a higher resolution - if (checkNoImagePlaceholder) + if (!string.IsNullOrEmpty(tempFilePath)) { - var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + var tempFullPath = Path.Combine(tempDir, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); - if (matchRating >= 0.9f) + // Skip setting image if it's similar to a known placeholder + if (checkNoImagePlaceholder) { - if (string.IsNullOrEmpty(person.CoverImage)) + var placeholderPath = Path.Combine(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg"); + var similarity = placeholderPath.CalculateSimilarity(tempFullPath); + if (similarity >= 0.9f) { - filePath = null; + _logger.LogInformation("Skipped setting placeholder image for person {PersonId} due to high similarity ({Similarity})", person.Id, similarity); + _directoryService.DeleteFiles([tempFullPath]); + return; + } + } + + try + { + if (!string.IsNullOrEmpty(person.CoverImage)) + { + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + if (choseNewImage) + { + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + person.CoverImage = Path.GetFileName(existingPath); + } } else { - filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage)); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; } - } - } + catch (Exception ex) + { + _logger.LogError(ex, "Error choosing better image for Person: {PersonId}", person.Id); + _directoryService.CopyFile(tempFullPath, finalFullPath); + person.CoverImage = finalFileName; + } + + _directoryService.DeleteFiles([tempFullPath]); - if (!string.IsNullOrEmpty(filePath)) - { - person.CoverImage = filePath; person.CoverImageLocked = true; _imageService.UpdateColorScape(person); _unitOfWork.PersonRepository.Update(person); @@ -545,32 +589,52 @@ public class CoverDbService : ICoverDbService { if (!string.IsNullOrEmpty(url)) { - var filePath = await CreateThumbnail(url, $"{ImageService.GetSeriesFormat(series.Id)}", fromBase64); + var tempDir = _directoryService.TempDirectory; + var format = ImageService.GetSeriesFormat(series.Id); + var finalFileName = format + ".webp"; + var tempFileName = format + "_new"; + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); - if (!string.IsNullOrEmpty(filePath)) + if (!string.IsNullOrEmpty(tempFilePath)) { - // Additional check to see if downloaded image is similar and we have a higher resolution + var tempFullPath = Path.Combine(tempDir, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + if (chooseBetterImage && !string.IsNullOrEmpty(series.CoverImage)) { try { - // BUG: There might be a bug here where it's comparing the same 2 images - var betterImage = Path.Join(_directoryService.CoverImageDirectory, series.CoverImage) - .GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!; - filePath = Path.GetFileName(betterImage); + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, series.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + if (choseNewImage) + { + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + series.CoverImage = Path.GetFileName(existingPath); + } } catch (Exception ex) { - _logger.LogError(ex, "There was an issue trying to choose a better cover image for Series: {SeriesName} ({SeriesId})", series.Name, series.Id); + _logger.LogError(ex, "Error choosing better image for Series: {SeriesId}", series.Id); + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; } } - - series.CoverImage = filePath; - series.CoverImageLocked = true; - if (series.CoverImage == null) + else { - _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + _directoryService.CopyFile(tempFullPath, finalFullPath); + series.CoverImage = finalFileName; } + + _directoryService.DeleteFiles([tempFullPath]); + series.CoverImageLocked = true; _imageService.UpdateColorScape(series); _unitOfWork.SeriesRepository.Update(series); } @@ -579,10 +643,7 @@ public class CoverDbService : ICoverDbService { series.CoverImage = null; series.CoverImageLocked = false; - if (series.CoverImage == null) - { - _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); - } + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); _imageService.UpdateColorScape(series); _unitOfWork.SeriesRepository.Update(series); } @@ -599,26 +660,51 @@ public class CoverDbService : ICoverDbService { if (!string.IsNullOrEmpty(url)) { - var filePath = await CreateThumbnail(url, $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}", fromBase64); + var tempDirectory = _directoryService.TempDirectory; + var finalFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + ".webp"; + var tempFileName = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + "_new"; - if (!string.IsNullOrEmpty(filePath)) + var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDirectory); + + if (!string.IsNullOrEmpty(tempFilePath)) { - // Additional check to see if downloaded image is similar and we have a higher resolution + var tempFullPath = Path.Combine(tempDirectory, tempFilePath); + var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName); + if (chooseBetterImage && !string.IsNullOrEmpty(chapter.CoverImage)) { try { - var betterImage = Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage) - .GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!; - filePath = Path.GetFileName(betterImage); + var existingPath = Path.Combine(_directoryService.CoverImageDirectory, chapter.CoverImage); + var betterImage = existingPath.GetBetterImage(tempFullPath)!; + var choseNewImage = string.Equals(betterImage, tempFullPath, StringComparison.OrdinalIgnoreCase); + + if (choseNewImage) + { + _directoryService.DeleteFiles([existingPath]); + _directoryService.CopyFile(tempFullPath, finalFullPath); + _directoryService.DeleteFiles([tempFullPath]); + } + else + { + _directoryService.DeleteFiles([tempFullPath]); + } + + chapter.CoverImage = finalFileName; } catch (Exception ex) { _logger.LogError(ex, "There was an issue trying to choose a better cover image for Chapter: {FileName} ({ChapterId})", chapter.Range, chapter.Id); } } + else + { + // No comparison needed, just copy and rename to final + _directoryService.CopyFile(tempFullPath, finalFullPath); + _directoryService.DeleteFiles([tempFullPath]); + chapter.CoverImage = finalFileName; + } - chapter.CoverImage = filePath; chapter.CoverImageLocked = true; _imageService.UpdateColorScape(chapter); _unitOfWork.ChapterRepository.Update(chapter); @@ -635,13 +721,26 @@ public class CoverDbService : ICoverDbService if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); + await _eventHub.SendMessageAsync( + MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), + false + ); } } - private async Task CreateThumbnail(string url, string filename, bool fromBase64 = true) + /// + /// + /// + /// + /// + /// + /// Not useable with fromBase64. Allows a different directory to be written to + /// + private async Task CreateThumbnail(string url, string filename, bool fromBase64 = true, string? targetDirectory = null) { + targetDirectory ??= _directoryService.CoverImageDirectory; + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var encodeFormat = settings.EncodeMediaAs; var coverImageSize = settings.CoverImageSize; @@ -652,6 +751,6 @@ public class CoverDbService : ICoverDbService filename, encodeFormat, coverImageSize.GetDimensions().Width); } - return await DownloadImageFromUrl(filename, encodeFormat, url); + return await DownloadImageFromUrl(filename, encodeFormat, url, targetDirectory); } }