Fixed Delete Series + Issue Covers from Kavita+ (#3784)
This commit is contained in:
parent
3a0d33ca13
commit
bc41b0256e
38 changed files with 2189 additions and 1596 deletions
|
@ -68,7 +68,8 @@ public class ChapterController : BaseApiController
|
|||
{
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
|
||||
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId,
|
||||
ChapterIncludes.Files | ChapterIncludes.ExternalReviews | ChapterIncludes.ExternalRatings);
|
||||
if (chapter == null)
|
||||
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
|
||||
|
@ -86,6 +87,15 @@ public class ChapterController : BaseApiController
|
|||
_unitOfWork.ChapterRepository.Remove(chapter);
|
||||
}
|
||||
|
||||
// If we removed the volume, do an additional check if we need to delete the actual series as well or not
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(vol.SeriesId, SeriesIncludes.ExternalData | SeriesIncludes.Volumes);
|
||||
var needToRemoveSeries = needToRemoveVolume && series != null && series.Volumes.Count <= 1;
|
||||
if (needToRemoveSeries)
|
||||
{
|
||||
_unitOfWork.SeriesRepository.Remove(series!);
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!await _unitOfWork.CommitAsync()) return Ok(false);
|
||||
|
||||
|
@ -95,6 +105,12 @@ public class ChapterController : BaseApiController
|
|||
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false);
|
||||
}
|
||||
|
||||
if (needToRemoveSeries)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
|
||||
MessageFactory.SeriesRemovedEvent(series!.Id, series.Name, series.LibraryId), false);
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
|
@ -419,7 +435,7 @@ public class ChapterController : BaseApiController
|
|||
ret.HasBeenRated = ownRating.HasBeenRated;
|
||||
}
|
||||
|
||||
var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviews(chapterId);
|
||||
var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviewDtos(chapterId);
|
||||
if (externalReviews.Count > 0)
|
||||
{
|
||||
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews));
|
||||
|
@ -427,7 +443,7 @@ public class ChapterController : BaseApiController
|
|||
|
||||
ret.Reviews = userReviews;
|
||||
|
||||
ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapterId);
|
||||
ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatingDtos(chapterId);
|
||||
|
||||
return Ok(ret);
|
||||
}
|
||||
|
|
|
@ -221,7 +221,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
return Ok(ret);
|
||||
}
|
||||
|
||||
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto ret)
|
||||
private async Task PrepareSeriesDetail(List<UserReviewDto> userReviews, SeriesDetailPlusDto? ret)
|
||||
{
|
||||
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!;
|
||||
|
@ -235,12 +235,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc
|
|||
ret.Recommendations.OwnedSeries =
|
||||
await unitOfWork.SeriesRepository.GetSeriesDtoByIdsAsync(
|
||||
ret.Recommendations.OwnedSeries.Select(s => s.Id), user);
|
||||
ret.Recommendations.ExternalSeries = new List<ExternalSeriesDto>();
|
||||
ret.Recommendations.ExternalSeries = [];
|
||||
}
|
||||
|
||||
if (ret.Recommendations != null && user != null)
|
||||
{
|
||||
ret.Recommendations.OwnedSeries ??= new List<SeriesDto>();
|
||||
ret.Recommendations.OwnedSeries ??= [];
|
||||
await unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, ret.Recommendations.OwnedSeries);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -71,6 +71,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
|
||||
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
||||
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
|
||||
[Obsolete]
|
||||
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
|
||||
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
|
||||
public DbSet<ChapterPeople> ChapterPeople { get; set; } = null!;
|
||||
|
|
|
@ -8,6 +8,7 @@ using API.DTOs.Reader;
|
|||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
|
@ -27,6 +28,7 @@ public enum ChapterIncludes
|
|||
Genres = 16,
|
||||
Tags = 32,
|
||||
ExternalReviews = 1 << 6,
|
||||
ExternalRatings = 1 << 7
|
||||
}
|
||||
|
||||
public interface IChapterRepository
|
||||
|
@ -51,8 +53,10 @@ public interface IChapterRepository
|
|||
IEnumerable<Chapter> GetChaptersForSeries(int seriesId);
|
||||
Task<IList<Chapter>> GetAllChaptersForSeries(int seriesId);
|
||||
Task<int> GetAverageUserRating(int chapterId, int userId);
|
||||
Task<IList<UserReviewDto>> GetExternalChapterReviews(int chapterId);
|
||||
Task<IList<RatingDto>> GetExternalChapterRatings(int chapterId);
|
||||
Task<IList<UserReviewDto>> GetExternalChapterReviewDtos(int chapterId);
|
||||
Task<IList<ExternalReview>> GetExternalChapterReview(int chapterId);
|
||||
Task<IList<RatingDto>> GetExternalChapterRatingDtos(int chapterId);
|
||||
Task<IList<ExternalRating>> GetExternalChapterRatings(int chapterId);
|
||||
}
|
||||
public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
|
@ -332,7 +336,7 @@ public class ChapterRepository : IChapterRepository
|
|||
return avg.HasValue ? (int) (avg.Value * 20) : 0;
|
||||
}
|
||||
|
||||
public async Task<IList<UserReviewDto>> GetExternalChapterReviews(int chapterId)
|
||||
public async Task<IList<UserReviewDto>> GetExternalChapterReviewDtos(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
|
@ -342,7 +346,15 @@ public class ChapterRepository : IChapterRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<RatingDto>> GetExternalChapterRatings(int chapterId)
|
||||
public async Task<IList<ExternalReview>> GetExternalChapterReview(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.SelectMany(c => c.ExternalReviews)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<RatingDto>> GetExternalChapterRatingDtos(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
|
@ -350,4 +362,12 @@ public class ChapterRepository : IChapterRepository
|
|||
.ProjectTo<RatingDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<ExternalRating>> GetExternalChapterRatings(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.SelectMany(c => c.ExternalRatings)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,6 +57,8 @@ public enum SeriesIncludes
|
|||
ExternalRatings = 128,
|
||||
ExternalRecommendations = 256,
|
||||
ExternalMetadata = 512,
|
||||
|
||||
ExternalData = ExternalMetadata | ExternalReviews | ExternalRatings | ExternalRecommendations,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -563,7 +565,13 @@ public class SeriesRepository : ISeriesRepository
|
|||
|
||||
if (!fullSeries) return await query.ToListAsync();
|
||||
|
||||
return await query.Include(s => s.Volumes)
|
||||
return await query
|
||||
.Include(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ThenInclude(c => c.ExternalRatings)
|
||||
.Include(s => s.Volumes)
|
||||
.ThenInclude(v => v.Chapters)
|
||||
.ThenInclude(c => c.ExternalReviews)
|
||||
.Include(s => s.Relations)
|
||||
.Include(s => s.Metadata)
|
||||
|
||||
|
|
|
@ -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 the 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>
|
||||
|
@ -43,80 +87,351 @@ 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));
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
// NOTE: We HAVE to use these scope blocks and load image here otherwise memory-mapped section exception will occur
|
||||
ImageQualityMetrics metrics1;
|
||||
using (var img1 = Image.Load<Rgba32>(imagePath1))
|
||||
{
|
||||
return resolution1 >= resolution2 ? imagePath1 : imagePath2;
|
||||
metrics1 = GetImageQualityMetrics(img1);
|
||||
}
|
||||
|
||||
// If images are not similar, allow the new image
|
||||
return imagePath2;
|
||||
ImageQualityMetrics metrics2;
|
||||
using (var img2 = Image.Load<Rgba32>(imagePath2))
|
||||
{
|
||||
metrics2 = GetImageQualityMetrics(img2);
|
||||
}
|
||||
|
||||
|
||||
// If one is color, and one is grayscale, then we prefer color
|
||||
if (preferColor && metrics1.IsColor != metrics2.IsColor)
|
||||
{
|
||||
return metrics1.IsColor ? imagePath1 : 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
|
||||
/// </summary>
|
||||
/// <param name="imagePath1"></param>
|
||||
/// <param name="imagePath2"></param>
|
||||
/// <returns></returns>
|
||||
private static float CalculateSimilarity(string imagePath1, string imagePath2)
|
||||
private static ImageQualityMetrics GetImageQualityMetrics(Image<Rgba32> image)
|
||||
{
|
||||
if (!File.Exists(imagePath1) || !File.Exists(imagePath2))
|
||||
// Create a smaller version if the 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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,6 +79,12 @@ public static class IncludesExtensions
|
|||
.Include(c => c.ExternalReviews);
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ChapterIncludes.ExternalRatings))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(c => c.ExternalRatings);
|
||||
}
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
|
|
|
@ -20,9 +20,11 @@ using Microsoft.Extensions.Configuration;
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetVips;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Sinks.AspNetCore.SignalR.Extensions;
|
||||
using Log = Serilog.Log;
|
||||
|
||||
namespace API;
|
||||
#nullable enable
|
||||
|
@ -143,6 +145,8 @@ public class Program
|
|||
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
LogLevelOptions.SwitchLogLevel(settings.LoggingLevel);
|
||||
|
||||
InitNetVips();
|
||||
|
||||
await host.RunAsync();
|
||||
} catch (Exception ex)
|
||||
{
|
||||
|
@ -225,4 +229,14 @@ public class Program
|
|||
|
||||
webBuilder.UseStartup<Startup>();
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Ensure NetVips does not cache
|
||||
/// </summary>
|
||||
/// <remarks>https://github.com/kleisauke/net-vips/issues/6#issuecomment-394379299</remarks>
|
||||
private static void InitNetVips()
|
||||
{
|
||||
Cache.MaxFiles = 0;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
@ -1090,4 +1112,23 @@ public class DirectoryService : IDirectoryService
|
|||
FlattenDirectory(root, subDirectory, ref directoryIndex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the file is locked or not existing
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <returns></returns>
|
||||
public static bool IsFileLocked(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath)) return false;
|
||||
using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None);
|
||||
return false; // If this works, the file is not locked
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true; // File is locked by another process
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1085,7 +1085,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification;
|
||||
|
||||
madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification;
|
||||
madeModification = UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
|
||||
madeModification = await UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
|
||||
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
@ -1094,7 +1094,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
return madeModification;
|
||||
}
|
||||
|
||||
private bool UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
|
||||
private async Task<bool> UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
|
||||
{
|
||||
if (!settings.Enabled) return false;
|
||||
|
||||
|
@ -1106,7 +1106,12 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var madeModification = false;
|
||||
|
||||
#region Review
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(chapter.ExternalReviews);
|
||||
|
||||
// Remove existing Reviews
|
||||
var existingReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReview(chapter.Id);
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(existingReviews);
|
||||
|
||||
|
||||
List<ExternalReview> externalReviews = [];
|
||||
externalReviews.AddRange(metadata.CriticReviews
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
|
||||
|
@ -1139,7 +1144,9 @@ public class ExternalMetadataService : IExternalMetadataService
|
|||
var averageCriticRating = metadata.CriticReviews.Average(r => r.Rating);
|
||||
var averageUserRating = metadata.UserReviews.Average(r => r.Rating);
|
||||
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(chapter.ExternalRatings);
|
||||
var existingRatings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapter.Id);
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(existingRatings);
|
||||
|
||||
chapter.ExternalRatings =
|
||||
[
|
||||
new ExternalRating
|
||||
|
|
|
@ -450,7 +450,7 @@ public class SeriesService : ISeriesService
|
|||
try
|
||||
{
|
||||
var chapterMappings =
|
||||
await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(seriesIds.ToArray());
|
||||
await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync([.. seriesIds]);
|
||||
|
||||
var allChapterIds = new List<int>();
|
||||
foreach (var mapping in chapterMappings)
|
||||
|
@ -458,9 +458,8 @@ public class SeriesService : ISeriesService
|
|||
allChapterIds.AddRange(mapping.Value);
|
||||
}
|
||||
|
||||
// NOTE: This isn't getting all the people and whatnot currently
|
||||
// NOTE: This isn't getting all the people and whatnot currently due to the lack of includes
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds);
|
||||
|
||||
_unitOfWork.SeriesRepository.Remove(series);
|
||||
|
||||
var libraryIds = series.Select(s => s.LibraryId);
|
||||
|
@ -481,7 +480,8 @@ public class SeriesService : ISeriesService
|
|||
|
||||
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
|
||||
await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries();
|
||||
_taskScheduler.CleanupChapters(allChapterIds.ToArray());
|
||||
_taskScheduler.CleanupChapters([.. allChapterIds]);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
@ -81,6 +81,22 @@ public class CoverDbService : ICoverDbService
|
|||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the favicon image from a given website URL, optionally falling back to a custom method if standard methods fail.
|
||||
/// </summary>
|
||||
/// <param name="url">The full URL of the website to extract the favicon from.</param>
|
||||
/// <param name="encodeFormat">The desired image encoding format for saving the favicon (e.g., WebP, PNG).</param>
|
||||
/// <returns>
|
||||
/// A string representing the filename of the downloaded favicon image, saved to the configured favicon directory.
|
||||
/// </returns>
|
||||
/// <exception cref="KavitaException">
|
||||
/// Thrown when favicon retrieval fails or if a previously failed domain is detected in cache.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// This method first checks for a cached failure to avoid re-requesting bad links.
|
||||
/// It then attempts to parse HTML for `link` tags pointing to `.png` favicons and
|
||||
/// falls back to an internal fallback method if needed. Valid results are saved to disk.
|
||||
/// </remarks>
|
||||
public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
|
||||
{
|
||||
// Parse the URL to get the domain (including subdomain)
|
||||
|
@ -157,23 +173,10 @@ public class CoverDbService : ICoverDbService
|
|||
// 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);
|
||||
}
|
||||
|
||||
|
||||
image.WriteToFile(Path.Combine(_directoryService.FaviconDirectory, filename));
|
||||
_logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain);
|
||||
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
|
@ -212,23 +215,10 @@ public class CoverDbService : ICoverDbService
|
|||
// Create the destination file path
|
||||
using var image = Image.NewFromStream(publisherStream);
|
||||
var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
image.WriteToFile(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize());
|
||||
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
|
@ -294,40 +284,30 @@ 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);
|
||||
|
||||
// Ensure if file exists, we delete to overwrite
|
||||
var targetFile = Path.Combine(targetDirectory, filename);
|
||||
|
||||
_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);
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
private async Task<string> GetCoverPersonImagePath(Person person)
|
||||
private async Task<string?> GetCoverPersonImagePath(Person person)
|
||||
{
|
||||
var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml");
|
||||
|
||||
|
@ -384,25 +364,22 @@ public class CoverDbService : ICoverDbService
|
|||
await CacheDataAsync(urlsFileName, allOverrides);
|
||||
|
||||
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
if (string.IsNullOrEmpty(allOverrides)) return correctSizeLink;
|
||||
|
||||
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))
|
||||
{
|
||||
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.Sanitize()}");
|
||||
}
|
||||
|
||||
correctSizeLink = $"{NewHost}favicons/" + externalFile;
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}");
|
||||
}
|
||||
|
||||
return correctSizeLink;
|
||||
return $"{NewHost}favicons/{externalFile}";
|
||||
}
|
||||
|
||||
private async Task<string> FallbackToKavitaReaderPublisher(string publisherName)
|
||||
|
@ -415,34 +392,30 @@ public class CoverDbService : ICoverDbService
|
|||
// Cache immediately
|
||||
await CacheDataAsync(publisherFileName, allOverrides);
|
||||
|
||||
if (string.IsNullOrEmpty(allOverrides)) return externalLink;
|
||||
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
externalLink = $"{NewHost}publishers/" + externalFile;
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
return $"{NewHost}publishers/{externalLink}";
|
||||
}
|
||||
|
||||
private async Task CacheDataAsync(string fileName, string? content)
|
||||
|
@ -485,33 +458,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 (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);
|
||||
|
@ -544,31 +551,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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
@ -577,10 +605,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);
|
||||
}
|
||||
|
@ -597,26 +622,52 @@ 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)
|
||||
{
|
||||
// This will fail if Cover gen is done just before this as there is a bug with files getting locked.
|
||||
_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);
|
||||
|
@ -633,13 +684,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="filenameWithoutExtension">Filename without extension</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 filenameWithoutExtension, bool fromBase64 = true, string? targetDirectory = null)
|
||||
{
|
||||
targetDirectory ??= _directoryService.CoverImageDirectory;
|
||||
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var encodeFormat = settings.EncodeMediaAs;
|
||||
var coverImageSize = settings.CoverImageSize;
|
||||
|
@ -647,9 +711,9 @@ public class CoverDbService : ICoverDbService
|
|||
if (fromBase64)
|
||||
{
|
||||
return _imageService.CreateThumbnailFromBase64(url,
|
||||
filename, encodeFormat, coverImageSize.GetDimensions().Width);
|
||||
filenameWithoutExtension, encodeFormat, coverImageSize.GetDimensions().Width);
|
||||
}
|
||||
|
||||
return await DownloadImageFromUrl(filename, encodeFormat, url);
|
||||
return await DownloadImageFromUrl(filenameWithoutExtension, encodeFormat, url, targetDirectory);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -194,8 +194,8 @@ public class ProcessSeries : IProcessSeries
|
|||
if (seriesAdded)
|
||||
{
|
||||
// See if any recommendations can link up to the series and pre-fetch external metadata for the series
|
||||
BackgroundJob.Enqueue(() =>
|
||||
_externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type));
|
||||
// BackgroundJob.Enqueue(() =>
|
||||
// _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type));
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded,
|
||||
MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false);
|
||||
|
@ -214,6 +214,10 @@ public class ProcessSeries : IProcessSeries
|
|||
return;
|
||||
}
|
||||
|
||||
if (seriesAdded)
|
||||
{
|
||||
await _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type);
|
||||
}
|
||||
await _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false);
|
||||
await _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue