AVIF Support & Much More! (#1992)

* Expand the list of potential favicon icons to grab.

* Added a url mapping functionality to use alternative urls for fetching icons

* Initial commit to streamline media encoding. No DB migration yet, No UI changes, no Task changes.

* Started refactoring code so that webp queries use encoding format instead.

* More refactoring to remove hardcoded webp references.

* Moved manual migrations to their own folder to keep things organized. Manually drop the obsolete webp keys.

* Removed old apis for converting media and now have one. Reworked where the conversion code was located and streamlined events and whatnot.

* Make favicon encode setting aware

* Cleaned up favicon conversion

* Updated format counter to now just use Extension from MangaFile now that it's been out a while.

* Tweaked jumpbar code to reduce a lookup to hashmap.

* Added AVIF (8-bit only) support.

* In UpdatePeopleList, use FirstOrDefault as Single adds extra checks that may not be needed.

* You can now remove weblinks from edit series page and you can leave empty cells, they will just be removed on backend.

* Forgot a file

* Don't prompt to write a review, just show the pencil. It's the same amount of clicks if you do, less if you dont.

* Fixed Refresh token using wrong Claim to look up the user.

* Refactored how we refresh authentication to perform it every 10 m ins to ensure we always stay authenticated.

* Changed Version update code to run more throughout the day. Updated some hangfire to newer method signatures.
This commit is contained in:
Joe Milazzo 2023-05-12 15:31:23 -05:00 committed by GitHub
parent c1989e2819
commit 70690b747e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 778 additions and 566 deletions

View File

@ -5,6 +5,7 @@ using System.IO.Abstractions.TestingHelpers;
using System.IO.Compression; using System.IO.Compression;
using System.Linq; using System.Linq;
using API.Archive; using API.Archive;
using API.Entities.Enums;
using API.Services; using API.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NetVips; using NetVips;
@ -178,7 +179,7 @@ public class ArchiveServiceTests
_directoryService.ExistOrCreate(outputDir); _directoryService.ExistOrCreate(outputDir);
var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir); Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir, EncodeFormat.PNG);
var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath)); var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath));
@ -208,7 +209,7 @@ public class ArchiveServiceTests
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress); archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress);
var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
Path.GetFileNameWithoutExtension(inputFile), outputDir); Path.GetFileNameWithoutExtension(inputFile), outputDir, EncodeFormat.PNG);
var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile)); var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile));
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
Assert.Equal(expectedBytes, actualBytes); Assert.Equal(expectedBytes, actualBytes);
@ -222,13 +223,14 @@ public class ArchiveServiceTests
public void CanParseCoverImage(string inputFile) public void CanParseCoverImage(string inputFile)
{ {
var imageService = Substitute.For<IImageService>(); var imageService = Substitute.For<IImageService>();
imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>()).Returns(x => "cover.jpg"); imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<EncodeFormat>())
.Returns(x => "cover.jpg");
var archiveService = new ArchiveService(_logger, _directoryService, imageService, Substitute.For<IMediaErrorService>()); var archiveService = new ArchiveService(_logger, _directoryService, imageService, Substitute.For<IMediaErrorService>());
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/"); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/");
var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile)); var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile));
var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output"); var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output");
new DirectoryInfo(outputPath).Create(); new DirectoryInfo(outputPath).Create();
var expectedImage = archiveService.GetCoverImage(inputPath, inputFile, outputPath); var expectedImage = archiveService.GetCoverImage(inputPath, inputFile, outputPath, EncodeFormat.PNG);
Assert.Equal("cover.jpg", expectedImage); Assert.Equal("cover.jpg", expectedImage);
new DirectoryInfo(outputPath).Delete(); new DirectoryInfo(outputPath).Delete();
} }

View File

@ -55,7 +55,7 @@ public class BookmarkServiceTests
private BookmarkService Create(IDirectoryService ds) private BookmarkService Create(IDirectoryService ds)
{ {
return new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds, return new BookmarkService(Substitute.For<ILogger<BookmarkService>>(), _unitOfWork, ds,
Substitute.For<IImageService>(), Substitute.For<IEventHub>()); Substitute.For<IMediaConversionService>());
} }
#region Setup #region Setup

View File

@ -42,7 +42,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
return 1; return 1;
} }
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP) public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
{ {
return string.Empty; return string.Empty;
} }

View File

@ -43,7 +43,7 @@ internal class MockReadingItemService : IReadingItemService
return 1; return 1;
} }
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP) public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
{ {
return string.Empty; return string.Empty;
} }

View File

@ -163,27 +163,26 @@ public class ImageController : BaseApiController
/// <summary> /// <summary>
/// Returns the image associated with a web-link /// Returns the image associated with a web-link
/// </summary> /// </summary>
/// <param name="chapterId"></param>
/// <param name="pageNum"></param>
/// <param name="apiKey"></param> /// <param name="apiKey"></param>
/// <returns></returns> /// <returns></returns>
[HttpGet("web-link")] [HttpGet("web-link")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})] [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})]
public async Task<ActionResult> GetBookmarkImage(string url, string apiKey) public async Task<ActionResult> GetWebLinkImage(string url, string apiKey)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
if (userId == 0) return BadRequest(); if (userId == 0) return BadRequest();
if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null"); if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null");
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
// Check if the domain exists // Check if the domain exists
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url)); var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, ImageService.GetWebLinkFormat(url, encodeFormat));
if (!_directoryService.FileSystem.File.Exists(domainFilePath)) if (!_directoryService.FileSystem.File.Exists(domainFilePath))
{ {
// We need to request the favicon and save it // We need to request the favicon and save it
try try
{ {
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
await _imageService.DownloadFaviconAsync(url)); await _imageService.DownloadFaviconAsync(url, encodeFormat));
} }
catch (Exception) catch (Exception)
{ {

View File

@ -8,6 +8,7 @@ using API.DTOs.Jobs;
using API.DTOs.MediaErrors; using API.DTOs.MediaErrors;
using API.DTOs.Stats; using API.DTOs.Stats;
using API.DTOs.Update; using API.DTOs.Update;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
@ -119,29 +120,22 @@ public class ServerController : BaseApiController
return Ok(await _statsService.GetServerInfo()); return Ok(await _statsService.GetServerInfo());
} }
/// <summary>
/// Triggers the scheduling of the convert bookmarks job. Only one job will run at a time.
/// </summary>
/// <returns></returns>
[HttpPost("convert-bookmarks")]
public ActionResult ScheduleConvertBookmarks()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP());
return Ok();
}
/// <summary> /// <summary>
/// Triggers the scheduling of the convert covers job. Only one job will run at a time. /// Triggers the scheduling of the convert media job. This will convert all media to the target encoding (except for PNG). Only one job will run at a time.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
[HttpPost("convert-covers")] [HttpPost("convert-media")]
public ActionResult ScheduleConvertCovers() public async Task<ActionResult> ScheduleConvertCovers()
{ {
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(), var encoding = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
TaskScheduler.DefaultQueue, true)) return Ok(); if (encoding == EncodeFormat.PNG)
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToWebP()); {
return BadRequest(
"You cannot convert to PNG. For covers, use Refresh Covers. Bookmarks and favicons cannot be encoded back.");
}
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToEncoding());
return Ok(); return Ok();
} }

View File

@ -231,15 +231,9 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }
if (setting.Key == ServerSettingKey.ConvertBookmarkToWebP && updateSettingsDto.ConvertBookmarkToWebP + string.Empty != setting.Value) if (setting.Key == ServerSettingKey.EncodeMediaAs && updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value)
{ {
setting.Value = updateSettingsDto.ConvertBookmarkToWebP + string.Empty; setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.ConvertCoverToWebP && updateSettingsDto.ConvertCoverToWebP + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.ConvertCoverToWebP + string.Empty;
_unitOfWork.SettingsRepository.Update(setting); _unitOfWork.SettingsRepository.Update(setting);
} }

View File

@ -222,15 +222,15 @@ public class UploadController : BaseApiController
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0)
{ {
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (thumbnailSize > 0) if (thumbnailSize > 0)
{ {
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, convertToWebP, thumbnailSize); filename, encodeFormat, thumbnailSize);
} }
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, convertToWebP); filename, encodeFormat);
} }
/// <summary> /// <summary>

View File

@ -1,4 +1,5 @@
using API.Services; using API.Entities.Enums;
using API.Services;
namespace API.DTOs.Settings; namespace API.DTOs.Settings;
@ -47,9 +48,11 @@ public class ServerSettingDto
/// </summary> /// </summary>
public string InstallId { get; set; } = default!; public string InstallId { get; set; } = default!;
/// <summary> /// <summary>
/// If the server should save bookmarks as WebP encoding /// The format that should be used when saving media for Kavita
/// </summary> /// </summary>
public bool ConvertBookmarkToWebP { get; set; } /// <example>This includes things like: Covers, Bookmarks, Favicons</example>
public EncodeFormat EncodeMediaAs { get; set; }
/// <summary> /// <summary>
/// The amount of Backups before cleanup /// The amount of Backups before cleanup
/// </summary> /// </summary>
@ -65,10 +68,6 @@ public class ServerSettingDto
/// <remarks>Value should be between 1 and 30</remarks> /// <remarks>Value should be between 1 and 30</remarks>
public int TotalLogs { get; set; } public int TotalLogs { get; set; }
/// <summary> /// <summary>
/// If the server should save covers as WebP encoding
/// </summary>
public bool ConvertCoverToWebP { get; set; }
/// <summary>
/// The Host name (ie Reverse proxy domain name) for the server /// The Host name (ie Reverse proxy domain name) for the server
/// </summary> /// </summary>
public string HostName { get; set; } public string HostName { get; set; }

View File

@ -85,11 +85,6 @@ public class ServerInfoDto
/// <remarks>Introduced in v0.5.4</remarks> /// <remarks>Introduced in v0.5.4</remarks>
public int TotalPeople { get; set; } public int TotalPeople { get; set; }
/// <summary> /// <summary>
/// Is this instance storing bookmarks as WebP
/// </summary>
/// <remarks>Introduced in v0.5.4</remarks>
public bool StoreBookmarksAsWebP { get; set; }
/// <summary>
/// Number of users on this instance using Card Layout /// Number of users on this instance using Card Layout
/// </summary> /// </summary>
/// <remarks>Introduced in v0.5.4</remarks> /// <remarks>Introduced in v0.5.4</remarks>
@ -175,8 +170,8 @@ public class ServerInfoDto
/// <remarks>Introduced in v0.7.0</remarks> /// <remarks>Introduced in v0.7.0</remarks>
public long TotalReadingHours { get; set; } public long TotalReadingHours { get; set; }
/// <summary> /// <summary>
/// Is the Server saving covers as WebP /// The encoding the server is using to save media
/// </summary> /// </summary>
/// <remarks>Added in v0.7.0</remarks> /// <remarks>Added in v0.7.3</remarks>
public bool StoreCoversAsWebP { get; set; } public EncodeFormat EncodeMediaAs { get; set; }
} }

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// v0.7 introduced UTC dates and GMT+1 users would sometimes have dates stored as '0000-12-31 23:00:00'. /// v0.7 introduced UTC dates and GMT+1 users would sometimes have dates stored as '0000-12-31 23:00:00'.

View File

@ -3,7 +3,7 @@ using API.Constants;
using API.Entities; using API.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// New role introduced in v0.5.1. Adds the role to all users. /// New role introduced in v0.5.1. Adds the role to all users.

View File

@ -4,7 +4,7 @@ using API.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// New role introduced in v0.6. Adds the role to all users. /// New role introduced in v0.6. Adds the role to all users.

View File

@ -4,7 +4,7 @@ using API.Entities;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Added in v0.7.1.18 /// Added in v0.7.1.18

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// v0.6.0 introduced a change in how Normalization works and hence every normalized field needs to be re-calculated /// v0.6.0 introduced a change in how Normalization works and hence every normalized field needs to be re-calculated

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// v0.5.6 introduced Normalized Localized Name, which allows for faster lookups and less memory usage. This migration will calculate them once /// v0.5.6 introduced Normalized Localized Name, which allows for faster lookups and less memory usage. This migration will calculate them once

View File

@ -4,7 +4,7 @@ using API.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists /// New role introduced in v0.6. Calculates the Age Rating on all Reading Lists

View File

@ -3,7 +3,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Services.Tasks; using API.Services.Tasks;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// In v0.5.3, we removed Light and E-Ink themes. This migration will remove the themes from the DB and default anyone on /// In v0.5.3, we removed Light and E-Ink themes. This migration will remove the themes from the DB and default anyone on

View File

@ -0,0 +1,31 @@
using System.Threading.Tasks;
using API.Entities.Enums;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Added in v0.7.2.7/v0.7.3 in which the ConvertXToWebP Setting keys were removed. This migration will remove them.
/// </summary>
public static class MigrateRemoveWebPSettingRows
{
public static async Task Migrate(IUnitOfWork unitOfWork, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateRemoveWebPSettingRows migration - Please be patient, this may take some time. This is not an error");
var key = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertBookmarkToWebP);
var key2 = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertCoverToWebP);
if (key == null && key2 == null)
{
logger.LogCritical("Running MigrateRemoveWebPSettingRows migration - complete. Nothing to do");
return;
}
unitOfWork.SettingsRepository.Remove(key);
unitOfWork.SettingsRepository.Remove(key2);
await unitOfWork.CommitAsync();
logger.LogCritical("Running MigrateRemoveWebPSettingRows migration - Completed. This is not an error");
}
}

View File

@ -10,7 +10,7 @@ using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
internal sealed class SeriesRelationMigrationOutput internal sealed class SeriesRelationMigrationOutput
{ {

View File

@ -8,7 +8,7 @@ using CsvHelper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Introduced in v0.6.1.2 and v0.7, this imports to a temp file the existing series relationships. It is a 3 part migration. /// Introduced in v0.6.1.2 and v0.7, this imports to a temp file the existing series relationships. It is a 3 part migration.

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Introduced in v0.6.1.38 or v0.7.0, /// Introduced in v0.6.1.38 or v0.7.0,

View File

@ -1,7 +1,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Data; namespace API.Data.ManualMigrations;
/// <summary> /// <summary>
/// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress /// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress

View File

@ -177,7 +177,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark"); b.ToTable("AppUserBookmark", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserPreferences", b => modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
@ -282,7 +282,7 @@ namespace API.Data.Migrations
b.HasIndex("ThemeId"); b.HasIndex("ThemeId");
b.ToTable("AppUserPreferences"); b.ToTable("AppUserPreferences", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserProgress", b => modelBuilder.Entity("API.Entities.AppUserProgress", b =>
@ -332,7 +332,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserProgresses"); b.ToTable("AppUserProgresses", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserRating", b => modelBuilder.Entity("API.Entities.AppUserRating", b =>
@ -359,7 +359,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("AppUserRating"); b.ToTable("AppUserRating", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserRole", b => modelBuilder.Entity("API.Entities.AppUserRole", b =>
@ -484,7 +484,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("Chapter"); b.ToTable("Chapter", (string)null);
}); });
modelBuilder.Entity("API.Entities.CollectionTag", b => modelBuilder.Entity("API.Entities.CollectionTag", b =>
@ -519,7 +519,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "Promoted") b.HasIndex("Id", "Promoted")
.IsUnique(); .IsUnique();
b.ToTable("CollectionTag"); b.ToTable("CollectionTag", (string)null);
}); });
modelBuilder.Entity("API.Entities.Device", b => modelBuilder.Entity("API.Entities.Device", b =>
@ -565,7 +565,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("Device"); b.ToTable("Device", (string)null);
}); });
modelBuilder.Entity("API.Entities.FolderPath", b => modelBuilder.Entity("API.Entities.FolderPath", b =>
@ -587,7 +587,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("FolderPath"); b.ToTable("FolderPath", (string)null);
}); });
modelBuilder.Entity("API.Entities.Genre", b => modelBuilder.Entity("API.Entities.Genre", b =>
@ -607,7 +607,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle") b.HasIndex("NormalizedTitle")
.IsUnique(); .IsUnique();
b.ToTable("Genre"); b.ToTable("Genre", (string)null);
}); });
modelBuilder.Entity("API.Entities.Library", b => modelBuilder.Entity("API.Entities.Library", b =>
@ -672,7 +672,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Library"); b.ToTable("Library", (string)null);
}); });
modelBuilder.Entity("API.Entities.MangaFile", b => modelBuilder.Entity("API.Entities.MangaFile", b =>
@ -721,7 +721,7 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId"); b.HasIndex("ChapterId");
b.ToTable("MangaFile"); b.ToTable("MangaFile", (string)null);
}); });
modelBuilder.Entity("API.Entities.MediaError", b => modelBuilder.Entity("API.Entities.MediaError", b =>
@ -756,7 +756,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("MediaError"); b.ToTable("MediaError", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
@ -857,7 +857,7 @@ namespace API.Data.Migrations
b.HasIndex("Id", "SeriesId") b.HasIndex("Id", "SeriesId")
.IsUnique(); .IsUnique();
b.ToTable("SeriesMetadata"); b.ToTable("SeriesMetadata", (string)null);
}); });
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
@ -881,7 +881,7 @@ namespace API.Data.Migrations
b.HasIndex("TargetSeriesId"); b.HasIndex("TargetSeriesId");
b.ToTable("SeriesRelation"); b.ToTable("SeriesRelation", (string)null);
}); });
modelBuilder.Entity("API.Entities.Person", b => modelBuilder.Entity("API.Entities.Person", b =>
@ -901,7 +901,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Person"); b.ToTable("Person", (string)null);
}); });
modelBuilder.Entity("API.Entities.ReadingList", b => modelBuilder.Entity("API.Entities.ReadingList", b =>
@ -962,7 +962,7 @@ namespace API.Data.Migrations
b.HasIndex("AppUserId"); b.HasIndex("AppUserId");
b.ToTable("ReadingList"); b.ToTable("ReadingList", (string)null);
}); });
modelBuilder.Entity("API.Entities.ReadingListItem", b => modelBuilder.Entity("API.Entities.ReadingListItem", b =>
@ -996,7 +996,7 @@ namespace API.Data.Migrations
b.HasIndex("VolumeId"); b.HasIndex("VolumeId");
b.ToTable("ReadingListItem"); b.ToTable("ReadingListItem", (string)null);
}); });
modelBuilder.Entity("API.Entities.Series", b => modelBuilder.Entity("API.Entities.Series", b =>
@ -1095,7 +1095,7 @@ namespace API.Data.Migrations
b.HasIndex("LibraryId"); b.HasIndex("LibraryId");
b.ToTable("Series"); b.ToTable("Series", (string)null);
}); });
modelBuilder.Entity("API.Entities.ServerSetting", b => modelBuilder.Entity("API.Entities.ServerSetting", b =>
@ -1112,7 +1112,7 @@ namespace API.Data.Migrations
b.HasKey("Key"); b.HasKey("Key");
b.ToTable("ServerSetting"); b.ToTable("ServerSetting", (string)null);
}); });
modelBuilder.Entity("API.Entities.ServerStatistics", b => modelBuilder.Entity("API.Entities.ServerStatistics", b =>
@ -1150,7 +1150,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("ServerStatistics"); b.ToTable("ServerStatistics", (string)null);
}); });
modelBuilder.Entity("API.Entities.SiteTheme", b => modelBuilder.Entity("API.Entities.SiteTheme", b =>
@ -1188,7 +1188,7 @@ namespace API.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("SiteTheme"); b.ToTable("SiteTheme", (string)null);
}); });
modelBuilder.Entity("API.Entities.Tag", b => modelBuilder.Entity("API.Entities.Tag", b =>
@ -1208,7 +1208,7 @@ namespace API.Data.Migrations
b.HasIndex("NormalizedTitle") b.HasIndex("NormalizedTitle")
.IsUnique(); .IsUnique();
b.ToTable("Tag"); b.ToTable("Tag", (string)null);
}); });
modelBuilder.Entity("API.Entities.Volume", b => modelBuilder.Entity("API.Entities.Volume", b =>
@ -1260,7 +1260,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesId"); b.HasIndex("SeriesId");
b.ToTable("Volume"); b.ToTable("Volume", (string)null);
}); });
modelBuilder.Entity("AppUserLibrary", b => modelBuilder.Entity("AppUserLibrary", b =>
@ -1275,7 +1275,7 @@ namespace API.Data.Migrations
b.HasIndex("LibrariesId"); b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary"); b.ToTable("AppUserLibrary", (string)null);
}); });
modelBuilder.Entity("ChapterGenre", b => modelBuilder.Entity("ChapterGenre", b =>
@ -1290,7 +1290,7 @@ namespace API.Data.Migrations
b.HasIndex("GenresId"); b.HasIndex("GenresId");
b.ToTable("ChapterGenre"); b.ToTable("ChapterGenre", (string)null);
}); });
modelBuilder.Entity("ChapterPerson", b => modelBuilder.Entity("ChapterPerson", b =>
@ -1305,7 +1305,7 @@ namespace API.Data.Migrations
b.HasIndex("PeopleId"); b.HasIndex("PeopleId");
b.ToTable("ChapterPerson"); b.ToTable("ChapterPerson", (string)null);
}); });
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
@ -1320,7 +1320,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId"); b.HasIndex("TagsId");
b.ToTable("ChapterTag"); b.ToTable("ChapterTag", (string)null);
}); });
modelBuilder.Entity("CollectionTagSeriesMetadata", b => modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
@ -1335,7 +1335,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata"); b.ToTable("CollectionTagSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("GenreSeriesMetadata", b => modelBuilder.Entity("GenreSeriesMetadata", b =>
@ -1350,7 +1350,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("GenreSeriesMetadata"); b.ToTable("GenreSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
@ -1449,7 +1449,7 @@ namespace API.Data.Migrations
b.HasIndex("SeriesMetadatasId"); b.HasIndex("SeriesMetadatasId");
b.ToTable("PersonSeriesMetadata"); b.ToTable("PersonSeriesMetadata", (string)null);
}); });
modelBuilder.Entity("SeriesMetadataTag", b => modelBuilder.Entity("SeriesMetadataTag", b =>
@ -1464,7 +1464,7 @@ namespace API.Data.Migrations
b.HasIndex("TagsId"); b.HasIndex("TagsId");
b.ToTable("SeriesMetadataTag"); b.ToTable("SeriesMetadataTag", (string)null);
}); });
modelBuilder.Entity("API.Entities.AppUserBookmark", b => modelBuilder.Entity("API.Entities.AppUserBookmark", b =>

View File

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;

View File

@ -6,6 +6,7 @@ using API.DTOs;
using API.DTOs.Metadata; using API.DTOs.Metadata;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using AutoMapper; using AutoMapper;
@ -36,7 +37,7 @@ public interface IChapterRepository
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds); Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<string?> GetChapterCoverImageAsync(int chapterId); Task<string?> GetChapterCoverImageAsync(int chapterId);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers(); Task<IList<Chapter>> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format);
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync(); Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter); Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
} }
@ -208,10 +209,11 @@ public class ChapterRepository : IChapterRepository
.ToListAsync())!; .ToListAsync())!;
} }
public async Task<IList<Chapter>> GetAllChaptersWithNonWebPCovers() public async Task<IList<Chapter>> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format)
{ {
var extension = format.GetExtension();
return await _context.Chapter return await _context.Chapter
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View File

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using API.Data.Misc; using API.Data.Misc;
using API.DTOs.CollectionTags; using API.DTOs.CollectionTags;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using AutoMapper; using AutoMapper;
@ -34,7 +35,7 @@ public interface ICollectionTagRepository
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None); Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> TagExists(string title); Task<bool> TagExists(string title);
Task<IList<CollectionTag>> GetAllWithNonWebPCovers(); Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
} }
public class CollectionTagRepository : ICollectionTagRepository public class CollectionTagRepository : ICollectionTagRepository
{ {
@ -108,10 +109,11 @@ public class CollectionTagRepository : ICollectionTagRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
} }
public async Task<IList<CollectionTag>> GetAllWithNonWebPCovers() public async Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.CollectionTag return await _context.CollectionTag
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View File

