diff --git a/.gitignore b/.gitignore index 584f0026e..612917a47 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 1651d35d0..e65229ab5 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -28,6 +28,7 @@ + diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs new file mode 100644 index 000000000..c868bfce2 --- /dev/null +++ b/API.Tests/Services/ImageServiceTests.cs @@ -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"; + + /// + /// Run this once to get the baseline generation + /// + [Fact] + public void GenerateBaseline() + { + GenerateFiles(BaselinePattern); + } + + /// + /// Change the Scaling/Crop code then run this continuously + /// + [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(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("Image Comparison"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("
"); + + 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("
"); + htmlBuilder.AppendLine($"

{fileName} ({((double) sourceImage.Width / sourceImage.Height).ToString("F2")}) - {ImageService.WillScaleWell(sourceImage, dims.Width, dims.Height)}

"); + htmlBuilder.AppendLine($"\"{fileName}\""); + if (File.Exists(baselinePath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + if (File.Exists(outputPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + htmlBuilder.AppendLine("
"); + } + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + + File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString()); + } + +} diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg new file mode 100644 index 000000000..b185d6e41 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg new file mode 100644 index 000000000..99aafb10a Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg new file mode 100644 index 000000000..91a8f9b8e Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg new file mode 100644 index 000000000..6ee3931b3 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg new file mode 100644 index 000000000..3442c8b32 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png b/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png new file mode 100644 index 000000000..eae5138c6 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg b/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg new file mode 100644 index 000000000..449400181 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png new file mode 100644 index 000000000..e89641384 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg new file mode 100644 index 000000000..469cb9bc3 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png b/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png new file mode 100644 index 000000000..2ad5103fe Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png differ diff --git a/API/API.csproj b/API/API.csproj index 2f97240df..f2b82968b 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -53,7 +53,7 @@ - + all @@ -65,33 +65,33 @@ - + - + - + - + - + - + - - + + @@ -101,8 +101,8 @@ - - + + diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 354cae2d4..3d29af5f6 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -591,9 +591,22 @@ public class OpdsController : BaseApiController var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); foreach (var item in items) { - feed.Entries.Add( - CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", - item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl)); + 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 CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) + private async Task 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}"; diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 63e3e8088..a83aa072b 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -13,7 +13,7 @@ public class UpdateNotificationDto /// Semver of the release version /// 0.4.3 /// - public required string UpdateVersion { get; init; } + public required string UpdateVersion { get; set; } /// /// Release body in HTML /// diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/MigrateEmailTemplates.cs index ca0dc125b..0e406c386 100644 --- a/API/Data/ManualMigrations/MigrateEmailTemplates.cs +++ b/API/Data/ManualMigrations/MigrateEmailTemplates.cs @@ -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 logger) diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 26e2208b2..0e1050c49 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -23,6 +23,10 @@ public enum VolumeIncludes Chapters = 2, People = 4, Tags = 8, + /// + /// This will include Chapters by default + /// + Files = 16 } public interface IVolumeRepository @@ -34,7 +38,7 @@ public interface IVolumeRepository Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); Task> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters); - Task GetVolumeAsync(int volumeId); + Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files); Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); @@ -173,11 +177,10 @@ public class VolumeRepository : IVolumeRepository /// /// /// - public async Task GetVolumeAsync(int volumeId) + public async Task 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); } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 4890a8b90..be26a1762 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -65,22 +65,28 @@ public static class IncludesExtensions public static IQueryable Includes(this IQueryable 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)) diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 815be6c86..98f03263c 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -125,14 +125,77 @@ public class ImageService : IImageService } } + /// + /// Tries to determine if there is a better mode for resizing + /// + /// + /// + /// + /// + 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 /// File name with extension of the file. This will always write to 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 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; diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 9704259c4..f05ebd191 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -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 ScanTasks = + public static readonly ImmutableArray 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); } + /// /// Checks if this same invocation is already enqueued or scheduled /// @@ -471,6 +475,7 @@ public class TaskScheduler : ITaskScheduler /// object[] of arguments in the order they are passed to enqueued job /// Queue to check against. Defaults to "default" /// Check against running jobs. Defaults to false. + /// Check against arguments. Defaults to true. /// public static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = DefaultQueue, bool checkRunningJobs = false) { diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index ab8340be0..513164298 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -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 _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 /// Defaults to false /// Defaults to true. Is this a standalone invocation or is it in a loop? [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; diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 200851d10..de4c4e607 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -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 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, }; } diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html index 282e234b6..3b200f657 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.html @@ -57,7 +57,7 @@
- + @if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) {