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.Linq;
using API.Archive;
using API.Entities.Enums;
using API.Services;
using Microsoft.Extensions.Logging;
using NetVips;
@ -178,7 +179,7 @@ public class ArchiveServiceTests
_directoryService.ExistOrCreate(outputDir);
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));
@ -208,7 +209,7 @@ public class ArchiveServiceTests
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress);
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 expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
Assert.Equal(expectedBytes, actualBytes);
@ -222,13 +223,14 @@ public class ArchiveServiceTests
public void CanParseCoverImage(string inputFile)
{
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 testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/");
var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile));
var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output");
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);
new DirectoryInfo(outputPath).Delete();
}

View File

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

View File

@ -42,7 +42,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
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;
}

View File

@ -43,7 +43,7 @@ internal class MockReadingItemService : IReadingItemService
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;
}

View File

@ -163,27 +163,26 @@ public class ImageController : BaseApiController
/// <summary>
/// Returns the image associated with a web-link
/// </summary>
/// <param name="chapterId"></param>
/// <param name="pageNum"></param>
/// <param name="apiKey"></param>
/// <returns></returns>
[HttpGet("web-link")]
[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);
if (userId == 0) return BadRequest();
if (string.IsNullOrEmpty(url)) return BadRequest("Url cannot be null");
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
// 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))
{
// We need to request the favicon and save it
try
{
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory,
await _imageService.DownloadFaviconAsync(url));
await _imageService.DownloadFaviconAsync(url, encodeFormat));
}
catch (Exception)
{

View File

@ -8,6 +8,7 @@ using API.DTOs.Jobs;
using API.DTOs.MediaErrors;
using API.DTOs.Stats;
using API.DTOs.Update;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
@ -119,29 +120,22 @@ public class ServerController : BaseApiController
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>
/// 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>
/// <returns></returns>
[HttpPost("convert-covers")]
public ActionResult ScheduleConvertCovers()
[HttpPost("convert-media")]
public async Task<ActionResult> ScheduleConvertCovers()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToWebP());
var encoding = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (encoding == EncodeFormat.PNG)
{
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();
}

View File

@ -231,15 +231,9 @@ public class SettingsController : BaseApiController
_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;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.ConvertCoverToWebP && updateSettingsDto.ConvertCoverToWebP + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.ConvertCoverToWebP + string.Empty;
setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty;
_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)
{
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
if (thumbnailSize > 0)
{
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, convertToWebP, thumbnailSize);
filename, encodeFormat, thumbnailSize);
}
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
filename, convertToWebP);
filename, encodeFormat);
}
/// <summary>

View File

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

View File

@ -85,11 +85,6 @@ public class ServerInfoDto
/// <remarks>Introduced in v0.5.4</remarks>
public int TotalPeople { get; set; }
/// <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
/// </summary>
/// <remarks>Introduced in v0.5.4</remarks>
@ -175,8 +170,8 @@ public class ServerInfoDto
/// <remarks>Introduced in v0.7.0</remarks>
public long TotalReadingHours { get; set; }
/// <summary>
/// Is the Server saving covers as WebP
/// The encoding the server is using to save media
/// </summary>
/// <remarks>Added in v0.7.0</remarks>
public bool StoreCoversAsWebP { get; set; }
/// <remarks>Added in v0.7.3</remarks>
public EncodeFormat EncodeMediaAs { get; set; }
}

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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 Microsoft.AspNetCore.Identity;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// Added in v0.7.1.18

View File

@ -3,7 +3,7 @@ using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <summary>
/// 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 API.Services.Tasks;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <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

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.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
internal sealed class SeriesRelationMigrationOutput
{

View File

@ -8,7 +8,7 @@ using CsvHelper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <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.

View File

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

View File

@ -1,7 +1,7 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace API.Data;
namespace API.Data.ManualMigrations;
/// <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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ public interface IReadingListRepository
Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> ReadingListExists(string name);
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
Task<IList<ReadingList>> GetAllWithNonWebPCovers();
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IList<string>> GetFirstFourCoverImagesByReadingListId(int readingListId);
Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
@ -110,10 +110,11 @@ public class ReadingListRepository : IReadingListRepository
.AsEnumerable();
}
public async Task<IList<ReadingList>> GetAllWithNonWebPCovers()
public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{
var extension = encodeFormat.GetExtension();
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();
}

View File

@ -4,6 +4,7 @@ using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Data.ManualMigrations;
using API.Data.Misc;
using API.Data.Scanner;
using API.DTOs;
@ -132,7 +133,7 @@ public interface ISeriesRepository
Task<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
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
@ -565,12 +566,14 @@ public class SeriesRepository : ISeriesRepository
/// Returns custom images only
/// </summary>
/// <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);
return await _context.Series
.Where(c => !string.IsNullOrEmpty(c.CoverImage)
&& !c.CoverImage.EndsWith(".webp")
&& !c.CoverImage.EndsWith(extension)
&& (!customOnly || c.CoverImage.StartsWith(prefix)))
.ToListAsync();
}

View File

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

View File

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

View File

@ -101,12 +101,11 @@ public static class Seed
new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()},
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
new() {Key = ServerSettingKey.TotalBackups, Value = "30"},
new() {Key = ServerSettingKey.TotalLogs, Value = "30"},
new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"},
new() {Key = ServerSettingKey.ConvertCoverToWebP, Value = "false"},
new() {Key = ServerSettingKey.HostName, Value = string.Empty},
new() {Key = ServerSettingKey.EncodeMediaAs, Value = EncodeFormat.PNG.ToString()},
}.ToArray());
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;
@ -82,6 +83,7 @@ public enum ServerSettingKey
/// <summary>
/// If Kavita should save bookmarks as WebP images
/// </summary>
[Obsolete("Use EncodeMediaAs instead")]
[Description("ConvertBookmarkToWebP")]
ConvertBookmarkToWebP = 14,
/// <summary>
@ -102,6 +104,7 @@ public enum ServerSettingKey
/// <summary>
/// If Kavita should save covers as WebP images
/// </summary>
[Obsolete("Use EncodeMediaAs instead")]
[Description("ConvertCoverToWebP")]
ConvertCoverToWebP = 19,
/// <summary>
@ -114,4 +117,11 @@ public enum ServerSettingKey
/// </summary>
[Description("IpAddresses")]
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<IStatisticService, StatisticService>();
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IScannerService, ScannerService>();
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.Entities;
using API.Entities.Enums;
@ -51,11 +52,8 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
case ServerSettingKey.InstallVersion:
destination.InstallVersion = row.Value;
break;
case ServerSettingKey.ConvertBookmarkToWebP:
destination.ConvertBookmarkToWebP = bool.Parse(row.Value);
break;
case ServerSettingKey.ConvertCoverToWebP:
destination.ConvertCoverToWebP = bool.Parse(row.Value);
case ServerSettingKey.EncodeMediaAs:
destination.EncodeMediaAs = Enum.Parse<EncodeFormat>(row.Value);
break;
case ServerSettingKey.TotalBackups:
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
/// </summary>
/// <param name="role"></param>
/// <param name="tags"></param>
/// <param name="people"></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="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)
{
if (tags == null) return;
if (people == null) return;
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
var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList();
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
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.
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 (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.Threading.Tasks;
using API.Data;
using API.Data.ManualMigrations;
using API.Entities;
using API.Entities.Enums;
using API.Logging;

View File

@ -7,6 +7,7 @@ using System.Linq;
using System.Xml.Serialization;
using API.Archive;
using API.Data.Metadata;
using API.Entities.Enums;
using API.Extensions;
using API.Services.Tasks;
using Kavita.Common;
@ -20,7 +21,7 @@ public interface IArchiveService
{
void ExtractArchive(string archivePath, string extractPath);
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);
ComicInfo? GetComicInfo(string archivePath);
ArchiveLibrary CanOpen(string archivePath);
@ -201,9 +202,9 @@ public class ArchiveService : IArchiveService
/// <param name="archivePath"></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="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param>
/// <param name="encodeFormat">When saving the file, use encoding</param>
/// <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;
try
@ -219,7 +220,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.FullName == entryName);
using var stream = entry.Open();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
}
case ArchiveLibrary.SharpCompress:
{
@ -230,7 +231,7 @@ public class ArchiveService : IArchiveService
var entry = archive.Entries.Single(e => e.Key == entryName);
using var stream = entry.OpenEntryStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
}
case ArchiveLibrary.NotSupported:
_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()
{
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
});
}

