Better OPDS Reading Lists & Cover Generation for Webtoons (#3017)

Co-authored-by: Zackaree <github@zackaree.com>
This commit is contained in:
Joe Milazzo 2024-06-24 20:01:50 -05:00 committed by GitHub
parent 2fb72ab0d4
commit a063333f80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 329 additions and 50 deletions

5
.gitignore vendored
View File

@ -534,3 +534,8 @@ UI/Web/.vscode/settings.json
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*
UI/Web/.angular/
BenchmarkDotNet.Artifacts
API.Tests/Services/Test Data/ImageService/Covers/*_output*
API.Tests/Services/Test Data/ImageService/Covers/*_baseline*
API.Tests/Services/Test Data/ImageService/Covers/index.html

View File

@ -9,8 +9,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.22" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.22" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -28,6 +28,7 @@
<ItemGroup>
<Folder Include="Services\Test Data\ArchiveService\ComicInfos" />
<Folder Include="Services\Test Data\ImageService\Covers\" />
<Folder Include="Services\Test Data\ScannerService\Manga" />
</ItemGroup>

View File

@ -0,0 +1,124 @@
using System.IO;
using System.Linq;
using System.Text;
using API.Entities.Enums;
using API.Services;
using NetVips;
using Xunit;
using Image = NetVips.Image;
namespace API.Tests.Services;
public class ImageServiceTests
{
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/Covers");
private const string OutputPattern = "_output";
private const string BaselinePattern = "_baseline";
/// <summary>
/// Run this once to get the baseline generation
/// </summary>
[Fact]
public void GenerateBaseline()
{
GenerateFiles(BaselinePattern);
}
/// <summary>
/// Change the Scaling/Crop code then run this continuously
/// </summary>
[Fact]
public void TestScaling()
{
GenerateFiles(OutputPattern);
GenerateHtmlFile();
}
private void GenerateFiles(string outputExtension)
{
// Step 1: Delete any images that have _output in the name
var outputFiles = Directory.GetFiles(_testDirectory, "*_output.*");
foreach (var file in outputFiles)
{
File.Delete(file);
}
// Step 2: Scan the _testDirectory for images
var imageFiles = Directory.GetFiles(_testDirectory, "*.*")
.Where(file => !file.EndsWith("html"))
.Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern))
.ToList();
// Step 3: Process each image
foreach (var imagePath in imageFiles)
{
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var dims = CoverImageSize.Default.GetDimensions();
using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered);
var size = ImageService.GetSizeForDimensions(sourceImage, dims.Width, dims.Height);
var crop = ImageService.GetCropForDimensions(sourceImage, dims.Width, dims.Height);
using var thumbnail = Image.Thumbnail(imagePath, dims.Width, dims.Height,
size: size,
crop: crop);
var outputFileName = fileName + outputExtension + ".png";
thumbnail.WriteToFile(Path.Join(_testDirectory, outputFileName));
}
}
private void GenerateHtmlFile()
{
var imageFiles = Directory.GetFiles(_testDirectory, "*.*")
.Where(file => !file.EndsWith("html"))
.Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern))
.ToList();
var htmlBuilder = new StringBuilder();
htmlBuilder.AppendLine("<!DOCTYPE html>");
htmlBuilder.AppendLine("<html lang=\"en\">");
htmlBuilder.AppendLine("<head>");
htmlBuilder.AppendLine("<meta charset=\"UTF-8\">");
htmlBuilder.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
htmlBuilder.AppendLine("<title>Image Comparison</title>");
htmlBuilder.AppendLine("<style>");
htmlBuilder.AppendLine("body { font-family: Arial, sans-serif; }");
htmlBuilder.AppendLine(".container { display: flex; flex-wrap: wrap; }");
htmlBuilder.AppendLine(".image-row { display: flex; align-items: center; margin-bottom: 20px; width: 100% }");
htmlBuilder.AppendLine(".image-row img { margin-right: 10px; max-width: 200px; height: auto; }");
htmlBuilder.AppendLine("</style>");
htmlBuilder.AppendLine("</head>");
htmlBuilder.AppendLine("<body>");
htmlBuilder.AppendLine("<div class=\"container\">");
foreach (var imagePath in imageFiles)
{
var fileName = Path.GetFileNameWithoutExtension(imagePath);
var baselinePath = Path.Combine(_testDirectory, fileName + "_baseline.png");
var outputPath = Path.Combine(_testDirectory, fileName + "_output.png");
var dims = CoverImageSize.Default.GetDimensions();
using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered);
htmlBuilder.AppendLine("<div class=\"image-row\">");
htmlBuilder.AppendLine($"<p>{fileName} ({((double) sourceImage.Width / sourceImage.Height).ToString("F2")}) - {ImageService.WillScaleWell(sourceImage, dims.Width, dims.Height)}</p>");
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(imagePath)}\" alt=\"{fileName}\">");
if (File.Exists(baselinePath))
{
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(baselinePath)}\" alt=\"{fileName} baseline\">");
}
if (File.Exists(outputPath))
{
htmlBuilder.AppendLine($"<img src=\"./{Path.GetFileName(outputPath)}\" alt=\"{fileName} output\">");
}
htmlBuilder.AppendLine("</div>");
}
htmlBuilder.AppendLine("</div>");
htmlBuilder.AppendLine("</body>");
htmlBuilder.AppendLine("</html>");
File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString());
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

View File

@ -53,7 +53,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.6.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
@ -65,33 +65,33 @@
<PackageReference Include="ExCSS" Version="4.2.5" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.12" />
<PackageReference Include="Hangfire" Version="1.8.14" />
<PackageReference Include="Hangfire.InMemory" Version="0.10.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.61" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.12" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.4.1" />
<PackageReference Include="NetVips.Native" Version="8.15.2" />
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
@ -101,8 +101,8 @@
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.0" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.6.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.22" />
<PackageReference Include="System.Drawing.Common" Version="8.0.6" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
</ItemGroup>

View File

@ -590,11 +590,24 @@ public class OpdsController : BaseApiController
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
foreach (var item in items)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
if (chapterDto != null && chapterDto.Files.Count == 1)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
}
else
{
feed.Entries.Add(
CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
}
}
return CreateXmlResult(SerializeXml(feed));
}
@ -914,15 +927,15 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
// var chapters =
// (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
// .OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
foreach (var chapter in chapters)
foreach (var chapter in volume.Chapters)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People);
foreach (var mangaFile in chapterDto.Files)
@ -1111,7 +1124,8 @@ public class OpdsController : BaseApiController
};
}
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
private async Task<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
{
var fileSize =
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
@ -1120,7 +1134,7 @@ public class OpdsController : BaseApiController
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath));
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
var title = $"{series.Name}";

View File

@ -13,7 +13,7 @@ public class UpdateNotificationDto
/// Semver of the release version
/// <example>0.4.3</example>
/// </summary>
public required string UpdateVersion { get; init; }
public required string UpdateVersion { get; set; }
/// <summary>
/// Release body in HTML
/// </summary>

View File

@ -21,10 +21,11 @@ public static class MigrateEmailTemplates
var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory);
if (files.Any())
{
logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
return;
}
logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
// Write files to directory
await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger);
await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger);
@ -33,8 +34,7 @@ public static class MigrateEmailTemplates
await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger);
logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error");
logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error");
}
private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger<Program> logger)

View File

@ -23,6 +23,10 @@ public enum VolumeIncludes
Chapters = 2,
People = 4,
Tags = 8,
/// <summary>
/// This will include Chapters by default
/// </summary>
Files = 16
}
public interface IVolumeRepository
@ -34,7 +38,7 @@ public interface IVolumeRepository
Task<string?> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
Task<IList<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters);
Task<Volume?> GetVolumeAsync(int volumeId);
Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files);
Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
@ -173,11 +177,10 @@ public class VolumeRepository : IVolumeRepository
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
public async Task<Volume?> GetVolumeAsync(int volumeId)
public async Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files)
{
return await _context.Volume
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.Includes(includes)
.AsSplitQuery()
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
}

View File

@ -65,22 +65,28 @@ public static class IncludesExtensions
public static IQueryable<Volume> Includes(this IQueryable<Volume> queryable,
VolumeIncludes includes)
{
if (includes.HasFlag(VolumeIncludes.Chapters))
if (includes.HasFlag(VolumeIncludes.Files))
{
queryable = queryable.Include(vol => vol.Chapters);
queryable = queryable
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.Files);
} else if (includes.HasFlag(VolumeIncludes.Chapters))
{
queryable = queryable
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder));
}
if (includes.HasFlag(VolumeIncludes.People))
{
queryable = queryable
.Include(vol => vol.Chapters)
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.People);
}
if (includes.HasFlag(VolumeIncludes.Tags))
{
queryable = queryable
.Include(vol => vol.Chapters)
.Include(vol => vol.Chapters.OrderBy(c => c.SortOrder))
.ThenInclude(c => c.Tags);
}
@ -104,7 +110,7 @@ public static class IncludesExtensions
{
query = query
.Include(s => s.Volumes)
.ThenInclude(v => v.Chapters);
.ThenInclude(v => v.Chapters.OrderBy(c => c.SortOrder));
}
if (includeFlags.HasFlag(SeriesIncludes.Related))

View File

@ -125,14 +125,77 @@ public class ImageService : IImageService
}
}
/// <summary>
/// Tries to determine if there is a better mode for resizing
/// </summary>
/// <param name="image"></param>
/// <param name="targetWidth"></param>
/// <param name="targetHeight"></param>
/// <returns></returns>
public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight)
{
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return Enums.Size.Force;
}
return Enums.Size.Both;
}
public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight)
{
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return null;
}
return Enums.Interesting.Attention;
}
public static bool WillScaleWell(Image sourceImage, int targetWidth, int targetHeight, double tolerance = 0.1)
{
// Calculate the aspect ratios
var sourceAspectRatio = (double) sourceImage.Width / sourceImage.Height;
var targetAspectRatio = (double) targetWidth / targetHeight;
// Compare aspect ratios
if (Math.Abs(sourceAspectRatio - targetAspectRatio) > tolerance)
{
return false; // Aspect ratios differ significantly
}
// Calculate scaling factors
var widthScaleFactor = (double)targetWidth / sourceImage.Width;
var heightScaleFactor = (double)targetHeight / sourceImage.Height;
// Check resolution quality (example thresholds)
if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0)
{
return false; // Scaling factor too large
}
return true; // Image will scale well
}
private static bool IsLikelyWideImage(int width, int height)
{
var aspectRatio = (double) width / height;
return aspectRatio > 1.25;
}
public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
{
if (string.IsNullOrEmpty(path)) return string.Empty;
try
{
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
using var sourceImage = Image.NewFromFile(path, false, Enums.Access.SequentialUnbuffered);
using var thumbnail = Image.Thumbnail(path, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename;
@ -156,8 +219,14 @@ public class ImageService : IImageService
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
var dims = size.GetDimensions();
using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
stream.Position = 0;
using var sourceImage = Image.NewFromStream(stream);
stream.Position = 0;
using var thumbnail = Image.ThumbnailStream(stream, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
@ -170,8 +239,12 @@ public class ImageService : IImageService
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{
var dims = size.GetDimensions();
using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force);
var (width, height) = size.GetDimensions();
using var sourceImage = Image.NewFromFile(sourceFile, false, Enums.Access.SequentialUnbuffered);
using var thumbnail = Image.Thumbnail(sourceFile, width, height: height,
size: GetSizeForDimensions(sourceImage, width, height),
crop: GetCropForDimensions(sourceImage, width, height));
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
@ -426,7 +499,7 @@ public class ImageService : IImageService
public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
{
var dims = size.GetDimensions();
var (width, height) = size.GetDimensions();
int rows, cols;
if (coverImages.Count == 1)
@ -446,7 +519,7 @@ public class ImageService : IImageService
}
var image = Image.Black(dims.Width, dims.Height);
var image = Image.Black(width, height);
var thumbnailWidth = image.Width / cols;
var thumbnailHeight = image.Height / rows;

View File

@ -77,7 +77,7 @@ public class TaskScheduler : ITaskScheduler
public const string KavitaPlusDataRefreshId = "kavita+-data-refresh";
public const string KavitaPlusStackSyncId = "kavita+-stack-sync";
private static readonly ImmutableArray<string> ScanTasks =
public static readonly ImmutableArray<string> ScanTasks =
["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"];
private static readonly Random Rnd = new Random();
@ -123,7 +123,7 @@ public class TaskScheduler : ITaskScheduler
{
var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false),
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
}
else
@ -345,6 +345,9 @@ public class TaskScheduler : ITaskScheduler
return;
}
// await _eventHub.SendMessageAsync(MessageFactory.Info,
// MessageFactory.InfoEvent($"Scan library invoked but a task is already running for {library.Name}. Rescheduling request for 10 mins", string.Empty));
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
// When we do a scan, force cache to re-unpack in case page numbers change
@ -463,6 +466,7 @@ public class TaskScheduler : ITaskScheduler
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs);
}
/// <summary>
/// Checks if this same invocation is already enqueued or scheduled
/// </summary>
@ -471,6 +475,7 @@ public class TaskScheduler : ITaskScheduler
/// <param name="args">object[] of arguments in the order they are passed to enqueued job</param>
/// <param name="queue">Queue to check against. Defaults to "default"</param>
/// <param name="checkRunningJobs">Check against running jobs. Defaults to false.</param>
/// <param name="checkArgs">Check against arguments. Defaults to true.</param>
/// <returns></returns>
public static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = DefaultQueue, bool checkRunningJobs = false)
{

View File

@ -76,6 +76,7 @@ public enum ScanCancelReason
public class ScannerService : IScannerService
{
public const string Name = "ScannerService";
public const int Timeout = 60 * 60 * 60;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ScannerService> _logger;
private readonly IMetadataService _metadataService;
@ -168,6 +169,7 @@ public class ScannerService : IScannerService
return;
}
// This is basically rework of what's already done in Library Watcher but is needed if invoked via API
var parentDirectory = _directoryService.GetParentDirectoryName(folder);
if (string.IsNullOrEmpty(parentDirectory)) return;
@ -202,6 +204,14 @@ public class ScannerService : IScannerService
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update
// if (TaskScheduler.HasScanTaskRunningForSeries(seriesId))
// {
// _logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Rescheduling request for 1 mins");
// BackgroundJob.Schedule(() => ScanSeries(seriesId, bypassFolderOptimizationChecks), TimeSpan.FromMinutes(1));
// return;
// }
var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
@ -465,13 +475,22 @@ public class ScannerService : IScannerService
}
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries(bool forceUpdate = false)
{
_logger.LogInformation("Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync())
{
if (TaskScheduler.RunningAnyTasksByMethod(TaskScheduler.ScanTasks, TaskScheduler.ScanQueue))
{
_logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running. Rescheduling for 4 hours");
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed",
$"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for {DateTime.UtcNow.AddHours(4)} UTC"));
BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4));
return;
}
await ScanLibrary(lib.Id, forceUpdate, true);
}
_processSeries.Reset();
@ -488,13 +507,14 @@ public class ScannerService : IScannerService
/// <param name="forceUpdate">Defaults to false</param>
/// <param name="isSingleScan">Defaults to true. Is this a standalone invocation or is it in a loop?</param>
[Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(60 * 60 * 60)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSingleScan = true)
{
var sw = Stopwatch.StartNew();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId,
LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
var libraryFolderPaths = library!.Folders.Select(fp => fp.Path).ToList();
if (!await CheckMounts(library.Name, libraryFolderPaths)) return;

View File

@ -91,12 +91,39 @@ public class VersionUpdaterService : IVersionUpdaterService
// Find the latest dto
var latestRelease = updateDtos[0]!;
var updateVersion = new Version(latestRelease.UpdateVersion);
var isNightly = BuildInfo.Version > new Version(latestRelease.UpdateVersion);
// isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0
if (IsVersionEqualToBuildVersion(updateVersion))
{
//latestRelease.UpdateVersion = BuildInfo.Version.ToString();
isNightly = false;
}
latestRelease.IsOnNightlyInRelease = isNightly;
return updateDtos;
}
private static bool IsVersionEqualToBuildVersion(Version updateVersion)
{
return updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 &&
CompareWithoutRevision(BuildInfo.Version, updateVersion);
}
private static bool CompareWithoutRevision(Version v1, Version v2)
{
if (v1.Major != v2.Major)
return v1.Major == v2.Major;
if (v1.Minor != v2.Minor)
return v1.Minor == v2.Minor;
if (v1.Build != v2.Build)
return v1.Build == v2.Build;
return true;
}
public async Task<int> GetNumberOfReleasesBehind()
{
var updates = await GetAllReleases();
@ -109,6 +136,7 @@ public class VersionUpdaterService : IVersionUpdaterService
var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty));
var currentVersion = BuildInfo.Version.ToString(4);
return new UpdateNotificationDto()
{
CurrentVersion = currentVersion,
@ -118,7 +146,7 @@ public class VersionUpdaterService : IVersionUpdaterService
UpdateUrl = update.Html_Url,
IsDocker = OsInfo.IsDocker,
PublishDate = update.Published_At,
IsReleaseEqual = BuildInfo.Version == updateVersion,
IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion),
IsReleaseNewer = BuildInfo.Version < updateVersion,
};
}

View File

@ -57,7 +57,7 @@
<div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}">
<div id="toolbarViewerLeft">
<pdf-toggle-sidebar></pdf-toggle-sidebar>
<pdf-find-button></pdf-find-button>
<pdf-find-button [textLayer]='true'></pdf-find-button>
<pdf-paging-area></pdf-paging-area>
@if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) {