@ -52,7 +52,7 @@ public interface ILibraryRepository
Task<string?> GetLibraryCoverImageAsync(int libraryId); Task<string?> GetLibraryCoverImageAsync(int libraryId);
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds); Task<IDictionary<int, LibraryType>> GetLibraryTypesForIdsAsync(IEnumerable<int> libraryIds);
Task<IList<Library>> GetAllWithNonWebPCovers(); Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
} }
public class LibraryRepository : ILibraryRepository public class LibraryRepository : ILibraryRepository
@ -170,10 +170,7 @@ public class LibraryRepository : ILibraryRepository
var c = sortChar; var c = sortChar;
var isAlpha = char.IsLetter(sortChar); var isAlpha = char.IsLetter(sortChar);
if (!isAlpha) c = '#'; if (!isAlpha) c = '#';
if (!firstCharacterMap.ContainsKey(c)) firstCharacterMap.TryAdd(c, 0);
{
firstCharacterMap[c] = 0;
}
firstCharacterMap[c] += 1; firstCharacterMap[c] += 1;
} }
@ -371,10 +368,11 @@ public class LibraryRepository : ILibraryRepository
return dict; return dict;
} }
public async Task<IList<Library>> GetAllWithNonWebPCovers() public async Task<IList<Library>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.Library return await _context.Library
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }
} }

View File

@ -45,7 +45,7 @@ public interface IReadingListRepository
Task<IList<string>> GetAllCoverImagesAsync(); Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> ReadingListExists(string name); Task<bool> ReadingListExists(string name);
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId); IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
Task<IList<ReadingList>> GetAllWithNonWebPCovers(); Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId); Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId);
Task<int> RemoveReadingListsWithoutSeries(); Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
@ -110,10 +110,11 @@ public class ReadingListRepository : IReadingListRepository
.AsEnumerable(); .AsEnumerable();
} }
public async Task<IList<ReadingList>> GetAllWithNonWebPCovers() public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.ReadingList return await _context.ReadingList
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View File

@ -4,6 +4,7 @@ using System.Drawing;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.Data.Misc; using API.Data.Misc;
using API.Data.Scanner; using API.Data.Scanner;
using API.DTOs; using API.DTOs;
@ -132,7 +133,7 @@ public interface ISeriesRepository
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync(); Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds); Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true); Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat, bool customOnly = true);
} }
public class SeriesRepository : ISeriesRepository public class SeriesRepository : ISeriesRepository
@ -565,12 +566,14 @@ public class SeriesRepository : ISeriesRepository
/// Returns custom images only /// Returns custom images only
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public async Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true) public async Task<IList<Series>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat,
bool customOnly = true)
{ {
var extension = encodeFormat.GetExtension();
var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty); var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty);
return await _context.Series return await _context.Series
.Where(c => !string.IsNullOrEmpty(c.CoverImage) .Where(c => !string.IsNullOrEmpty(c.CoverImage)
&& !c.CoverImage.EndsWith(".webp") && !c.CoverImage.EndsWith(extension)
&& (!customOnly || c.CoverImage.StartsWith(prefix))) && (!customOnly || c.CoverImage.StartsWith(prefix)))
.ToListAsync(); .ToListAsync();
} }

View File

@ -15,6 +15,7 @@ public interface ISettingsRepository
Task<ServerSettingDto> GetSettingsDtoAsync(); Task<ServerSettingDto> GetSettingsDtoAsync();
Task<ServerSetting> GetSettingAsync(ServerSettingKey key); Task<ServerSetting> GetSettingAsync(ServerSettingKey key);
Task<IEnumerable<ServerSetting>> GetSettingsAsync(); Task<IEnumerable<ServerSetting>> GetSettingsAsync();
void Remove(ServerSetting setting);
} }
public class SettingsRepository : ISettingsRepository public class SettingsRepository : ISettingsRepository
{ {
@ -32,6 +33,11 @@ public class SettingsRepository : ISettingsRepository
_context.Entry(settings).State = EntityState.Modified; _context.Entry(settings).State = EntityState.Modified;
} }
public void Remove(ServerSetting setting)
{
_context.Remove(setting);
}
public async Task<ServerSettingDto> GetSettingsDtoAsync() public async Task<ServerSettingDto> GetSettingsDtoAsync()
{ {
var settings = await _context.ServerSetting var settings = await _context.ServerSetting

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Services; using API.Services;
using AutoMapper; using AutoMapper;
@ -26,7 +27,7 @@ public interface IVolumeRepository
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false); Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId); Task<IEnumerable<Volume>> GetVolumes(int seriesId);
Task<Volume?> GetVolumeByIdAsync(int volumeId); Task<Volume?> GetVolumeByIdAsync(int volumeId);
Task<IList<Volume>> GetAllWithNonWebPCovers(); Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
} }
public class VolumeRepository : IVolumeRepository public class VolumeRepository : IVolumeRepository
{ {
@ -200,10 +201,11 @@ public class VolumeRepository : IVolumeRepository
return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId);
} }
public async Task<IList<Volume>> GetAllWithNonWebPCovers() public async Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{ {
var extension = encodeFormat.GetExtension();
return await _context.Volume return await _context.Volume
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
.ToListAsync(); .ToListAsync();
} }

View File

@ -101,12 +101,11 @@ public static class Seed
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
new() {Key = ServerSettingKey.ConvertCoverToWebP, Value = "false"},
new() {Key = ServerSettingKey.HostName, Value = string.Empty}, new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
}.ToArray()); }.ToArray());
foreach (var defaultSetting in DefaultSettings) foreach (var defaultSetting in DefaultSettings)

View File

@ -0,0 +1,13 @@
using System.ComponentModel;
namespace API.Entities.Enums;
public enum EncodeFormat
{
[Description("PNG")]
PNG = 0,
[Description("WebP")]
WEBP = 1,
[Description("AVIF")]
AVIF = 2
}

View File

@ -1,4 +1,5 @@
using System.ComponentModel; using System;
using System.ComponentModel;
namespace API.Entities.Enums; namespace API.Entities.Enums;
@ -82,6 +83,7 @@ public enum ServerSettingKey
/// <summary> /// <summary>
/// If Kavita should save bookmarks as WebP images /// If Kavita should save bookmarks as WebP images
/// </summary> /// </summary>
[Obsolete("Use EncodeMediaAs instead")]
[Description("ConvertBookmarkToWebP")] [Description("ConvertBookmarkToWebP")]
ConvertBookmarkToWebP = 14, ConvertBookmarkToWebP = 14,
/// <summary> /// <summary>
@ -102,6 +104,7 @@ public enum ServerSettingKey
/// <summary> /// <summary>
/// If Kavita should save covers as WebP images /// If Kavita should save covers as WebP images
/// </summary> /// </summary>
[Obsolete("Use EncodeMediaAs instead")]
[Description("ConvertCoverToWebP")] [Description("ConvertCoverToWebP")]
ConvertCoverToWebP = 19, ConvertCoverToWebP = 19,
/// <summary> /// <summary>
@ -114,4 +117,11 @@ public enum ServerSettingKey
/// </summary> /// </summary>
[Description("IpAddresses")] [Description("IpAddresses")]
IpAddresses = 21, IpAddresses = 21,
/// <summary>
/// Encode all media as PNG/WebP/AVIF/etc.
/// </summary>
/// <remarks>As of v0.7.3 this replaced ConvertCoverToWebP and ConvertBookmarkToWebP</remarks>
[Description("EncodeMediaAs")]
EncodeMediaAs = 22,
} }

View File

@ -50,6 +50,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IDeviceService, DeviceService>(); services.AddScoped<IDeviceService, DeviceService>();
services.AddScoped<IStatisticService, StatisticService>(); services.AddScoped<IStatisticService, StatisticService>();
services.AddScoped<IMediaErrorService, MediaErrorService>(); services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IScannerService, ScannerService>(); services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IMetadataService, MetadataService>(); services.AddScoped<IMetadataService, MetadataService>();

View File

@ -0,0 +1,18 @@
using System;
using API.Entities.Enums;
namespace API.Extensions;
public static class EncodeFormatExtensions
{
public static string GetExtension(this EncodeFormat encodeFormat)
{
return encodeFormat switch
{
EncodeFormat.PNG => ".png",
EncodeFormat.WEBP => ".webp",
EncodeFormat.AVIF => ".avif",
_ => throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null)
};
}
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using API.DTOs.Settings; using API.DTOs.Settings;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -51,11 +52,8 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.InstallVersion: case ServerSettingKey.InstallVersion:
destination.InstallVersion = row.Value; destination.InstallVersion = row.Value;
break; break;
case ServerSettingKey.ConvertBookmarkToWebP: case ServerSettingKey.EncodeMediaAs:
destination.ConvertBookmarkToWebP = bool.Parse(row.Value); destination.EncodeMediaAs = Enum.Parse<EncodeFormat>(row.Value);
break;
case ServerSettingKey.ConvertCoverToWebP:
destination.ConvertCoverToWebP = bool.Parse(row.Value);
break; break;
case ServerSettingKey.TotalBackups: case ServerSettingKey.TotalBackups:
destination.TotalBackups = int.Parse(row.Value); destination.TotalBackups = int.Parse(row.Value);

View File