View File

@ -34,7 +34,7 @@ namespace API.Services;
public interface IBookService
{
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);
ParserInfo? ParseInfo(string filePath);
/// <summary>
@ -1062,15 +1062,15 @@ public class BookService : IBookService
/// <param name="fileFilePath"></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="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param>
/// <param name="encodeFormat">When saving the file, use encoding</param>
/// <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 (Parser.IsPdf(fileFilePath))
{
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, saveAsWebP);
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat);
}
using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions);
@ -1085,7 +1085,7 @@ public class BookService : IBookService
if (coverImageContent == null) return string.Empty;
using var stream = coverImageContent.GetContentStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
}
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
{
@ -1108,7 +1108,7 @@ public class BookService : IBookService
using var stream = StreamManager.GetStream("BookService.GetPdfPage");
GetPdfPage(docReader, 0, stream);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP);
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat);
}
catch (Exception ex)

View File

@ -7,7 +7,6 @@ using API.Data;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.SignalR;
using Hangfire;
using Microsoft.Extensions.Logging;
@ -19,9 +18,6 @@ public interface IBookmarkService
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToWebP();
Task ConvertAllCoverToWebP();
}
public class BookmarkService : IBookmarkService
@ -30,17 +26,15 @@ public class BookmarkService : IBookmarkService
private readonly ILogger<BookmarkService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly IEventHub _eventHub;
private readonly IMediaConversionService _mediaConversionService;
public BookmarkService(ILogger<BookmarkService> logger, IUnitOfWork unitOfWork,
IDirectoryService directoryService, IImageService imageService, IEventHub eventHub)
IDirectoryService directoryService, IMediaConversionService mediaConversionService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_directoryService = directoryService;
_imageService = imageService;
_eventHub = eventHub;
_mediaConversionService = mediaConversionService;
}
/// <summary>
@ -77,21 +71,25 @@ public class BookmarkService : IBookmarkService
/// This is a job that runs after a bookmark is saved
/// </summary>
/// <remarks>This must be public</remarks>
public async Task ConvertBookmarkToWebP(int bookmarkId)
public async Task ConvertBookmarkToEncoding(int bookmarkId)
{
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var convertBookmarkToWebP =
(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP;
var encodeFormat =
(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
var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId);
if (bookmark == null) return;
bookmark.FileName = await SaveAsWebP(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId));
bookmark.FileName = await _mediaConversionService.SaveAsEncodingFormat(bookmarkDirectory, bookmark.FileName,
BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId), encodeFormat);
_unitOfWork.UserRepository.Update(bookmark);
await _unitOfWork.CommitAsync();
@ -137,10 +135,10 @@ public class BookmarkService : IBookmarkService
_unitOfWork.UserRepository.Add(bookmark);
await _unitOfWork.CommitAsync();
if (settings.ConvertBookmarkToWebP)
if (settings.EncodeMediaAs == EncodeFormat.WEBP)
{
// Enqueue a task to convert the bookmark to webP
BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id));
BackgroundJob.Enqueue(() => ConvertBookmarkToEncoding(bookmark.Id));
}
}
catch (Exception ex)
@ -192,198 +190,9 @@ public class BookmarkService : IBookmarkService
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>
/// 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)
public static string BookmarkStem(int userId, int seriesId, int chapterId)
{
return Path.Join($"{userId}", $"{seriesId}", $"{chapterId}");
}

