Better OPDS Reading Lists & Cover Generation for Webtoons (#3017)
Co-authored-by: Zackaree <github@zackaree.com>
5
.gitignore
vendored
@ -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
|
||||
|
@ -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>
|
||||
|
||||
|
124
API.Tests/Services/ImageServiceTests.cs
Normal 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());
|
||||
}
|
||||
|
||||
}
|
After Width: | Height: | Size: 351 KiB |
After Width: | Height: | Size: 1.5 MiB |
After Width: | Height: | Size: 447 KiB |
After Width: | Height: | Size: 482 KiB |
BIN
API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg
Normal file
After Width: | Height: | Size: 618 KiB |
BIN
API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png
Normal file
After Width: | Height: | Size: 3.1 MiB |
After Width: | Height: | Size: 886 KiB |
After Width: | Height: | Size: 1.4 MiB |
After Width: | Height: | Size: 257 KiB |
BIN
API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png
Normal file
After Width: | Height: | Size: 466 KiB |
@ -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>
|
||||
|
@ -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<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}";
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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) {
|
||||
|