@ -115,21 +115,21 @@ public static class PersonHelper
/// For a given role and people dtos, update a series /// For a given role and people dtos, update a series
/// </summary> /// </summary>
/// <param name="role"></param> /// <param name="role"></param>
/// <param name="tags"></param> /// <param name="people"></param>
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="allTags"></param> /// <param name="allPeople"></param>
/// <param name="handleAdd">This will call with an existing or new tag, but the method does not update the series Metadata</param> /// <param name="handleAdd">This will call with an existing or new tag, but the method does not update the series Metadata</param>
/// <param name="onModified"></param> /// <param name="onModified"></param>
public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? tags, Series series, IReadOnlyCollection<Person> allTags, public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? people, Series series, IReadOnlyCollection<Person> allPeople,
Action<Person> handleAdd, Action onModified) Action<Person> handleAdd, Action onModified)
{ {
if (tags == null) return; if (people == null) return;
var isModified = false; var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList(); var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList();
foreach (var existing in existingTags) foreach (var existing in existingTags)
{ {
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
{ {
// Remove tag // Remove tag
series.Metadata.People.Remove(existing); series.Metadata.People.Remove(existing);
@ -138,9 +138,9 @@ public static class PersonHelper
} }
// At this point, all tags that aren't in dto have been removed. // At this point, all tags that aren't in dto have been removed.
foreach (var tag in tags) foreach (var tag in people)
{ {
var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
if (existingTag != null) if (existingTag != null)
{ {
if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name)))

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.ManualMigrations;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Logging; using API.Logging;

View File

@ -7,6 +7,7 @@ using System.Linq;
using System.Xml.Serialization; using System.Xml.Serialization;
using API.Archive; using API.Archive;
using API.Data.Metadata; using API.Data.Metadata;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Services.Tasks; using API.Services.Tasks;
using Kavita.Common; using Kavita.Common;
@ -20,7 +21,7 @@ public interface IArchiveService
{ {
void ExtractArchive(string archivePath, string extractPath); void ExtractArchive(string archivePath, string extractPath);
int GetNumberOfPagesFromArchive(string archivePath); int GetNumberOfPagesFromArchive(string archivePath);
string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false); string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format);
bool IsValidArchive(string archivePath); bool IsValidArchive(string archivePath);
ComicInfo? GetComicInfo(string archivePath); ComicInfo? GetComicInfo(string archivePath);
ArchiveLibrary CanOpen(string archivePath); ArchiveLibrary CanOpen(string archivePath);
@ -201,9 +202,9 @@ public class ArchiveService : IArchiveService
/// <param name="archivePath"></param> /// <param name="archivePath"></param>
/// <param name="fileName">File name to use based on context of entity.</param> /// <param name="fileName">File name to use based on context of entity.</param>
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param> /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param> /// <param name="encodeFormat">When saving the file, use encoding</param>
/// <returns></returns> /// <returns></returns>
public string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false) public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty;
try try
@ -219,7 +220,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.FullName == entryName); var entry = archive.Entries.Single(e => e.FullName == entryName);
using var stream = entry.Open(); using var stream = entry.Open();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
case ArchiveLibrary.SharpCompress: case ArchiveLibrary.SharpCompress:
{ {
@ -230,7 +231,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.Key == entryName); var entry = archive.Entries.Single(e => e.Key == entryName);
using var stream = entry.OpenEntryStream(); using var stream = entry.OpenEntryStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
case ArchiveLibrary.NotSupported: case ArchiveLibrary.NotSupported:
_logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath);
@ -426,7 +427,7 @@ public class ArchiveService : IArchiveService
{ {
entry.WriteToDirectory(extractPath, new ExtractionOptions() entry.WriteToDirectory(extractPath, new ExtractionOptions()
{ {
ExtractFullPath = true, // Don't flatten, let the flatterner ensure correct order of nested folders ExtractFullPath = true, // Don't flatten, let the flattener ensure correct order of nested folders
Overwrite = false Overwrite = false
}); });
} }

View File

@ -34,7 +34,7 @@ namespace API.Services;
public interface IBookService public interface IBookService
{ {
int GetNumberOfPages(string filePath); int GetNumberOfPages(string filePath);
string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false); string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat);
ComicInfo? GetComicInfo(string filePath); ComicInfo? GetComicInfo(string filePath);
ParserInfo? ParseInfo(string filePath); ParserInfo? ParseInfo(string filePath);
/// <summary> /// <summary>
@ -1062,15 +1062,15 @@ public class BookService : IBookService
/// <param name="fileFilePath"></param> /// <param name="fileFilePath"></param>
/// <param name="fileName">Name of the new file.</param> /// <param name="fileName">Name of the new file.</param>
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param> /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param> /// <param name="encodeFormat">When saving the file, use encoding</param>
/// <returns></returns> /// <returns></returns>
public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false) public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
if (!IsValidFile(fileFilePath)) return string.Empty; if (!IsValidFile(fileFilePath)) return string.Empty;
if (Parser.IsPdf(fileFilePath)) if (Parser.IsPdf(fileFilePath))
{ {
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, saveAsWebP); return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat);
} }
using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions);
@ -1085,7 +1085,7 @@ public class BookService : IBookService
if (coverImageContent == null) return string.Empty; if (coverImageContent == null) return string.Empty;
using var stream = coverImageContent.GetContentStream(); using var stream = coverImageContent.GetContentStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -1098,7 +1098,7 @@ public class BookService : IBookService
} }
private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP) private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
try try
{ {
@ -1108,7 +1108,7 @@ public class BookService : IBookService
using var stream = StreamManager.GetStream("BookService.GetPdfPage"); using var stream = StreamManager.GetStream("BookService.GetPdfPage");
GetPdfPage(docReader, 0, stream); GetPdfPage(docReader, 0, stream);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -7,7 +7,6 @@ using API.Data;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.SignalR;
using Hangfire; using Hangfire;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -19,9 +18,6 @@ public interface IBookmarkService
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark); Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto); Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds); Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToWebP();
Task ConvertAllCoverToWebP();
} }
public class BookmarkService : IBookmarkService public class BookmarkService : IBookmarkService
@ -30,17 +26,15 @@ public class BookmarkService : IBookmarkService
private readonly ILogger<BookmarkService> _logger; private readonly ILogger<BookmarkService> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService; private readonly IMediaConversionService _mediaConversionService;
private readonly IEventHub _eventHub;
public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork, public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork,
IDirectoryService directoryService, IImageService imageService, IEventHub eventHub) IDirectoryService directoryService, IMediaConversionService mediaConversionService)
{ {
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_directoryService = directoryService; _directoryService = directoryService;
_imageService = imageService; _mediaConversionService = mediaConversionService;
_eventHub = eventHub;
} }
/// <summary> /// <summary>
@ -77,21 +71,25 @@ public class BookmarkService : IBookmarkService
/// This is a job that runs after a bookmark is saved /// This is a job that runs after a bookmark is saved
/// </summary> /// </summary>
/// <remarks>This must be public</remarks> /// <remarks>This must be public</remarks>
public async Task ConvertBookmarkToWebP(int bookmarkId) public async Task ConvertBookmarkToEncoding(int bookmarkId)
{ {
var bookmarkDirectory = var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var convertBookmarkToWebP = var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (!convertBookmarkToWebP) return; if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
// Validate the bookmark still exists // Validate the bookmark still exists
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId); var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
if (bookmark == null) return; if (bookmark == null) return;
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName, bookmark.FileName = await _mediaConversionService.SaveAsEncodingFormat(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId)); BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId), encodeFormat);
_unitOfWork.UserRepository.Update(bookmark); _unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
@ -137,10 +135,10 @@ public class BookmarkService : IBookmarkService
_unitOfWork.UserRepository.Add(bookmark); _unitOfWork.UserRepository.Add(bookmark);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
if (settings.ConvertBookmarkToWebP) if (settings.EncodeMediaAs == EncodeFormat.WEBP)
{ {
// Enqueue a task to convert the bookmark to webP // Enqueue a task to convert the bookmark to webP
BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id)); BackgroundJob.Enqueue(() => ConvertBookmarkToEncoding(bookmark.Id));
} }
} }
catch (Exception ex) catch (Exception ex)
@ -192,198 +190,9 @@ public class BookmarkService : IBookmarkService
b.FileName))); b.FileName)));
} }
/// <summary>
/// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllBookmarkToWebP()
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
.Where(b => !b.FileName.EndsWith(".webp")).ToList();
var count = 1F;
foreach (var bookmark in bookmarks)
{
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Started));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[BookmarkService] Converted bookmarks to WebP");
}
/// <summary>
/// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllCoverToWebP()
{
_logger.LogInformation("[BookmarkService] Starting conversion of all covers to webp");
var coverDirectory = _directoryService.CoverImageDirectory;
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
var chapterCovers = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers();
var seriesCovers = await _unitOfWork.SeriesRepository.GetAllWithNonWebPCovers();
var readingListCovers = await _unitOfWork.ReadingListRepository.GetAllWithNonWebPCovers();
var libraryCovers = await _unitOfWork.LibraryRepository.GetAllWithNonWebPCovers();
var collectionCovers = await _unitOfWork.CollectionTagRepository.GetAllWithNonWebPCovers();
var totalCount = chapterCovers.Count + seriesCovers.Count + readingListCovers.Count +
libraryCovers.Count + collectionCovers.Count;
var count = 1F;
_logger.LogInformation("[BookmarkService] Starting conversion of chapters");
foreach (var chapter in chapterCovers)
{
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, chapter.CoverImage, coverDirectory);
chapter.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of series");
foreach (var series in seriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, series.CoverImage, coverDirectory);
series.CoverImage = Path.GetFileName(newFile);
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of libraries");
foreach (var library in libraryCovers)
{
if (string.IsNullOrEmpty(library.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, library.CoverImage, coverDirectory);
library.CoverImage = Path.GetFileName(newFile);
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of reading lists");
foreach (var readingList in readingListCovers)
{
if (string.IsNullOrEmpty(readingList.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, readingList.CoverImage, coverDirectory);
readingList.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ReadingListRepository.Update(readingList);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
_logger.LogInformation("[BookmarkService] Starting conversion of collections");
foreach (var collection in collectionCovers)
{
if (string.IsNullOrEmpty(collection.CoverImage)) continue;
var newFile = await SaveAsWebP(coverDirectory, collection.CoverImage, coverDirectory);
collection.CoverImage = Path.GetFileName(newFile);
_unitOfWork.CollectionTagRepository.Update(collection);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Started));
count++;
}
// Now null out all series and volumes that aren't webp or custom
var nonCustomOrConvertedVolumeCovers = await _unitOfWork.VolumeRepository.GetAllWithNonWebPCovers();
foreach (var volume in nonCustomOrConvertedVolumeCovers)
{
if (string.IsNullOrEmpty(volume.CoverImage)) continue;
volume.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.VolumeRepository.Update(volume);
await _unitOfWork.CommitAsync();
}
var nonCustomOrConvertedSeriesCovers = await _unitOfWork.SeriesRepository.GetAllWithNonWebPCovers(false);
foreach (var series in nonCustomOrConvertedSeriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
series.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[BookmarkService] Converted covers to WebP");
}
/// <summary> public static string BookmarkStem(int userId, int seriesId, int chapterId)
/// Converts an image file, deletes original and returns the new path back
/// </summary>
/// <param name="imageDirectory">Full Path to where files are stored</param>
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
public async Task<string> SaveAsWebP(string imageDirectory, string filename, string targetFolder)
{
// This must be Public as it's used in via Hangfire as a background task
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
var newFilename = string.Empty;
_logger.LogDebug("Converting {Source} image into WebP at {Target}", fullSourcePath, fullTargetDirectory);
try
{
// Convert target file to webp then delete original target file and update bookmark
try
{
var targetFile = await _imageService.ConvertToWebP(fullSourcePath, fullTargetDirectory);
var targetName = new FileInfo(targetFile).Name;
newFilename = Path.Join(targetFolder, targetName);
_directoryService.DeleteFiles(new[] {fullSourcePath});
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image {FilePath}", filename);
newFilename = filename;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image to WebP");
}
return newFilename;
}
private static string BookmarkStem(int userId, int seriesId, int chapterId)
{ {
return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}"); return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}");
} }

View File

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities.Enums;
using API.Extensions;
using Flurl; using Flurl;
using Flurl.Http; using Flurl.Http;
using HtmlAgilityPack; using HtmlAgilityPack;
@ -16,49 +18,49 @@ namespace API.Services;
public interface IImageService public interface IImageService
{ {
void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1); void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1);
string GetCoverImage(string path, string fileName, string outputDirectory, bool saveAsWebP = false); string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary> /// <summary>
/// Creates a Thumbnail version of a base64 image /// Creates a Thumbnail version of a base64 image
/// </summary> /// </summary>
/// <param name="encodedImage">base64 encoded image</param> /// <param name="encodedImage">base64 encoded image</param>
/// <param name="fileName"></param> /// <param name="fileName"></param>
/// <param name="saveAsWebP">Convert and save as webp</param> /// <param name="encodeFormat">Convert and save as encoding format</param>
/// <param name="thumbnailWidth">Width of thumbnail</param> /// <param name="thumbnailWidth">Width of thumbnail</param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns> /// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = 320); string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320);
/// <summary> /// <summary>
/// Writes out a thumbnail by stream input /// Writes out a thumbnail by stream input
/// </summary> /// </summary>
/// <param name="stream"></param> /// <param name="stream"></param>
/// <param name="fileName"></param> /// <param name="fileName"></param>
/// <param name="outputDirectory"></param> /// <param name="outputDirectory"></param>
/// <param name="saveAsWebP"></param> /// <param name="encodeFormat"></param>
/// <returns></returns> /// <returns></returns>
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false); string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary> /// <summary>
/// Writes out a thumbnail by file path input /// Writes out a thumbnail by file path input
/// </summary> /// </summary>
/// <param name="sourceFile"></param> /// <param name="sourceFile"></param>
/// <param name="fileName"></param> /// <param name="fileName"></param>
/// <param name="outputDirectory"></param> /// <param name="outputDirectory"></param>
/// <param name="saveAsWebP"></param> /// <param name="encodeFormat"></param>
/// <returns></returns> /// <returns></returns>
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false); string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary> /// <summary>
/// Converts the passed image to webP and outputs it in the same directory /// Converts the passed image to encoding and outputs it in the same directory
/// </summary> /// </summary>
/// <param name="filePath">Full path to the image to convert</param> /// <param name="filePath">Full path to the image to convert</param>
/// <param name="outputPath">Where to output the file</param> /// <param name="outputPath">Where to output the file</param>
/// <returns>File of written webp image</returns> /// <returns>File of written encoded image</returns>
Task<string> ConvertToWebP(string filePath, string outputPath); Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
Task<bool> IsImage(string filePath); Task<bool> IsImage(string filePath);
Task<string> DownloadFaviconAsync(string url); Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
} }
public class ImageService : IImageService public class ImageService : IImageService
{ {
public const string Name = "BookmarkService";
private readonly ILogger<ImageService> _logger; private readonly ILogger<ImageService> _logger;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
public const string ChapterCoverImageRegex = @"v\d+_c\d+"; public const string ChapterCoverImageRegex = @"v\d+_c\d+";
@ -75,6 +77,20 @@ public class ImageService : IImageService
/// </summary> /// </summary>
public const int LibraryThumbnailWidth = 32; public const int LibraryThumbnailWidth = 32;
private static readonly string[] ValidIconRelations = {
"icon",
"apple-touch-icon",
"apple-touch-icon-precomposed"
};
/// <summary>
/// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
/// </summary>
private static readonly IDictionary<string, string> FaviconUrlMapper = new Dictionary<string, string>
{
["https://app.plex.tv"] = "https://plex.tv"
};
public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService) public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService)
{ {
_logger = logger; _logger = logger;
@ -96,14 +112,14 @@ public class ImageService : IImageService
} }
} }
public string GetCoverImage(string path, string fileName, string outputDirectory, bool saveAsWebP = false) public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
if (string.IsNullOrEmpty(path)) return string.Empty; if (string.IsNullOrEmpty(path)) return string.Empty;
try try
{ {
using var thumbnail = Image.Thumbnail(path, ThumbnailWidth); using var thumbnail = Image.Thumbnail(path, ThumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png"); var filename = fileName + encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
return filename; return filename;
} }
@ -122,12 +138,12 @@ public class ImageService : IImageService
/// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param> /// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param>
/// <param name="fileName">filename to save as without extension</param> /// <param name="fileName">filename to save as without extension</param>
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param> /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
/// <param name="saveAsWebP">Export the file as webP otherwise will default to png</param> /// <param name="encodeFormat">Export the file as the passed encoding</param>
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns> /// <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, bool saveAsWebP = false) public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png"); var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory); _directoryService.ExistOrCreate(outputDirectory);
try try
{ {
@ -137,10 +153,10 @@ public class ImageService : IImageService
return filename; return filename;
} }
public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false) public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat)
{ {
using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth); using var thumbnail = Image.Thumbnail(sourceFile, ThumbnailWidth);
var filename = fileName + (saveAsWebP ? ".webp" : ".png"); var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory); _directoryService.ExistOrCreate(outputDirectory);
try try
{ {
@ -150,11 +166,11 @@ public class ImageService : IImageService
return filename; return filename;
} }
public Task<string> ConvertToWebP(string filePath, string outputPath) public Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat)
{ {
var file = _directoryService.FileSystem.FileInfo.New(filePath); var file = _directoryService.FileSystem.FileInfo.New(filePath);
var fileName = file.Name.Replace(file.Extension, string.Empty); var fileName = file.Name.Replace(file.Extension, string.Empty);
var outputFile = Path.Join(outputPath, fileName + ".webp"); var outputFile = Path.Join(outputPath, fileName + encodeFormat.GetExtension());
using var sourceImage = Image.NewFromFile(filePath, false, Enums.Access.SequentialUnbuffered); using var sourceImage = Image.NewFromFile(filePath, false, Enums.Access.SequentialUnbuffered);
sourceImage.WriteToFile(outputFile); sourceImage.WriteToFile(outputFile);
@ -183,24 +199,26 @@ public class ImageService : IImageService
return false; return false;
} }
public async Task<string> DownloadFaviconAsync(string url) public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
{ {
// Parse the URL to get the domain (including subdomain) // Parse the URL to get the domain (including subdomain)
var uri = new Uri(url); var uri = new Uri(url);
var domain = uri.Host; var domain = uri.Host;
var baseUrl = uri.Scheme + "://" + uri.Host; var baseUrl = uri.Scheme + "://" + uri.Host;
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
{
url = value;
}
try try
{ {
var validIconRelations = new[]
{
"icon",
"apple-touch-icon",
};
var htmlContent = url.GetStringAsync().Result; var htmlContent = url.GetStringAsync().Result;
var htmlDocument = new HtmlDocument(); var htmlDocument = new HtmlDocument();
htmlDocument.LoadHtml(htmlContent); htmlDocument.LoadHtml(htmlContent);
var pngLinks = htmlDocument.DocumentNode.Descendants("link") var pngLinks = htmlDocument.DocumentNode.Descendants("link")
.Where(link => validIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty)))
.Select(link => link.GetAttributeValue("href", string.Empty)) .Select(link => link.GetAttributeValue("href", string.Empty))
.Where(href => href.EndsWith(".png") || href.EndsWith(".PNG")) .Where(href => href.EndsWith(".png") || href.EndsWith(".PNG"))
.ToList(); .ToList();
@ -228,9 +246,23 @@ public class ImageService : IImageService
.GetStreamAsync(); .GetStreamAsync();
// Create the destination file path // Create the destination file path
var filename = $"{domain}.png";
using var image = Image.PngloadStream(faviconStream); using var image = Image.PngloadStream(faviconStream);
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); var filename = $"{domain}{encodeFormat.GetExtension()}";
switch (encodeFormat)
{
case EncodeFormat.PNG:
image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
break;
case EncodeFormat.WEBP:
image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
break;
case EncodeFormat.AVIF:
image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
break;
default:
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
}
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain); _logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
return filename; return filename;
@ -242,14 +274,13 @@ public class ImageService : IImageService
} }
} }
/// <inheritdoc /> /// <inheritdoc />
public string CreateThumbnailFromBase64(string encodedImage, string fileName, bool saveAsWebP = false, int thumbnailWidth = ThumbnailWidth) public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
{ {
try try
{ {
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth); using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth);
fileName += (saveAsWebP ? ".webp" : ".png"); fileName += encodeFormat.GetExtension();
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName)); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName));
return fileName; return fileName;
} }
@ -309,6 +340,7 @@ public class ImageService : IImageService
/// <returns></returns> /// <returns></returns>
public static string GetReadingListFormat(int readingListId) public static string GetReadingListFormat(int readingListId)
{ {
// ReSharper disable once StringLiteralTypo
return $"readinglist{readingListId}"; return $"readinglist{readingListId}";
} }
@ -322,9 +354,9 @@ public class ImageService : IImageService
return $"thumbnail{chapterId}"; return $"thumbnail{chapterId}";
} }
public static string GetWebLinkFormat(string url) public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat)
{ {
return $"{new Uri(url).Host}.png"; return $"{new Uri(url).Host}{encodeFormat.GetExtension()}";
} }