View File

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Extensions;
using Flurl;
using Flurl.Http;
using HtmlAgilityPack;
@ -16,49 +18,49 @@ namespace API.Services;
public interface IImageService
{
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>
/// Creates a Thumbnail version of a base64 image
/// </summary>
/// <param name="encodedImage">base64 encoded image</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>
/// <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>
/// Writes out a thumbnail by stream input
/// </summary>
/// <param name="stream"></param>
/// <param name="fileName"></param>
/// <param name="outputDirectory"></param>
/// <param name="saveAsWebP"></param>
/// <param name="encodeFormat"></param>
/// <returns></returns>
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false);
string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <summary>
/// Writes out a thumbnail by file path input
/// </summary>
/// <param name="sourceFile"></param>
/// <param name="fileName"></param>
/// <param name="outputDirectory"></param>
/// <param name="saveAsWebP"></param>
/// <param name="encodeFormat"></param>
/// <returns></returns>
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, bool saveAsWebP = false);
string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat);
/// <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>
/// <param name="filePath">Full path to the image to convert</param>
/// <param name="outputPath">Where to output the file</param>
/// <returns>File of written webp image</returns>
Task<string> ConvertToWebP(string filePath, string outputPath);
/// <returns>File of written encoded image</returns>
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
Task<bool> IsImage(string filePath);
Task<string> DownloadFaviconAsync(string url);
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
}
public class ImageService : IImageService
{
public const string Name = "BookmarkService";
private readonly ILogger<ImageService> _logger;
private readonly IDirectoryService _directoryService;
public const string ChapterCoverImageRegex = @"v\d+_c\d+";
@ -75,6 +77,20 @@ public class ImageService : IImageService
/// </summary>
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)
{
_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;
try
{
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));
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="fileName">filename to save as without extension</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>
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);
var filename = fileName + (saveAsWebP ? ".webp" : ".png");
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
{
@ -137,10 +153,10 @@ public class ImageService : IImageService
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);
var filename = fileName + (saveAsWebP ? ".webp" : ".png");
var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory);
try
{
@ -150,11 +166,11 @@ public class ImageService : IImageService
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 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);
sourceImage.WriteToFile(outputFile);
@ -183,24 +199,26 @@ public class ImageService : IImageService
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)
var uri = new Uri(url);
var domain = uri.Host;
var baseUrl = uri.Scheme + "://" + uri.Host;
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
{
url = value;
}
try
{
var validIconRelations = new[]
{
"icon",
"apple-touch-icon",
};
var htmlContent = url.GetStringAsync().Result;
var htmlDocument = new HtmlDocument();
htmlDocument.LoadHtml(htmlContent);
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))
.Where(href => href.EndsWith(".png") || href.EndsWith(".PNG"))
.ToList();
@ -228,9 +246,23 @@ public class ImageService : IImageService
.GetStreamAsync();
// Create the destination file path
var filename = $"{domain}.png";
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);
return filename;
@ -242,14 +274,13 @@ public class ImageService : IImageService
}
}
/// <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
{
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));
return fileName;
}
@ -309,6 +340,7 @@ public class ImageService : IImageService
/// <returns></returns>
public static string GetReadingListFormat(int readingListId)
{
// ReSharper disable once StringLiteralTypo
return $"readinglist{readingListId}";
}
@ -322,9 +354,9 @@ public class ImageService : IImageService
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.Data;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.SignalR;
@ -32,7 +33,7 @@ public interface IMetadataService
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
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();
}
@ -63,8 +64,8 @@ public class MetadataService : IMetadataService
/// </summary>
/// <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="convertToWebPOnWrite">Convert image to WebP when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, bool convertToWebPOnWrite)
/// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat)
{
var firstFile = chapter.Files.MinBy(x => x.Chapter);
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);
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);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter));
return Task.FromResult(true);
@ -141,8 +142,8 @@ public class MetadataService : IMetadataService
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate"></param>
/// <param name="convertToWebP"></param>
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, bool convertToWebP)
/// <param name="encodeFormat"></param>
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat)
{
_logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName);
try
@ -155,7 +156,7 @@ public class MetadataService : IMetadataService
var index = 0;
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
UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated);
if (index == 0 && chapterUpdated)
@ -207,7 +208,7 @@ public class MetadataService : IMetadataService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
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++)
{
@ -237,7 +238,7 @@ public class MetadataService : IMetadataService
try
{
await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP);
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
}
catch (Exception ex)
{
@ -287,23 +288,23 @@ public class MetadataService : IMetadataService
return;
}
var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP;
await GenerateCoversForSeries(series, convertToWebP, forceUpdate);
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
await GenerateCoversForSeries(series, encodeFormat, forceUpdate);
}
/// <summary>
/// Generate Cover for a Series. This is used by Scan Loop and should not be invoked directly via User Interaction.
/// </summary>
/// <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>
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();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP);
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat);
if (_unitOfWork.HasChanges())

