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.
This commit is contained in:
Joseph Milazzo 2025-05-03 07:02:58 -05:00
parent 1d9084fe49
commit 32c035b5c8
4 changed files with 541 additions and 111 deletions

View file

@ -30,7 +30,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
public async Task<ActionResult<UserDto>> 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);

View file

@ -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)
/// <summary>
/// Structure to hold various image quality metrics
/// </summary>
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; }
}
/// <summary>
/// Calculate a similarity score (0-1f) based on resolution difference and MSE.
/// </summary>
/// <param name="imagePath1">Path to first image</param>
/// <param name="imagePath2">Path to the second image</param>
/// <returns>Similarity score between 0-1, where 1 is identical</returns>
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<Rgba32>(imagePath1);
using var img2 = Image.Load<Rgba32>(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);
}
/// <summary>
@ -46,77 +90,342 @@ 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);
}
/// <summary>
/// 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
/// </summary>
/// <param name="imagePath1">Path to first image</param>
/// <param name="imagePath2">Path to second image</param>
/// <param name="similarityThreshold">Minimum similarity to consider images similar</param>
/// <param name="imagePath2">Path to the second image</param>
/// <param name="preferColor">Whether to prefer color images over grayscale (default: true)</param>
/// <returns>The path of the better image</returns>
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;
}
/// <summary>
/// Calculate a weighted overall score based on metrics
/// </summary>
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);
}
/// <summary>
/// Calculate a similarity score (0-1f) based on resolution difference and MSE.
/// Gets quality metrics for an image using only ImageSharp
/// </summary>
/// <param name="imagePath1"></param>
/// <param name="imagePath2"></param>
/// <returns></returns>
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<Rgba32>(imagePath);
// Create a smaller version if image is large to speed up analysis
Image<Rgba32> 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<Rgba32>(imagePath1);
using var imgSharp2 = SixLabors.ImageSharp.Image.Load<Rgba32>(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;
}
/// <summary>
/// Analyzes colorfulness of an image
/// </summary>
private static (bool IsColor, double Colorfulness) AnalyzeColorfulness(Image<Rgba32> 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);
}
/// <summary>
/// Calculate standard deviation of a list of values
/// </summary>
private static double CalculateStdDev(List<int> values)
{
var mean = values.Average();
var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum();
return Math.Sqrt(sumOfSquaresOfDifferences / values.Count);
}
/// <summary>
/// Calculate standard deviation of a list of values
/// </summary>
private static double CalculateStdDev(List<double> values)
{
var mean = values.Average();
var sumOfSquaresOfDifferences = values.Select(val => (val - mean) * (val - mean)).Sum();
return Math.Sqrt(sumOfSquaresOfDifferences / values.Count);
}
/// <summary>
/// Calculates contrast of an image
/// </summary>
private static double CalculateContrast(Image<Rgba32> 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<int> 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);
}
/// <summary>
/// Estimates sharpness using simple Laplacian-based method
/// </summary>
private static double EstimateSharpness(Image<Rgba32> 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);
}
/// <summary>
/// Estimates noise level using simple block-based variance method
/// </summary>
private static double EstimateNoiseLevel(Image<Rgba32> image)
{
// Block size for noise estimation
const int blockSize = 8;
List<double> 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<int> 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);
}
}

View file

@ -69,6 +69,7 @@ public interface IDirectoryService
IEnumerable<string> GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly);
bool ExistOrCreate(string directoryPath);
void DeleteFiles(IEnumerable<string> files);
void CopyFile(string sourcePath, string destinationPath, bool overwrite = true);
void RemoveNonImages(string directoryName);
void Flatten(string directoryName);
Task<bool> 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);
}
/// <summary>
/// Returns the human-readable file size for an arbitrary, 64-bit file size
/// <remarks>The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB"</remarks>

View file

@ -294,36 +294,46 @@ public class CoverDbService : ICoverDbService
return null;
}
private async Task<string> DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url)
private async Task<string> 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
/// <param name="checkNoImagePlaceholder">Will check against all known null image placeholders to avoid writing it</param>
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 (!string.IsNullOrEmpty(tempFilePath))
{
var tempFullPath = Path.Combine(tempDir, tempFilePath);
var finalFullPath = Path.Combine(_directoryService.CoverImageDirectory, finalFileName);
// Skip setting image if it's similar to a known placeholder
if (checkNoImagePlaceholder)
{
var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!;
var placeholderPath = Path.Combine(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg");
var similarity = placeholderPath.CalculateSimilarity(tempFullPath);
if (similarity >= 0.9f)
{
_logger.LogInformation("Skipped setting placeholder image for person {PersonId} due to high similarity ({Similarity})", person.Id, similarity);
_directoryService.DeleteFiles([tempFullPath]);
return;
}
}
if (matchRating >= 0.9f)
try
{
if (string.IsNullOrEmpty(person.CoverImage))
if (!string.IsNullOrEmpty(person.CoverImage))
{
filePath = null;
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
{
filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage));
}
_directoryService.DeleteFiles([tempFullPath]);
person.CoverImage = Path.GetFileName(existingPath);
}
}
if (!string.IsNullOrEmpty(filePath))
else
{
person.CoverImage = filePath;
_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]);
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;
}
}
else
{
_directoryService.CopyFile(tempFullPath, finalFullPath);
series.CoverImage = finalFileName;
}
series.CoverImage = filePath;
_directoryService.DeleteFiles([tempFullPath]);
series.CoverImageLocked = true;
if (series.CoverImage == null)
{
_logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null");
}
_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");
}
_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<string> CreateThumbnail(string url, string filename, bool fromBase64 = true)
/// <summary>
///
/// </summary>
/// <param name="url"></param>
/// <param name="filename"></param>
/// <param name="fromBase64"></param>
/// <param name="targetDirectory">Not useable with fromBase64. Allows a different directory to be written to</param>
/// <returns></returns>
private async Task<string> 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);
}
}