View File

@ -0,0 +1,312 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Enums;
using API.Extensions;
using API.SignalR;
using Hangfire;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IMediaConversionService
{
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToEncoding();
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllCoversToEncoding();
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllManagedMediaToEncodingFormat();
Task<string> SaveAsEncodingFormat(string imageDirectory, string filename, string targetFolder,
EncodeFormat encodeFormat);
}
public class MediaConversionService : IMediaConversionService
{
public const string Name = "MediaConversionService";
public static readonly string[] ConversionMethods = {"ConvertAllBookmarkToEncoding", "ConvertAllCoversToEncoding", "ConvertAllManagedMediaToEncodingFormat"};
private readonly IUnitOfWork _unitOfWork;
private readonly IImageService _imageService;
private readonly IEventHub _eventHub;
private readonly IDirectoryService _directoryService;
private readonly ILogger<MediaConversionService> _logger;
public MediaConversionService(IUnitOfWork unitOfWork, IImageService imageService, IEventHub eventHub,
IDirectoryService directoryService, ILogger<MediaConversionService> logger)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
_eventHub = eventHub;
_directoryService = directoryService;
_logger = logger;
}
/// <summary>
/// Converts all Kavita managed media (bookmarks, covers, favicons, etc) to the saved target encoding.
/// Do not invoke anyway except via Hangfire.
/// </summary>
/// <remarks>This is a long-running job</remarks>
/// <returns></returns>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllManagedMediaToEncodingFormat()
{
await ConvertAllBookmarkToEncoding();
await ConvertAllCoversToEncoding();
await CoverAllFaviconsToEncoding();
}
/// <summary>
/// This is a long-running job that will convert all bookmarks into a format that is not PNG. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllBookmarkToEncoding()
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
.Where(b => !b.FileName.EndsWith(encodeFormat.GetExtension())).ToList();
var count = 1F;
foreach (var bookmark in bookmarks)
{
bookmark.FileName = await SaveAsEncodingFormat(bookmarkDirectory, bookmark.FileName,
BookmarkService.BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId), encodeFormat);
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / bookmarks.Count, ProgressEventType.Updated));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[MediaConversionService] Converted bookmarks to {Format}", encodeFormat);
}
/// <summary>
/// This is a long-running job that will convert all covers into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllCoversToEncoding()
{
var coverDirectory = _directoryService.CoverImageDirectory;
var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of all covers to {Format}", encodeFormat);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0F, ProgressEventType.Started));
var chapterCovers = await _unitOfWork.ChapterRepository.GetAllChaptersWithCoversInDifferentEncoding(encodeFormat);
var seriesCovers = await _unitOfWork.SeriesRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var readingListCovers = await _unitOfWork.ReadingListRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var libraryCovers = await _unitOfWork.LibraryRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var collectionCovers = await _unitOfWork.CollectionTagRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
var totalCount = chapterCovers.Count + seriesCovers.Count + readingListCovers.Count +
libraryCovers.Count + collectionCovers.Count;
var count = 1F;
_logger.LogInformation("[MediaConversionService] Starting conversion of chapters");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(0, ProgressEventType.Started));
foreach (var chapter in chapterCovers)
{
if (string.IsNullOrEmpty(chapter.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, chapter.CoverImage, coverDirectory, encodeFormat);
chapter.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ChapterRepository.Update(chapter);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of series");
foreach (var series in seriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, series.CoverImage, coverDirectory, encodeFormat);
series.CoverImage = Path.GetFileName(newFile);
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of libraries");
foreach (var library in libraryCovers)
{
if (string.IsNullOrEmpty(library.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, library.CoverImage, coverDirectory, encodeFormat);
library.CoverImage = Path.GetFileName(newFile);
_unitOfWork.LibraryRepository.Update(library);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of reading lists");
foreach (var readingList in readingListCovers)
{
if (string.IsNullOrEmpty(readingList.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, readingList.CoverImage, coverDirectory, encodeFormat);
readingList.CoverImage = Path.GetFileName(newFile);
_unitOfWork.ReadingListRepository.Update(readingList);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of collections");
foreach (var collection in collectionCovers)
{
if (string.IsNullOrEmpty(collection.CoverImage)) continue;
var newFile = await SaveAsEncodingFormat(coverDirectory, collection.CoverImage, coverDirectory, encodeFormat);
collection.CoverImage = Path.GetFileName(newFile);
_unitOfWork.CollectionTagRepository.Update(collection);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(count / totalCount, ProgressEventType.Updated));
count++;
}
// Now null out all series and volumes that aren't webp or custom
var nonCustomOrConvertedVolumeCovers = await _unitOfWork.VolumeRepository.GetAllWithCoversInDifferentEncoding(encodeFormat);
foreach (var volume in nonCustomOrConvertedVolumeCovers)
{
if (string.IsNullOrEmpty(volume.CoverImage)) continue;
volume.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.VolumeRepository.Update(volume);
await _unitOfWork.CommitAsync();
}
var nonCustomOrConvertedSeriesCovers = await _unitOfWork.SeriesRepository.GetAllWithCoversInDifferentEncoding(encodeFormat, false);
foreach (var series in nonCustomOrConvertedSeriesCovers)
{
if (string.IsNullOrEmpty(series.CoverImage)) continue;
series.CoverImage = null; // We null it out so when we call Refresh Metadata it will auto update from first chapter
_unitOfWork.SeriesRepository.Update(series);
await _unitOfWork.CommitAsync();
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertCoverProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[MediaConversionService] Converted covers to {Format}", encodeFormat);
}
private async Task CoverAllFaviconsToEncoding()
{
var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encodeFormat == EncodeFormat.PNG)
{
_logger.LogError("Cannot convert media to PNG");
return;
}
_logger.LogInformation("[MediaConversionService] Starting conversion of favicons to {Format}", encodeFormat);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(0F, ProgressEventType.Started));
var pngFavicons = _directoryService.GetFiles(_directoryService.FaviconDirectory)
.Where(b => !b.EndsWith(encodeFormat.GetExtension())).
ToList();
var count = 1F;
foreach (var file in pngFavicons)
{
await SaveAsEncodingFormat(_directoryService.FaviconDirectory, _directoryService.FileSystem.FileInfo.New(file).Name, _directoryService.FaviconDirectory,
encodeFormat);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(count / pngFavicons.Count, ProgressEventType.Updated));
count++;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ConvertBookmarksProgressEvent(1F, ProgressEventType.Ended));
_logger.LogInformation("[MediaConversionService] Converted favicons to {Format}", encodeFormat);
}
/// <summary>
/// Converts an image file, deletes original and returns the new path back
/// </summary>
/// <param name="imageDirectory">Full Path to where files are stored</param>
/// <param name="filename">The file to convert</param>
/// <param name="targetFolder">Full path to where files should be stored or any stem</param>
/// <returns></returns>
public async Task<string> SaveAsEncodingFormat(string imageDirectory, string filename, string targetFolder, EncodeFormat encodeFormat)
{
// This must be Public as it's used in via Hangfire as a background task
var fullSourcePath = _directoryService.FileSystem.Path.Join(imageDirectory, filename);
var fullTargetDirectory = fullSourcePath.Replace(new FileInfo(filename).Name, string.Empty);
var newFilename = string.Empty;
_logger.LogDebug("Converting {Source} image into {Encoding} at {Target}", fullSourcePath, encodeFormat, fullTargetDirectory);
if (!File.Exists(fullSourcePath))
{
_logger.LogError("Requested to convert {File} but it doesn't exist", fullSourcePath);
return newFilename;
}
try
{
// Convert target file to format then delete original target file
try
{
var targetFile = await _imageService.ConvertToEncodingFormat(fullSourcePath, fullTargetDirectory, encodeFormat);
var targetName = new FileInfo(targetFile).Name;
newFilename = Path.Join(targetFolder, targetName);
_directoryService.DeleteFiles(new[] {fullSourcePath});
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image {FilePath} to {Format}", filename, encodeFormat);
newFilename = filename;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not convert image to {Format}", encodeFormat);
}
return newFilename;
}
}

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.Data; using API.Data;
using API.Entities; using API.Entities;
using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
using API.SignalR; using API.SignalR;
@ -32,7 +33,7 @@ public interface IMetadataService
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param> /// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true); Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true);
Task GenerateCoversForSeries(Series series, bool convertToWebP, bool forceUpdate = false); Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false);
Task RemoveAbandonedMetadataKeys(); Task RemoveAbandonedMetadataKeys();
} }
@ -63,8 +64,8 @@ public class MetadataService : IMetadataService
/// </summary> /// </summary>
/// <param name="chapter"></param> /// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param> /// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
/// <param name="convertToWebPOnWrite">Convert image to WebP when extracting the cover</param> /// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, bool convertToWebPOnWrite) private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat)
{ {
var firstFile = chapter.Files.MinBy(x => x.Chapter); var firstFile = chapter.Files.MinBy(x => x.Chapter);
if (firstFile == null) return Task.FromResult(false); if (firstFile == null) return Task.FromResult(false);
@ -78,7 +79,7 @@ public class MetadataService : IMetadataService
_logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath);
chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath,
ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, convertToWebPOnWrite); ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat);
_unitOfWork.ChapterRepository.Update(chapter); _unitOfWork.ChapterRepository.Update(chapter);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
return Task.FromResult(true); return Task.FromResult(true);
@ -141,8 +142,8 @@ public class MetadataService : IMetadataService
/// </summary> /// </summary>
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
/// <param name="convertToWebP"></param> /// <param name="encodeFormat"></param>
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, bool convertToWebP) private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat)
{ {
_logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName);
try try
@ -155,7 +156,7 @@ public class MetadataService : IMetadataService
var index = 0; var index = 0;
foreach (var chapter in volume.Chapters) foreach (var chapter in volume.Chapters)
{ {
var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, convertToWebP); var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat);
// If cover was update, either the file has changed or first scan and we should force a metadata update // If cover was update, either the file has changed or first scan and we should force a metadata update
UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated);
if (index == 0 && chapterUpdated) if (index == 0 && chapterUpdated)
@ -207,7 +208,7 @@ public class MetadataService : IMetadataService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}")); MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}"));
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
{ {
@ -237,7 +238,7 @@ public class MetadataService : IMetadataService
try try
{ {
await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP); await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -287,23 +288,23 @@ public class MetadataService : IMetadataService
return; return;
} }
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
await GenerateCoversForSeries(series, convertToWebP, forceUpdate); await GenerateCoversForSeries(series, encodeFormat, forceUpdate);
} }
/// <summary> /// <summary>
/// Generate Cover for a Series. This is used by Scan Loop and should not be invoked directly via User Interaction. /// Generate Cover for a Series. This is used by Scan Loop and should not be invoked directly via User Interaction.
/// </summary> /// </summary>
/// <param name="series">A full Series, with metadata, chapters, etc</param> /// <param name="series">A full Series, with metadata, chapters, etc</param>
/// <param name="convertToWebP">When saving the file, use WebP encoding instead of PNG</param> /// <param name="encodeFormat">When saving the file, what encoding should be used</param>
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
public async Task GenerateCoversForSeries(Series series, bool convertToWebP, bool forceUpdate = false) public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, bool forceUpdate = false)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP); await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
if (_unitOfWork.HasChanges()) if (_unitOfWork.HasChanges())