View File

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

View File

@ -9,7 +9,7 @@ public interface IReadingItemService
{
ComicInfo? GetComicInfo(string filePath);
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);
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))
{
@ -171,10 +171,10 @@ public class ReadingItemService : IReadingItemService
return format switch
{
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP),
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP),
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP),
MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, saveAsWebP),
MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory, encodeFormat),
_ => string.Empty
};
}

View File

@ -336,7 +336,7 @@ public class ReadingListService : IReadingListService
// .Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList();
//
// 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;
}

View File

@ -31,7 +31,7 @@ public interface ITaskScheduler
void CancelStatsTasks();
Task RunStatCollection();
void ScanSiteThemes();
Task CovertAllCoversToWebP();
Task CovertAllCoversToEncoding();
Task CleanupDbEntries();
}
@ -50,9 +50,9 @@ public class TaskScheduler : ITaskScheduler
private readonly IThemeService _themeService;
private readonly IWordCountAnalyzerService _wordCountAnalyzerService;
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 DefaultQueue = "default";
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 RecurringJobOptions RecurringJobOptions = new RecurringJobOptions()
{
TimeZone = TimeZoneInfo.Local
};
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IBookmarkService bookmarkService)
IMediaConversionService mediaConversionService)
{
_cacheService = cacheService;
_logger = logger;
@ -87,7 +92,7 @@ public class TaskScheduler : ITaskScheduler
_themeService = themeService;
_wordCountAnalyzerService = wordCountAnalyzerService;
_statisticService = statisticService;
_bookmarkService = bookmarkService;
_mediaConversionService = mediaConversionService;
}
public async Task ScheduleTasks()
@ -100,28 +105,28 @@ public class TaskScheduler : ITaskScheduler
var scanLibrarySetting = setting;
_logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting);
RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false),
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local);
() => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions);
}
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;
if (setting != null)
{
_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
{
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(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local);
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local);
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, TimeZoneInfo.Local);
RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions);
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions);
}
#region StatsTasks
@ -137,7 +142,7 @@ public class TaskScheduler : ITaskScheduler
}
_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)
@ -182,10 +187,20 @@ public class TaskScheduler : ITaskScheduler
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();
_logger.LogInformation("[BookmarkService] Queuing tasks to update Series and Volume references via Cover Refresh");
var defaultParams = Array.Empty<object>();
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();
foreach (var lib in libraryIds)
{
@ -200,8 +215,10 @@ public class TaskScheduler : ITaskScheduler
public void ScheduleUpdaterTasks()
{
_logger.LogInformation("Scheduling Auto-Update tasks");
// Schedule update check between noon and 6pm local time
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local);
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(5, 23)), new RecurringJobOptions()
{
TimeZone = TimeZoneInfo.Local
});
}
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)]
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.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty<object>(),
TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToEncoding", Array.Empty<object>(),
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,
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;
}

View File

