diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index 5c8fad2e7..22a52c04a 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -119,6 +119,11 @@ public async Task> UpdatePerson(UpdatePersonDto dto) return Ok(_mapper.Map(person)); } + /// + /// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita) + /// + /// + /// [HttpPost("fetch-cover")] public async Task> DownloadCoverImage([FromQuery] int personId) { @@ -129,13 +134,13 @@ public async Task> DownloadCoverImage([FromQuery] int perso var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs); if (string.IsNullOrEmpty(personImage)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist")); + person.CoverImage = personImage; _imageService.UpdateColorScape(person); _unitOfWork.PersonRepository.Update(person); await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false); - return Ok(personImage); } @@ -150,6 +155,12 @@ public async Task>> GetKnownSeries(int perso return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); } + /// + /// Returns all individual chapters by role. Limited to 20 results. + /// + /// + /// + /// [HttpGet("chapters-by-role")] public async Task>> GetChaptersByRole(int personId, PersonRole role) { diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index f27cd251d..f6413ff6f 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -56,7 +56,7 @@ public bool IsLooseLeaf() } /// - /// Does this volume hold only specials? + /// Does this volume hold only specials /// /// public bool IsSpecial() diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index f826b67f7..d0183ae66 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1192,7 +1192,7 @@ private static IQueryable ApplyLibraryFilter(FilterV2Dto filter, IQuerya private static IQueryable BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable query) { - if (filterDto.Statements == null || !filterDto.Statements.Any()) return query; + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; var queries = filterDto.Statements diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index ff9d3ec03..90c691df2 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -167,6 +167,7 @@ public static IQueryable HasAgeRating(this IQueryable queryable, throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } } + public static IQueryable HasAverageReadTime(this IQueryable queryable, bool condition, FilterComparison comparison, int avgReadTime) { @@ -175,17 +176,17 @@ public static IQueryable HasAverageReadTime(this IQueryable quer switch (comparison) { case FilterComparison.NotEqual: - return queryable.Where(s => s.AvgHoursToRead != avgReadTime); + return queryable.WhereNotEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.Equal: - return queryable.Where(s => s.AvgHoursToRead == avgReadTime); + return queryable.WhereEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.GreaterThan: - return queryable.Where(s => s.AvgHoursToRead > avgReadTime); + return queryable.WhereGreaterThan(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.GreaterThanEqual: - return queryable.Where(s => s.AvgHoursToRead >= avgReadTime); + return queryable.WhereGreaterThanOrEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.LessThan: - return queryable.Where(s => s.AvgHoursToRead < avgReadTime); + return queryable.WhereLessThan(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.LessThanEqual: - return queryable.Where(s => s.AvgHoursToRead <= avgReadTime); + return queryable.WhereLessThanOrEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.Contains: case FilterComparison.Matches: case FilterComparison.NotContains: @@ -257,29 +258,29 @@ public static IQueryable HasReadingProgress(this IQueryable quer Series = s, Percentage = s.Progress .Where(p => p != null && p.AppUserId == userId) - .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100 + .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f }) .AsSplitQuery(); switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance); + subQuery = subQuery.WhereEqual(s => s.Percentage, readProgress); break; case FilterComparison.GreaterThan: - subQuery = subQuery.Where(s => s.Percentage > readProgress); + subQuery = subQuery.WhereGreaterThan(s => s.Percentage, readProgress); break; case FilterComparison.GreaterThanEqual: - subQuery = subQuery.Where(s => s.Percentage >= readProgress); + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.Percentage, readProgress); break; case FilterComparison.LessThan: - subQuery = subQuery.Where(s => s.Percentage < readProgress); + subQuery = subQuery.WhereLessThan(s => s.Percentage, readProgress); break; case FilterComparison.LessThanEqual: - subQuery = subQuery.Where(s => s.Percentage <= readProgress); + subQuery = subQuery.WhereLessThanOrEqual(s => s.Percentage, readProgress); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance); + subQuery = subQuery.WhereNotEqual(s => s.Percentage, readProgress); break; case FilterComparison.IsEmpty: case FilterComparison.Matches: @@ -306,7 +307,6 @@ public static IQueryable HasAverageRating(this IQueryable querya { if (!condition) return queryable; - var subQuery = queryable .Where(s => s.ExternalSeriesMetadata != null) .Include(s => s.ExternalSeriesMetadata) @@ -316,27 +316,27 @@ public static IQueryable HasAverageRating(this IQueryable querya AverageRating = s.ExternalSeriesMetadata.AverageExternalRating }) .AsSplitQuery() - .AsEnumerable(); + .AsQueryable(); switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) < FloatingPointTolerance); + subQuery = subQuery.WhereEqual(s => s.AverageRating, rating); break; case FilterComparison.GreaterThan: - subQuery = subQuery.Where(s => s.AverageRating > rating); + subQuery = subQuery.WhereGreaterThan(s => s.AverageRating, rating); break; case FilterComparison.GreaterThanEqual: - subQuery = subQuery.Where(s => s.AverageRating >= rating); + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.AverageRating, rating); break; case FilterComparison.LessThan: - subQuery = subQuery.Where(s => s.AverageRating < rating); + subQuery = subQuery.WhereLessThan(s => s.AverageRating, rating); break; case FilterComparison.LessThanEqual: - subQuery = subQuery.Where(s => s.AverageRating <= rating); + subQuery = subQuery.WhereLessThanOrEqual(s => s.AverageRating, rating); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) > FloatingPointTolerance); + subQuery = subQuery.WhereNotEqual(s => s.AverageRating, rating); break; case FilterComparison.Matches: case FilterComparison.Contains: @@ -534,21 +534,21 @@ public static IQueryable HasPeople(this IQueryable queryable, bo { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId))); + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId) && p.Role == role)); case FilterComparison.NotEqual: case FilterComparison.NotContains: - return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.PersonId))); + return queryable.Where(s => s.Metadata.People.All(p => !people.Contains(p.PersonId) || p.Role != role)); case FilterComparison.MustContains: - // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == gId)))); + queries.AddRange(people.Select(personId => + queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == personId && p.Role == role)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: - // Check if there are no people with specific roles (e.g., Writer, Penciller, etc.) + // Ensure no person with the given role exists return queryable.Where(s => s.Metadata.People.All(p => p.Role != role)); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: diff --git a/API/Extensions/QueryExtensions/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 627111b89..c4fad72ab 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -16,6 +16,8 @@ namespace API.Extensions.QueryExtensions; public static class QueryableExtensions { + private const float DefaultTolerance = 0.001f; + public static Task GetUserAgeRestriction(this DbSet queryable, int userId) { if (userId < 1) @@ -125,6 +127,140 @@ public static IQueryable WhereLike(this IQueryable queryable, bool cond return queryable.Where(lambda); } + public static IQueryable WhereGreaterThan(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + // Absolute difference comparison: (propertyAccess - value) > tolerance + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var greaterThanExpression = Expression.GreaterThan(propertyAccess, Expression.Constant(value)); + var toleranceExpression = Expression.GreaterThan(absoluteDifference, Expression.Constant(tolerance)); + var combinedExpression = Expression.AndAlso(greaterThanExpression, toleranceExpression); + + var lambda = Expression.Lambda>(combinedExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereGreaterThanOrEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var greaterThanOrEqualExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value)); + var toleranceExpression = Expression.GreaterThanOrEqual(absoluteDifference, Expression.Constant(tolerance)); + var combinedExpression = Expression.AndAlso(greaterThanOrEqualExpression, toleranceExpression); + + var lambda = Expression.Lambda>(combinedExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereLessThan(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var lessThanExpression = Expression.LessThan(propertyAccess, Expression.Constant(value)); + var toleranceExpression = Expression.LessThan(absoluteDifference, Expression.Constant(tolerance)); + var combinedExpression = Expression.AndAlso(lessThanExpression, toleranceExpression); + + var lambda = Expression.Lambda>(combinedExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereLessThanOrEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var lessThanOrEqualExpression = Expression.LessThanOrEqual(propertyAccess, Expression.Constant(value)); + var toleranceExpression = Expression.LessThanOrEqual(absoluteDifference, Expression.Constant(tolerance)); + var combinedExpression = Expression.AndAlso(lessThanOrEqualExpression, toleranceExpression); + + var lambda = Expression.Lambda>(combinedExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + // Absolute difference comparison: Math.Abs(propertyAccess - value) < tolerance + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var toleranceExpression = Expression.LessThan(absoluteDifference, Expression.Constant(tolerance)); + var lambda = Expression.Lambda>(toleranceExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereNotEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var toleranceExpression = Expression.GreaterThan(absoluteDifference, Expression.Constant(tolerance)); + var lambda = Expression.Lambda>(toleranceExpression, parameter); + + return source.Where(lambda); + } + /// /// Performs a WhereLike that ORs multiple fields /// diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 43d252c3b..a36c8268a 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -213,9 +213,8 @@ await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, return; } - BackgroundJob.Enqueue(() => - _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false)); - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate)); + await _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false); + await _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate); } private async Task ReportDuplicateSeriesLookup(Library library, ParserInfo firstInfo, Exception ex) diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index bd61901b5..28237dbc0 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -464,6 +464,7 @@ "version": "18.2.9", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.2.9.tgz", "integrity": "sha512-4iMoRvyMmq/fdI/4Gob9HKjL/jvTlCjbS4kouAYHuGO9w9dmUhi1pY1z+mALtCEl9/Q8CzU2W8e5cU2xtV4nVg==", + "dev": true, "dependencies": { "@babel/core": "7.25.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -491,6 +492,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -505,6 +507,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, "engines": { "node": ">= 14.16.0" }, @@ -4007,7 +4010,8 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true }, "node_modules/cosmiconfig": { "version": "8.3.6", @@ -4514,6 +4518,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -4523,6 +4528,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -7464,7 +7470,8 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true }, "node_modules/replace-in-file": { "version": "7.1.0", @@ -7735,7 +7742,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true + "dev": true }, "node_modules/sass": { "version": "1.77.6", @@ -7769,6 +7776,7 @@ "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -8323,6 +8331,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"