View File

@ -236,7 +236,6 @@ public class ReaderService : IReaderService
try try
{ {
// TODO: Rewrite this code to just pull user object with progress for that particular appuserprogress, else create it
var userProgress = var userProgress =
await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId); await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
@ -667,15 +666,15 @@ public class ReaderService : IReaderService
_directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetThumbnailFormat(chapter.Id)); _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, ImageService.GetThumbnailFormat(chapter.Id));
try try
{ {
var saveAsWebp = var encodeFormat =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (!Directory.Exists(outputDirectory)) if (!Directory.Exists(outputDirectory))
{ {
var outputtedThumbnails = cachedImages var outputtedThumbnails = cachedImages
.Select((img, idx) => .Select((img, idx) =>
_directoryService.FileSystem.Path.Join(outputDirectory, _directoryService.FileSystem.Path.Join(outputDirectory,
_imageService.WriteCoverThumbnail(img, $"{idx}", outputDirectory, saveAsWebp))) _imageService.WriteCoverThumbnail(img, $"{idx}", outputDirectory, encodeFormat)))
.ToArray(); .ToArray();
return CacheService.GetPageFromFiles(outputtedThumbnails, pageNum); return CacheService.GetPageFromFiles(outputtedThumbnails, pageNum);
} }

View File

@ -9,7 +9,7 @@ public interface IReadingItemService
{ {
ComicInfo? GetComicInfo(string filePath); ComicInfo? GetComicInfo(string filePath);
int GetNumberOfPages(string filePath, MangaFormat format); int GetNumberOfPages(string filePath, MangaFormat format);
string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat);
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
ParserInfo? ParseFile(string path, string rootPath, LibraryType type); ParserInfo? ParseFile(string path, string rootPath, LibraryType type);
} }
@ -161,7 +161,7 @@ public class ReadingItemService : IReadingItemService
} }
} }
public string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP) public string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat)
{ {
if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName)) if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName))
{ {
@ -171,10 +171,10 @@ public class ReadingItemService : IReadingItemService
return format switch return format switch
{ {
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP), MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
_ => string.Empty _ => string.Empty
}; };
} }

View File

@ -336,7 +336,7 @@ public class ReadingListService : IReadingListService
// .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(); // .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList();
// //
// var combinedFile = ImageService.CreateMergedImage(fullImages, _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, $"{readingListId}.png")); // var combinedFile = ImageService.CreateMergedImage(fullImages, _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, $"{readingListId}.png"));
// // webp needs to be handled // // webp/avif needs to be handled
// return combinedFile; // return combinedFile;
} }

View File

@ -31,7 +31,7 @@ public interface ITaskScheduler
void CancelStatsTasks(); void CancelStatsTasks();
Task RunStatCollection(); Task RunStatCollection();
void ScanSiteThemes(); void ScanSiteThemes();
Task CovertAllCoversToWebP(); Task CovertAllCoversToEncoding();
Task CleanupDbEntries(); Task CleanupDbEntries();
} }
@ -50,9 +50,9 @@ public class TaskScheduler : ITaskScheduler
private readonly IThemeService _themeService; private readonly IThemeService _themeService;
private readonly IWordCountAnalyzerService _wordCountAnalyzerService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
private readonly IStatisticService _statisticService; private readonly IStatisticService _statisticService;
private readonly IBookmarkService _bookmarkService; private readonly IMediaConversionService _mediaConversionService;
public static BackgroundJobServer Client => new BackgroundJobServer(); public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan"; public const string ScanQueue = "scan";
public const string DefaultQueue = "default"; public const string DefaultQueue = "default";
public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read";
@ -68,12 +68,17 @@ public class TaskScheduler : ITaskScheduler
private static readonly Random Rnd = new Random(); private static readonly Random Rnd = new Random();
private static readonly RecurringJobOptions RecurringJobOptions = new RecurringJobOptions()
{
TimeZone = TimeZoneInfo.Local
};
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService, public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IBookmarkService bookmarkService) IMediaConversionService mediaConversionService)
{ {
_cacheService = cacheService; _cacheService = cacheService;
_logger = logger; _logger = logger;
@ -87,7 +92,7 @@ public class TaskScheduler : ITaskScheduler
_themeService = themeService; _themeService = themeService;
_wordCountAnalyzerService = wordCountAnalyzerService; _wordCountAnalyzerService = wordCountAnalyzerService;
_statisticService = statisticService; _statisticService = statisticService;
_bookmarkService = bookmarkService; _mediaConversionService = mediaConversionService;
} }
public async Task ScheduleTasks() public async Task ScheduleTasks()
@ -100,28 +105,28 @@ public class TaskScheduler : ITaskScheduler
var scanLibrarySetting = setting; var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false), RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
} }
else else
{ {
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), Cron.Daily, RecurringJobOptions);
} }
setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value;
if (setting != null) if (setting != null)
{ {
_logger.LogDebug("Scheduling Backup Task for {Setting}", setting); _logger.LogDebug("Scheduling Backup Task for {Setting}", setting);
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), RecurringJobOptions);
} }
else else
{ {
RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, RecurringJobOptions);
} }
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, TimeZoneInfo.Local); RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
} }
#region StatsTasks #region StatsTasks
@ -137,7 +142,7 @@ public class TaskScheduler : ITaskScheduler
} }
_logger.LogDebug("Scheduling stat collection daily"); _logger.LogDebug("Scheduling stat collection daily");
RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local); RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions);
} }
public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false) public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false)
@ -182,10 +187,20 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _themeService.Scan()); BackgroundJob.Enqueue(() => _themeService.Scan());
} }
public async Task CovertAllCoversToWebP() /// <summary>
/// Do not invoke this manually, always enqueue on a background thread
/// </summary>
public async Task CovertAllCoversToEncoding()
{ {
await _bookmarkService.ConvertAllCoverToWebP(); var defaultParams = Array.Empty<object>();
_logger.LogInformation("[BookmarkService] Queuing tasks to update Series and Volume references via Cover Refresh"); if (MediaConversionService.ConversionMethods.Any(method =>
HasAlreadyEnqueuedTask(MediaConversionService.Name, method, defaultParams, DefaultQueue, true)))
{
return;
}
await _mediaConversionService.ConvertAllManagedMediaToEncodingFormat();
_logger.LogInformation("Queuing tasks to update Series and Volume references via Cover Refresh");
var libraryIds = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); var libraryIds = await _unitOfWork.LibraryRepository.GetLibrariesAsync();
foreach (var lib in libraryIds) foreach (var lib in libraryIds)
{ {
@ -200,8 +215,10 @@ public class TaskScheduler : ITaskScheduler
public void ScheduleUpdaterTasks() public void ScheduleUpdaterTasks()
{ {
_logger.LogInformation("Scheduling Auto-Update tasks"); _logger.LogInformation("Scheduling Auto-Update tasks");
// Schedule update check between noon and 6pm local time RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(5, 23)), new RecurringJobOptions()
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); {
TimeZone = TimeZoneInfo.Local
});
} }
public void ScanFolder(string folderPath, TimeSpan delay) public void ScanFolder(string folderPath, TimeSpan delay)

View File

@ -58,14 +58,14 @@ public class CleanupService : ICleanupService
[AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public async Task Cleanup() public async Task Cleanup()
{ {
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(), if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToEncoding", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true) || TaskScheduler.DefaultQueue, true) ||
TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty<object>(), TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToEncoding", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) TaskScheduler.DefaultQueue, true))
{ {
_logger.LogInformation("Cleanup put on hold as a conversion to WebP in progress"); _logger.LogInformation("Cleanup put on hold as a media conversion in progress");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.ErrorEvent("Cleanup", "Cleanup put on hold as a conversion to WebP in progress")); MessageFactory.ErrorEvent("Cleanup", "Cleanup put on hold as a media conversion in progress"));
return; return;
} }

View File

@ -14,7 +14,7 @@ public static class Parser
private const int RegexTimeoutMs = 5000000; // 500 ms private const int RegexTimeoutMs = 5000000; // 500 ms
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500);
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif)"; public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)";
public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt";
private const string BookFileExtensions = @"\.epub|\.pdf"; private const string BookFileExtensions = @"\.epub|\.pdf";
private const string XmlRegexExtensions = @"\.xml"; private const string XmlRegexExtensions = @"\.xml";