@ -14,7 +14,7 @@ public static class Parser
private const int RegexTimeoutMs = 5000000; // 500 ms
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";
private const string BookFileExtensions = @"\.epub|\.pdf";
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);
}
await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP);
await _metadataService.GenerateCoversForSeries(series, (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs);
EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id);
}

View File

@ -34,7 +34,7 @@ public class StatsService : IStatsService
private readonly IUnitOfWork _unitOfWork;
private readonly DataContext _context;
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)
{
@ -139,8 +139,7 @@ public class StatsService : IStatsService
TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(),
TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(),
UsingSeriesRelationships = await GetIfUsingSeriesRelationship(),
StoreBookmarksAsWebP = serverSettings.ConvertBookmarkToWebP,
StoreCoversAsWebP = serverSettings.ConvertCoverToWebP,
EncodeMediaAs = serverSettings.EncodeMediaAs,
MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(),
MaxVolumesInASeries = await MaxVolumesInASeries(),
MaxChaptersInASeries = await MaxChaptersInASeries(),
@ -292,14 +291,14 @@ public class StatsService : IStatsService
private IEnumerable<FileFormatDto> AllFormats()
{
// TODO: Rewrite this with new migration code in feature/basic-stats
var results = _context.MangaFile
.AsNoTracking()
.AsEnumerable()
.Select(m => new FileFormatDto()
{
Format = m.Format,
Extension = Path.GetExtension(m.FilePath)?.ToLowerInvariant()!
Extension = m.Extension
})
.DistinctBy(f => f.Extension)
.ToList();

View File

@ -113,13 +113,13 @@ public class VersionUpdaterService : IVersionUpdaterService
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),
true);
}
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),
true);
}

View File

@ -77,9 +77,9 @@ public class TokenService : ITokenService
{
var tokenHandler = new JwtSecurityTokenHandler();
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;
var user = await _userManager.FindByIdAsync(username);
var user = await _userManager.FindByNameAsync(username);
if (user == null) return null; // This forces a logout
var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken);
if (!validated) return null;

View File

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

View File

@ -10,6 +10,7 @@ using System.Threading.RateLimiting;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.ManualMigrations;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -249,6 +250,9 @@ public class Startup
// v0.7.2
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
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
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/=kavitaignore/@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/=noreferrer/@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.currentUserSource.next(user);
this.stopRefreshTokenTimer();
if (this.currentUser !== undefined) {
this.startRefreshTokenTimer();
} else {
this.stopRefreshTokenTimer();
}
}
@ -264,7 +264,6 @@ export class AccountService implements OnDestroy {
private refreshToken() {
if (this.currentUser === null || this.currentUser === undefined) return of();
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
{token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
if (this.currentUser) {
@ -277,23 +276,23 @@ export class AccountService implements OnDestroy {
}));
}
/**
* Every 10 mins refresh the token
*/
private startRefreshTokenTimer() {
if (this.currentUser === null || this.currentUser === undefined) return;
if (this.refreshTokenTimeout !== undefined) {
if (this.currentUser === null || this.currentUser === undefined) {
this.stopRefreshTokenTimer();
return;
}
const jwtToken = JSON.parse(atob(this.currentUser.token.split('.')[1]));
// set a timeout to refresh the token 10 mins before it expires
const expires = new Date(jwtToken.exp * 1000);
const timeout = expires.getTime() - Date.now() - (60 * 10000);
this.refreshTokenTimeout = setTimeout(() => this.refreshToken().subscribe(() => {}), timeout);
this.stopRefreshTokenTimer();
this.refreshTokenTimeout = setInterval(() => this.refreshToken().subscribe(() => {}), (60 * 10_000));
}
private stopRefreshTokenTimer() {
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');
}
convertBookmarks() {
return this.httpClient.post(this.baseUrl + 'server/convert-bookmarks', {});
}
convertCovers() {
return this.httpClient.post(this.baseUrl + 'server/convert-covers', {});
convertMedia() {
return this.httpClient.post(this.baseUrl + 'server/convert-media', {});
}
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 {
cacheDirectory: string;
taskScan: string;
@ -10,8 +12,7 @@ export interface ServerSettings {
baseUrl: string;
bookmarksDirectory: string;
emailServiceUrl: string;
convertBookmarkToWebP: boolean;
convertCoverToWebP: boolean;
encodeMediaAs: EncodeFormat;
totalBackups: number;
totalLogs: number;
enableFolderWatching: boolean;

View File

@ -2,28 +2,17 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined" class="mb-2">
<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>
<div *ngIf="settingsForm.dirty" class="alert alert-danger" role="alert">You must trigger the conversion to WebP task in Tasks Tab.</div>
<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>.
<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">
<label for="bookmark-webp" class="form-label me-1" aria-describedby="settings-convertBookmarkToWebP-help">Save Bookmarks as WebP</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="convertBookmarkToWebPTooltip" role="button" tabindex="0"></i>
<ng-template #convertBookmarkToWebPTooltip>When saving bookmarks, convert them to WebP.</ng-template>
<span class="visually-hidden" id="settings-convertBookmarkToWebP-help"><ng-container [ngTemplateOutlet]="convertBookmarkToWebPTooltip"></ng-container></span>
<div class="form-check form-switch">
<input id="bookmark-webp" type="checkbox" class="form-check-input" formControlName="convertBookmarkToWebP" role="switch">
<label for="bookmark-webp" class="form-check-label" aria-describedby="settings-convertBookmarkToWebP-help">{{settingsForm.get('convertBookmarkToWebP')?.value ? 'WebP' : 'Original' }}</label>
</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>
<label for="settings-media-encodeMediaAs" class="form-label me-1">Save Media As</label>
<i class="fa fa-info-circle" placement="right" [ngbTooltip]="encodeMediaAsTooltip" role="button" tabindex="0"></i>
<ng-template #encodeMediaAsTooltip>All media Kavita manages (covers, bookmarks, favicons) will be encoded as this type.</ng-template>
<span class="visually-hidden" id="settings-media-encodeMediaAs-help"><ng-container [ngTemplateOutlet]="encodeMediaAsTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-media-encodeMediaAs-help" formControlName="encodeMediaAs" id="settings-media-encodeMediaAs">
<option *ngFor="let format of EncodeFormats" [value]="format.value">{{format.title}}</option>
</select>
</div>
</div>

View File

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

View File

@ -34,16 +34,10 @@ export class ManageTasksSettingsComponent implements OnInit {
recurringTasks$: Observable<Array<Job>> = of([]);
adhocTasks: Array<AdhocTask> = [
{
name: 'Convert Bookmarks to WebP',
description: 'Runs a long-running task which will convert all bookmarks to WebP. This is slow (especially on ARM devices).',
api: this.serverService.convertBookmarks(),
successMessage: 'Conversion of Bookmarks 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: 'Convert Media to Target Encoding',
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.convertMedia(),
successMessage: 'Conversion of Media to Target Encoding has been queued'
},
{
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) {
task.api.subscribe((data: any) => {
if (task.successMessage.length > 0) {
@ -159,6 +147,8 @@ export class ManageTasksSettingsComponent implements OnInit {
if (task.successFunction) {
task.successFunction(data);
}
}, (err: any) => {
console.error('error: ', err);
});
}

View File

@ -357,10 +357,14 @@
</div>
</div>
<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>
<span class="visually-hidden">Add Link</span>
</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>
</ng-template>

View File

@ -175,7 +175,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear);
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();
@ -521,7 +521,16 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
addWebLink() {
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();
}

View File

@ -128,7 +128,7 @@
</button>
</div>
<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">
<span class="star" [class.filled]="(index < series.userRating) && series.userRating > 0">&#9733;</span>
</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) {
const modalRef = this.modalService.open(ReviewSeriesModalComponent, { scrollable: true, size: 'lg' });
modalRef.componentInstance.series = this.series;

View File

@ -15,7 +15,7 @@
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-status-bar-style" content="#000000">
<!-- 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="mobile-web-app-capable" content="yes">

View File

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