View File

@ -230,7 +230,7 @@ public class ProcessSeries : IProcessSeries
_logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name); _logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name);
} }
await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP); await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs);
EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id); EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id);
} }

View File

@ -34,7 +34,7 @@ public class StatsService : IStatsService
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly DataContext _context; private readonly DataContext _context;
private readonly IStatisticService _statisticService; private readonly IStatisticService _statisticService;
private const string ApiUrl = "https://stats.kavitareader.com"; private const string ApiUrl = "http://localhost:5003";
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService)
{ {
@ -139,8 +139,7 @@ public class StatsService : IStatsService
TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(), TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(),
TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(),
UsingSeriesRelationships = await GetIfUsingSeriesRelationship(), UsingSeriesRelationships = await GetIfUsingSeriesRelationship(),
StoreBookmarksAsWebP = serverSettings.ConvertBookmarkToWebP, EncodeMediaAs = serverSettings.EncodeMediaAs,
StoreCoversAsWebP = serverSettings.ConvertCoverToWebP,
MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(), MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(),
MaxVolumesInASeries = await MaxVolumesInASeries(), MaxVolumesInASeries = await MaxVolumesInASeries(),
MaxChaptersInASeries = await MaxChaptersInASeries(), MaxChaptersInASeries = await MaxChaptersInASeries(),
@ -292,14 +291,14 @@ public class StatsService : IStatsService
private IEnumerable<FileFormatDto> AllFormats() private IEnumerable<FileFormatDto> AllFormats()
{ {
// TODO: Rewrite this with new migration code in feature/basic-stats
var results = _context.MangaFile var results = _context.MangaFile
.AsNoTracking() .AsNoTracking()
.AsEnumerable() .AsEnumerable()
.Select(m => new FileFormatDto() .Select(m => new FileFormatDto()
{ {
Format = m.Format, Format = m.Format,
Extension = Path.GetExtension(m.FilePath)?.ToLowerInvariant()! Extension = m.Extension
}) })
.DistinctBy(f => f.Extension) .DistinctBy(f => f.Extension)
.ToList(); .ToList();

View File

@ -113,13 +113,13 @@ public class VersionUpdaterService : IVersionUpdaterService
if (BuildInfo.Version < updateVersion) if (BuildInfo.Version < updateVersion)
{ {
_logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion); _logger.LogWarning("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion);
await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update), await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update),
true); true);
} }
else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development)
{ {
_logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version); _logger.LogWarning("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version);
await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update), await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update),
true); true);
} }

View File

@ -77,9 +77,9 @@ public class TokenService : ITokenService
{ {
var tokenHandler = new JwtSecurityTokenHandler(); var tokenHandler = new JwtSecurityTokenHandler();
var tokenContent = tokenHandler.ReadJwtToken(request.Token); var tokenContent = tokenHandler.ReadJwtToken(request.Token);
var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.NameId)?.Value; var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.Name)?.Value;
if (string.IsNullOrEmpty(username)) return null; if (string.IsNullOrEmpty(username)) return null;
var user = await _userManager.FindByIdAsync(username); var user = await _userManager.FindByNameAsync(username);
if (user == null) return null; // This forces a logout if (user == null) return null; // This forces a logout
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken); var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
if (!validated) return null; if (!validated) return null;

View File

@ -484,7 +484,7 @@ public static class MessageFactory
return new SignalRMessage() return new SignalRMessage()
{ {
Name = ConvertBookmarksProgress, Name = ConvertBookmarksProgress,
Title = "Converting Bookmarks to WebP", Title = "Converting Bookmarks",
SubTitle = string.Empty, SubTitle = string.Empty,
EventType = eventType, EventType = eventType,
Progress = ProgressType.Determinate, Progress = ProgressType.Determinate,
@ -501,7 +501,7 @@ public static class MessageFactory
return new SignalRMessage() return new SignalRMessage()
{ {
Name = ConvertCoversProgress, Name = ConvertCoversProgress,
Title = "Converting Covers to WebP", Title = "Converting Covers",
SubTitle = string.Empty, SubTitle = string.Empty,
EventType = eventType, EventType = eventType,
Progress = ProgressType.Determinate, Progress = ProgressType.Determinate,

View File

@ -10,6 +10,7 @@ using System.Threading.RateLimiting;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.ManualMigrations;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
@ -249,6 +250,9 @@ public class Startup
// v0.7.2 // v0.7.2
await MigrateLoginRoles.Migrate(unitOfWork, userManager, logger); await MigrateLoginRoles.Migrate(unitOfWork, userManager, logger);
// v0.7.3
await MigrateRemoveWebPSettingRows.Migrate(unitOfWork, logger);
// Update the version in the DB after all migrations are run // Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString();

View File

@ -6,6 +6,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=epubs/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=epubs/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignore/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignores/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=kavitaignores/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=MACOSX/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=noopener/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=noopener/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=noreferrer/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=noreferrer/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=OEBPS/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=OEBPS/@EntryIndexedValue">True</s:Boolean>

View File

@ -115,10 +115,10 @@ export class AccountService implements OnDestroy {
this.currentUser = user; this.currentUser = user;
this.currentUserSource.next(user); this.currentUserSource.next(user);
this.stopRefreshTokenTimer();
if (this.currentUser !== undefined) { if (this.currentUser !== undefined) {
this.startRefreshTokenTimer(); this.startRefreshTokenTimer();
} else {
this.stopRefreshTokenTimer();
} }
} }
@ -264,7 +264,6 @@ export class AccountService implements OnDestroy {
private refreshToken() { private refreshToken() {
if (this.currentUser === null || this.currentUser === undefined) return of(); if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
{token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
if (this.currentUser) { if (this.currentUser) {
@ -277,23 +276,23 @@ export class AccountService implements OnDestroy {
})); }));
} }
/**
* Every 10 mins refresh the token
*/
private startRefreshTokenTimer() { private startRefreshTokenTimer() {
if (this.currentUser === null || this.currentUser === undefined) return; if (this.currentUser === null || this.currentUser === undefined) {
if (this.refreshTokenTimeout !== undefined) {
this.stopRefreshTokenTimer(); this.stopRefreshTokenTimer();
return;
} }
const jwtToken = JSON.parse(atob(this.currentUser.token.split('.')[1])); this.stopRefreshTokenTimer();
// set a timeout to refresh the token 10 mins before it expires
const expires = new Date(jwtToken.exp * 1000); this.refreshTokenTimeout = setInterval(() => this.refreshToken().subscribe(() => {}), (60 * 10_000));
const timeout = expires.getTime() - Date.now() - (60 * 10000);
this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => {}), timeout);
} }
private stopRefreshTokenTimer() { private stopRefreshTokenTimer() {
if (this.refreshTokenTimeout !== undefined) { if (this.refreshTokenTimeout !== undefined) {
clearTimeout(this.refreshTokenTimeout); clearInterval(this.refreshTokenTimeout);
} }
} }

View File

@ -55,12 +55,8 @@ export class ServerService {
return this.httpClient.get<Job[]>(this.baseUrl + 'server/jobs'); return this.httpClient.get<Job[]>(this.baseUrl + 'server/jobs');
} }
convertBookmarks() { convertMedia() {
return this.httpClient.post(this.baseUrl + 'server/convert-bookmarks', {}); return this.httpClient.post(this.baseUrl + 'server/convert-media', {});
}
convertCovers() {
return this.httpClient.post(this.baseUrl + 'server/convert-covers', {});
} }
getMediaErrors() { getMediaErrors() {

View File

@ -0,0 +1,7 @@
export enum EncodeFormat {
PNG = 0,
WebP = 1,
AVIF = 2
}
export const EncodeFormats = [{value: EncodeFormat.PNG, title: 'PNG'}, {value: EncodeFormat.WebP, title: 'WebP'}, {value: EncodeFormat.AVIF, title: 'AVIF'}];

View File

@ -1,3 +1,5 @@
import { EncodeFormat } from "./encode-format";
export interface ServerSettings { export interface ServerSettings {
cacheDirectory: string; cacheDirectory: string;
taskScan: string; taskScan: string;
@ -10,8 +12,7 @@ export interface ServerSettings {
baseUrl: string; baseUrl: string;
bookmarksDirectory: string; bookmarksDirectory: string;
emailServiceUrl: string; emailServiceUrl: string;
convertBookmarkToWebP: boolean; encodeMediaAs: EncodeFormat;
convertCoverToWebP: boolean;
totalBackups: number; totalBackups: number;
totalLogs: number; totalLogs: number;
enableFolderWatching: boolean; enableFolderWatching: boolean;

View File

@ -2,28 +2,17 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2"> <form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
<div class="row g-0"> <div class="row g-0">
<p>WebP can drastically reduce space requirements for files. WebP is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use</a>.</p> <p>WebP/AVIF can drastically reduce space requirements for files. WebP/AVIF is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit <a href="https://caniuse.com/?search=webp" target="_blank" rel="noopener noreferrer">Can I Use WebP</a> or <a href="https://caniuse.com/?search=avif" target="_blank" rel="noopener noreferrer">Can I Use AVIF</a>.
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the conversion to WebP task in Tasks Tab.</div> <b>You cannot convert back to PNG once you've gone to WebP/AVIF. You would need to refresh covers on your libraries to regenerate all covers. Bookmarks and favicons cannot be converted.</b></p>
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the media conversion task in Tasks Tab.</div>
<div class="col-md-6 col-sm-12 mb-3"> <div class="col-md-6 col-sm-12 mb-3">
<label for="bookmark-webp" class="form-label me-1" aria-describedby="settings-convertBookmarkToWebP-help">Save Bookmarks as WebP</label> <label for="settings-media-encodeMediaAs" class="form-label me-1">Save Media As</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertBookmarkToWebPTooltip" role="button" tabindex="0"></i> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>
<ng-template #convertBookmarkToWebPTooltip>When saving bookmarks, convert them to WebP.</ng-template> <ng-template #encodeMediaAsTooltip>All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.</ng-template>
<span class="visually-hidden" id="settings-convertBookmarkToWebP-help"><ng-container [ngTemplateOutlet]="convertBookmarkToWebPTooltip"></ng-container></span> <span class="visually-hidden" id="settings-media-encodeMediaAs-help"><ng-container [ngTemplateOutlet]="encodeMediaAsTooltip"></ng-container></span>
<div class="form-check form-switch"> <select class="form-select" aria-describedby="settings-media-encodeMediaAs-help" formControlName="encodeMediaAs" id="settings-media-encodeMediaAs">
<input id="bookmark-webp" type="checkbox" class="form-check-input" formControlName="convertBookmarkToWebP" role="switch"> <option *ngFor="let format of EncodeFormats" [value]="format.value">{{format.title}}</option>
<label for="bookmark-webp" class="form-check-label" aria-describedby="settings-convertBookmarkToWebP-help">{{settingsForm.get('convertBookmarkToWebP')?.value ? 'WebP' : 'Original' }}</label> </select>
</div>
</div>
<div class="col-md-6 col-sm-12 mb-3">
<label for="cover-webp" class="form-label me-1" aria-describedby="settings-convertCoverToWebP-help">Save Covers as WebP</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertCoverToWebPTooltip" role="button" tabindex="0"></i>
<ng-template #convertCoverToWebPTooltip>When generating covers, convert them to WebP.</ng-template>
<span class="visually-hidden" id="settings-convertCoverToWebP-help"><ng-container [ngTemplateOutlet]="convertBookmarkToWebPTooltip"></ng-container></span>
<div class="form-check form-switch">
<input id="cover-webp" type="checkbox" class="form-check-input" formControlName="convertCoverToWebP" role="switch">
<label for="cover-webp" class="form-check-label" aria-describedby="settings-convertCoverToWebP-help">{{settingsForm.get('convertCoverToWebP')?.value ? 'WebP' : 'Original' }}</label>
</div>
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@ import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings'; import { ServerSettings } from '../_models/server-settings';
import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component'; import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { EncodeFormats } from '../_models/encode-format';
@Component({ @Component({
selector: 'app-manage-media-settings', selector: 'app-manage-media-settings',
@ -16,29 +17,28 @@ export class ManageMediaSettingsComponent implements OnInit {
serverSettings!: ServerSettings; serverSettings!: ServerSettings;
settingsForm: FormGroup = new FormGroup({}); settingsForm: FormGroup = new FormGroup({});
get EncodeFormats() { return EncodeFormats; }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal, ) { } constructor(private settingsService: SettingsService, private toastr: ToastrService, private modalService: NgbModal, ) { }
ngOnInit(): void { ngOnInit(): void {
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => { this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings; this.serverSettings = settings;
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, [Validators.required])); this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, [Validators.required]));
this.settingsForm.addControl('convertCoverToWebP', new FormControl(this.serverSettings.convertCoverToWebP, [Validators.required]));
this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required])); this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required]));
}); });
} }
resetForm() { resetForm() {
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP); this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
this.settingsForm.get('convertCoverToWebP')?.setValue(this.serverSettings.convertCoverToWebP);
this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory); this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory);
this.settingsForm.markAsPristine(); this.settingsForm.markAsPristine();
} }
saveSettings() { saveSettings() {
const modelSettings = Object.assign({}, this.serverSettings); const modelSettings = Object.assign({}, this.serverSettings);
modelSettings.convertBookmarkToWebP = this.settingsForm.get('convertBookmarkToWebP')?.value; modelSettings.encodeMediaAs = parseInt(this.settingsForm.get('encodeMediaAs')?.value, 10);
modelSettings.convertCoverToWebP = this.settingsForm.get('convertCoverToWebP')?.value;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings; this.serverSettings = settings;

View File

@ -50,7 +50,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)])); this.settingsForm.addControl('totalBackups', new FormControl(this.serverSettings.totalBackups, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)])); this.settingsForm.addControl('totalLogs', new FormControl(this.serverSettings.totalLogs, [Validators.required, Validators.min(1), Validators.max(30)]));
this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required])); this.settingsForm.addControl('enableFolderWatching', new FormControl(this.serverSettings.enableFolderWatching, [Validators.required]));
this.settingsForm.addControl('convertBookmarkToWebP', new FormControl(this.serverSettings.convertBookmarkToWebP, [])); this.settingsForm.addControl('encodeMediaAs', new FormControl(this.serverSettings.encodeMediaAs, []));
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)])); this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)]));
this.serverService.getServerInfo().subscribe(info => { this.serverService.getServerInfo().subscribe(info => {
@ -76,7 +76,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('totalBackups')?.setValue(this.serverSettings.totalBackups); this.settingsForm.get('totalBackups')?.setValue(this.serverSettings.totalBackups);
this.settingsForm.get('totalLogs')?.setValue(this.serverSettings.totalLogs); this.settingsForm.get('totalLogs')?.setValue(this.serverSettings.totalLogs);
this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching); this.settingsForm.get('enableFolderWatching')?.setValue(this.serverSettings.enableFolderWatching);
this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP); this.settingsForm.get('encodeMediaAs')?.setValue(this.serverSettings.encodeMediaAs);
this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName); this.settingsForm.get('hostName')?.setValue(this.serverSettings.hostName);
this.settingsForm.markAsPristine(); this.settingsForm.markAsPristine();
} }

View File

@ -34,16 +34,10 @@ export class ManageTasksSettingsComponent implements OnInit {
recurringTasks$: Observable<Array<Job>> = of([]); recurringTasks$: Observable<Array<Job>> = of([]);
adhocTasks: Array<AdhocTask> = [ adhocTasks: Array<AdhocTask> = [
{ {
name: 'Convert Bookmarks to WebP', name: 'Convert Media to Target Encoding',
description: 'Runs a long-running task which will convert all bookmarks to WebP. This is slow (especially on ARM devices).', description: 'Runs a long-running task which will convert all kavita-managed media to the target encoding. This is slow (especially on ARM devices).',
api: this.serverService.convertBookmarks(), api: this.serverService.convertMedia(),
successMessage: 'Conversion of Bookmarks has been queued' successMessage: 'Conversion of Media to Target Encoding has been queued'
},
{
name: 'Convert Covers to WebP',
description: 'Runs a long-running task which will convert all existing covers to WebP. This is slow (especially on ARM devices).',
api: this.serverService.convertCovers(),
successMessage: 'Conversion of Covers has been queued'
}, },
{ {
name: 'Clear Cache', name: 'Clear Cache',
@ -144,12 +138,6 @@ export class ManageTasksSettingsComponent implements OnInit {
}); });
} }
runAdhocConvert() {
this.serverService.convertBookmarks().subscribe(() => {
this.toastr.success('Conversion of Bookmarks has been queued.');
});
}
runAdhoc(task: AdhocTask) { runAdhoc(task: AdhocTask) {
task.api.subscribe((data: any) => { task.api.subscribe((data: any) => {
if (task.successMessage.length > 0) { if (task.successMessage.length > 0) {
@ -159,6 +147,8 @@ export class ManageTasksSettingsComponent implements OnInit {
if (task.successFunction) { if (task.successFunction) {
task.successFunction(data); task.successFunction(data);
} }
}, (err: any) => {
console.error('error: ', err);
}); });
} }

View File

@ -357,10 +357,14 @@
</div> </div>
</div> </div>
<div class="col-lg-2"> <div class="col-lg-2">
<button class="btn btn-secondary" (click)="addWebLink()"> <button class="btn btn-secondary me-1" (click)="addWebLink()">
<i class="fa-solid fa-plus" aria-hidden="true"></i> <i class="fa-solid fa-plus" aria-hidden="true"></i>
<span class="visually-hidden">Add Link</span> <span class="visually-hidden">Add Link</span>
</button> </button>
<button class="btn btn-secondary" (click)="removeWebLink(i)">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden">Remove Link</span>
</button>
</div> </div>
</div> </div>
</ng-template> </ng-template>

View File

@ -175,7 +175,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear); this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear);
this.WebLinks.forEach((link, index) => { this.WebLinks.forEach((link, index) => {
this.editSeriesForm.addControl('link' + index, new FormControl(link, [Validators.required])); this.editSeriesForm.addControl('link' + index, new FormControl(link, []));
}); });
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -521,7 +521,16 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
addWebLink() { addWebLink() {
this.metadata.webLinks += ','; this.metadata.webLinks += ',';
this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', [Validators.required])); this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', []));
this.cdRef.markForCheck();
}
removeWebLink(index: number) {
const tokens = this.metadata.webLinks.split(',');
const tokenToRemove = tokens[index];
this.metadata.webLinks = tokens.filter(t => t != tokenToRemove).join(',');
this.editSeriesForm.removeControl('link' + index, {emitEvent: true});
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }

View File

@ -128,7 +128,7 @@
</button> </button>
</div> </div>
<div class="col-auto ms-2"> <div class="col-auto ms-2">
<ngb-rating class="rating-star" [(rate)]="series.userRating" (rateChange)="updateRating($event)" (click)="promptToReview()" [resettable]="false"> <ngb-rating class="rating-star" [(rate)]="series.userRating" (rateChange)="updateRating($event)" [resettable]="false">
<ng-template let-fill="fill" let-index="index"> <ng-template let-fill="fill" let-index="index">
<span class="star" [class.filled]="(index < series.userRating) && series.userRating > 0">&#9733;</span> <span class="star" [class.filled]="(index < series.userRating) && series.userRating > 0">&#9733;</span>
</ng-template> </ng-template>

View File

@ -740,19 +740,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
}); });
} }
async promptToReview() {
// TODO: After a review has been set, we might just want to show an edit icon next to star rating which opens the review, instead of prompting each time.
const shouldPrompt = this.isNullOrEmpty(this.series.userReview);
const config = new ConfirmConfig();
config.header = 'Confirm';
config.content = 'Do you want to write a review?';
config.buttons.push({text: 'No', type: 'secondary'});
config.buttons.push({text: 'Yes', type: 'primary'});
if (shouldPrompt && await this.confirmService.confirm('Do you want to write a review?', config)) {
this.openReviewModal();
}
}
openReviewModal(force = false) { openReviewModal(force = false) {
const modalRef = this.modalService.open(ReviewSeriesModalComponent, { scrollable: true, size: 'lg' }); const modalRef = this.modalService.open(ReviewSeriesModalComponent, { scrollable: true, size: 'lg' });
modalRef.componentInstance.series = this.series; modalRef.componentInstance.series = this.series;

View File

@ -15,7 +15,7 @@
<meta name="theme-color" content="#000000"> <meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-status-bar-style" content="#000000"> <meta name="apple-mobile-web-app-status-bar-style" content="#000000">
<!-- Don't allow indexing from Bots --> <!-- Don't allow indexing from Bots -->
<meta name='robots' content='none' /> <meta name="robots" content="noindex,nofollow">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.7.2.3" "version": "0.7.2.6"
}, },
"servers": [ "servers": [
{ {
@ -7490,25 +7490,12 @@
} }
} }
}, },
"/api/Server/convert-bookmarks": { "/api/Server/convert-media": {
"post": { "post": {
"tags": [ "tags": [
"Server" "Server"
], ],
"summary": "Triggers the scheduling of the convert bookmarks job. Only one job will run at a time.", "summary": "Triggers the scheduling of the convert media job. This will convert all media to the target encoding (except for PNG). Only one job will run at a time.",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Server/convert-covers": {
"post": {
"tags": [
"Server"
],
"summary": "Triggers the scheduling of the convert covers job. Only one job will run at a time.",
"responses": { "responses": {
"200": { "200": {
"description": "Success" "description": "Success"
@ -11451,6 +11438,15 @@
"additionalProperties": false, "additionalProperties": false,
"description": "Represents if Test Email Service URL was successful or not and if any error occured" "description": "Represents if Test Email Service URL was successful or not and if any error occured"
}, },
"EncodeFormat": {
"enum": [
0,
1,
2
],
"type": "integer",
"format": "int32"
},
"FileDimensionDto": { "FileDimensionDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -14137,10 +14133,6 @@
"description": "Total number of People in the instance", "description": "Total number of People in the instance",
"format": "int32" "format": "int32"
}, },
"storeBookmarksAsWebP": {
"type": "boolean",
"description": "Is this instance storing bookmarks as WebP"
},
"usersOnCardLayout": { "usersOnCardLayout": {
"type": "integer", "type": "integer",
"description": "Number of users on this instance using Card Layout", "description": "Number of users on this instance using Card Layout",
@ -14236,9 +14228,8 @@
"description": "Total reading hours of all users", "description": "Total reading hours of all users",
"format": "int64" "format": "int64"
}, },
"storeCoversAsWebP": { "encodeMediaAs": {
"type": "boolean", "$ref": "#/components/schemas/EncodeFormat"
"description": "Is the Server saving covers as WebP"
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -14306,9 +14297,8 @@
"description": "Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs.", "description": "Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs.",
"nullable": true "nullable": true
}, },
"convertBookmarkToWebP": { "encodeMediaAs": {
"type": "boolean", "$ref": "#/components/schemas/EncodeFormat"
"description": "If the server should save bookmarks as WebP encoding"
}, },
"totalBackups": { "totalBackups": {
"type": "integer", "type": "integer",
@ -14324,10 +14314,6 @@
"description": "Total number of days worth of logs to keep at a given time.", "description": "Total number of days worth of logs to keep at a given time.",
"format": "int32" "format": "int32"
}, },
"convertCoverToWebP": {
"type": "boolean",
"description": "If the server should save covers as WebP encoding"
},
"hostName": { "hostName": {
"type": "string", "type": "string",
"description": "The Host name (ie Reverse proxy domain name) for the server", "description": "The Host name (ie Reverse proxy domain name) for the server",