diff --git a/API.Tests/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index 76b1c04a5..3d45d5ed7 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -6,8 +6,10 @@ using System.Threading.Tasks; using API.Data; using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Helpers; using API.Services; +using API.Tests.Helpers.Builders; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -117,20 +119,20 @@ public class SeriesRepositoryTests { var library = new Library() { - Name = "Manga", + Name = "GetFullSeriesByAnyName Manga", Type = LibraryType.Manga, Folders = new List() { new FolderPath() {Path = "C:/data/manga/"} + }, + Series = new List() + { + new SeriesBuilder("The Idaten Deities Know Only Peace") + .WithLocalizedName("Heion Sedai no Idaten-tachi") + .WithFormat(MangaFormat.Archive) + .Build() } - }; - var s = DbFactory.Series("The Idaten Deities Know Only Peace", "Heion Sedai no Idaten-tachi"); - s.Format = MangaFormat.Archive; - - library.Series = new List() - { - s, }; _unitOfWork.LibraryRepository.Add(library); @@ -138,16 +140,18 @@ public class SeriesRepositoryTests } - // This test case isn't ready to go - [InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Archive, "", "The Idaten Deities Know Only Peace")] // Matching on localized name in DB + [Theory] + [InlineData("The Idaten Deities Know Only Peace", MangaFormat.Archive, "", "The Idaten Deities Know Only Peace")] // Matching on series name in DB + [InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Archive, "The Idaten Deities Know Only Peace", "The Idaten Deities Know Only Peace")] // Matching on localized name in DB [InlineData("Heion Sedai no Idaten-tachi", MangaFormat.Pdf, "", null)] public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected) { await ResetDb(); await SetupSeriesData(); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName, - 1, format); + 2, format, false); if (expected == null) { Assert.Null(series); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 57c88142f..b6d3cb220 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -806,7 +806,7 @@ public class OpdsController : BaseApiController CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"), // We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly accLink, - CreatePageStreamLink(series.LibraryId,seriesId, volumeId, chapterId, mangaFile, apiKey) + await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey) }, Content = new FeedEntryContent() { @@ -818,6 +818,16 @@ public class OpdsController : BaseApiController return entry; } + /// + /// This returns a streamed image following OPDS-PS v1.2 + /// + /// + /// + /// + /// + /// + /// + /// [HttpGet("{apiKey}/image")] public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) { @@ -886,10 +896,17 @@ public class OpdsController : BaseApiController throw new KavitaException("User does not exist"); } - private static FeedLink CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) + private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) { - var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); + var userId = await GetUser(apiKey); + var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId); + + var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", + $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); link.TotalPages = mangaFile.Pages; + link.LastRead = progress.PageNum; + link.LastReadDate = progress.LastModifiedUtc; + link.IsPageStream = true; return link; } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 80429eaf3..469b69118 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -103,7 +103,7 @@ public class ReaderController : BaseApiController { var path = _cacheService.GetCachedPagePath(chapter.Id, page); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache."); - var format = Path.GetExtension(path).Replace(".", ""); + var format = Path.GetExtension(path).Replace(".", string.Empty); return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true); } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index e0e2fadb4..e54745abb 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -22,14 +22,12 @@ public class ReadingListController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; - private readonly IDirectoryService _directoryService; - public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService, IDirectoryService directoryService) + public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService) { _unitOfWork = unitOfWork; _eventHub = eventHub; _readingListService = readingListService; - _directoryService = directoryService; } /// diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 8a3b1f08b..1d4caef37 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -30,14 +30,15 @@ public class ServerController : BaseApiController private readonly IVersionUpdaterService _versionUpdaterService; private readonly IStatsService _statsService; private readonly ICleanupService _cleanupService; - private readonly IEmailService _emailService; private readonly IBookmarkService _bookmarkService; private readonly IScannerService _scannerService; private readonly IAccountService _accountService; + private readonly ITaskScheduler _taskScheduler; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, - ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService) + ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService, + ITaskScheduler taskScheduler) { _applicationLifetime = applicationLifetime; _logger = logger; @@ -46,10 +47,10 @@ public class ServerController : BaseApiController _versionUpdaterService = versionUpdaterService; _statsService = statsService; _cleanupService = cleanupService; - _emailService = emailService; _bookmarkService = bookmarkService; _scannerService = scannerService; _accountService = accountService; + _taskScheduler = taskScheduler; } /// @@ -151,7 +152,7 @@ public class ServerController : BaseApiController { if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty(), TaskScheduler.DefaultQueue, true)) return Ok(); - BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP()); + BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToWebP()); return Ok(); } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 465a143cd..55e2074f8 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -93,8 +93,7 @@ public class UploadController : BaseApiController { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); if (series == null) return BadRequest("Invalid Series"); - var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, ImageService.GetSeriesFormat(uploadFileDto.Id), convertToWebP); + var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { @@ -142,8 +141,7 @@ public class UploadController : BaseApiController { var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); if (tag == null) return BadRequest("Invalid Tag id"); - var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}", convertToWebP); + var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { @@ -194,8 +192,7 @@ public class UploadController : BaseApiController { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); if (readingList == null) return BadRequest("Reading list is not valid"); - var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}", convertToWebP); + var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { @@ -222,6 +219,19 @@ public class UploadController : BaseApiController return BadRequest("Unable to save cover image to Reading List"); } + private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) + { + var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; + if (thumbnailSize > 0) + { + return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, + filename, convertToWebP, thumbnailSize); + } + + return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, + filename, convertToWebP); ; + } + /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// @@ -243,8 +253,7 @@ public class UploadController : BaseApiController { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest("Invalid Chapter"); - var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}", convertToWebP); + var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); if (!string.IsNullOrEmpty(filePath)) { @@ -310,9 +319,7 @@ public class UploadController : BaseApiController try { - var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, - $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", convertToWebP, ImageService.LibraryThumbnailWidth); + var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", ImageService.LibraryThumbnailWidth); if (!string.IsNullOrEmpty(filePath)) { diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs index 20f8897a8..76a740b89 100644 --- a/API/DTOs/OPDS/Feed.cs +++ b/API/DTOs/OPDS/Feed.cs @@ -26,7 +26,7 @@ public class Feed public FeedAuthor Author { get; set; } = new FeedAuthor() { Name = "Kavita", - Uri = "https://kavitareader.com" + Uri = "https://www.kavitareader.com" }; [XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs index b4ed730a8..2a9053f16 100644 --- a/API/DTOs/OPDS/FeedLink.cs +++ b/API/DTOs/OPDS/FeedLink.cs @@ -1,9 +1,12 @@ -using System.Xml.Serialization; +using System; +using System.Xml.Serialization; namespace API.DTOs.OPDS; public class FeedLink { + [XmlIgnore] + public bool IsPageStream { get; set; } /// /// Relation on the Link /// @@ -25,6 +28,34 @@ public class FeedLink [XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")] public int TotalPages { get; set; } + /// + /// lastRead MUST provide the last page read for this document. The numbering starts at 1. + /// + [XmlAttribute("lastRead", Namespace = "http://vaemendis.net/opds-pse/ns")] + public int LastRead { get; set; } = -1; + + /// + /// lastReadDate MAY provide the date of when the lastRead attribute was last updated. + /// + /// Attribute MUST conform Atom's Date construct + [XmlAttribute("lastReadDate", Namespace = "http://vaemendis.net/opds-pse/ns")] + public DateTime LastReadDate { get; set; } + + public bool ShouldSerializeLastReadDate() + { + return IsPageStream; + } + + public bool ShouldSerializeLastRead() + { + return LastRead >= 0; + } + + public bool ShouldSerializeTitle() + { + return !string.IsNullOrEmpty(Title); + } + public bool ShouldSerializeTotalPages() { return TotalPages > 0; diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs index 3356a4827..641714234 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/ProgressDto.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; namespace API.DTOs; @@ -19,4 +20,6 @@ public class ProgressDto /// on pages that combine multiple "chapters". /// public string? BookScrollId { get; set; } + + public DateTime LastModifiedUtc { get; set; } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 84579d1ec..ab09329bc 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -46,10 +46,10 @@ public class UserPreferencesDto /// [Required] public string BackgroundColor { get; set; } = "#000000"; - [Required] /// /// Manga Reader Option: Should swiping trigger pagination /// + [Required] public bool SwipeToPaginate { get; set; } /// /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 0de0216e7..86ba4cc7a 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -33,6 +33,7 @@ public interface ICollectionTagRepository Task> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None); Task> GetAllCoverImagesAsync(); Task TagExists(string title); + Task> GetAllWithNonWebPCovers(); } public class CollectionTagRepository : ICollectionTagRepository { @@ -106,6 +107,13 @@ public class CollectionTagRepository : ICollectionTagRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } + public async Task> GetAllWithNonWebPCovers() + { + return await _context.CollectionTag + .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) + .ToListAsync(); + } + public async Task> GetAllTagDtosAsync() { diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index f8cc72449..02081b25e 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -51,6 +51,7 @@ public interface ILibraryRepository Task GetLibraryCoverImageAsync(int libraryId); Task> GetAllCoverImagesAsync(); Task> GetLibraryTypesForIdsAsync(IEnumerable libraryIds); + Task> GetAllWithNonWebPCovers(); } public class LibraryRepository : ILibraryRepository @@ -368,4 +369,11 @@ public class LibraryRepository : ILibraryRepository return dict; } + + public async Task> GetAllWithNonWebPCovers() + { + return await _context.Library + .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index e5ab8e227..3b27caa8f 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -36,6 +36,7 @@ public interface IReadingListRepository Task ReadingListExists(string name); Task> GetAllReadingListsAsync(); IEnumerable GetReadingListCharactersAsync(int readingListId); + Task> GetAllWithNonWebPCovers(); } public class ReadingListRepository : IReadingListRepository @@ -106,6 +107,13 @@ public class ReadingListRepository : IReadingListRepository .AsEnumerable(); } + public async Task> GetAllWithNonWebPCovers() + { + return await _context.ReadingList + .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) + .ToListAsync(); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 26e5177de..a2937665e 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -130,6 +131,7 @@ public interface ISeriesRepository Task> GetLibraryIdsForSeriesAsync(); Task> GetSeriesMetadataForIds(IEnumerable seriesIds); + Task> GetAllWithNonWebPCovers(bool customOnly = true); } public class SeriesRepository : ISeriesRepository @@ -560,6 +562,21 @@ public class SeriesRepository : ISeriesRepository } + /// + /// Returns custom images only + /// + /// + public async Task> GetAllWithNonWebPCovers(bool customOnly = true) + { + var prefix = ImageService.GetSeriesFormat(0).Replace("0", string.Empty); + return await _context.Series + .Where(c => !string.IsNullOrEmpty(c.CoverImage) + && !c.CoverImage.EndsWith(".webp") + && (!customOnly || c.CoverImage.StartsWith(prefix))) + .ToListAsync(); + } + + public async Task AddSeriesModifiers(int userId, List series) { var userProgress = await _context.AppUserProgresses @@ -1262,38 +1279,40 @@ public class SeriesRepository : ISeriesRepository /// /// Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back /// - public Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true) + public Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, + MangaFormat format, bool withFullIncludes = true) { var normalizedSeries = seriesName.ToNormalized(); var normalizedLocalized = localizedName.ToNormalized(); var query = _context.Series .Where(s => s.LibraryId == libraryId) .Where(s => s.Format == format && format != MangaFormat.Unknown) - .Where(s => s.NormalizedName.Equals(normalizedSeries) - || (s.NormalizedLocalizedName == normalizedSeries) - || (s.OriginalName == seriesName)); + .Where(s => + s.NormalizedName.Equals(normalizedSeries) + || s.NormalizedName.Equals(normalizedLocalized) - if (!string.IsNullOrEmpty(normalizedLocalized)) - { - // TODO: Apply WhereIf - query = query.Where(s => - s.NormalizedName.Equals(normalizedLocalized) - || (s.NormalizedLocalizedName != null && s.NormalizedLocalizedName.Equals(normalizedLocalized))); - } + || s.NormalizedLocalizedName.Equals(normalizedSeries) + || (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized)) + || (s.OriginalName != null && s.OriginalName.Equals(seriesName)) + ); if (!withFullIncludes) { return query.SingleOrDefaultAsync(); } #nullable disable - return query.Include(s => s.Metadata) + query = query.Include(s => s.Library) + + .Include(s => s.Metadata) .ThenInclude(m => m.People) .Include(s => s.Metadata) .ThenInclude(m => m.Genres) - .Include(s => s.Library) + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags) + .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(cm => cm.People) @@ -1306,15 +1325,12 @@ public class SeriesRepository : ISeriesRepository .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Genres) - - .Include(s => s.Metadata) - .ThenInclude(m => m.Tags) - .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Files) - .AsSplitQuery() - .SingleOrDefaultAsync(); + + .AsSplitQuery(); + return query.SingleOrDefaultAsync(); #nullable enable } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 4d986ff5f..23ebd45bc 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; using API.Extensions; +using API.Services; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -24,6 +25,7 @@ public interface IVolumeRepository Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); Task GetVolumeByIdAsync(int volumeId); + Task> GetAllWithNonWebPCovers(); } public class VolumeRepository : IVolumeRepository { @@ -195,6 +197,13 @@ public class VolumeRepository : IVolumeRepository return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); } + public async Task> GetAllWithNonWebPCovers() + { + return await _context.Volume + .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp")) + .ToListAsync(); + } + private static void SortSpecialChapters(IEnumerable volumes) { diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index 125f12883..1e89023b2 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -83,7 +83,6 @@ public static class TagHelper /// Used to remove before we update/add new tags /// Existing tags on Entity /// Tags from metadata - /// Remove external tags? /// Callback which will be executed for each tag removed public static void RemoveTags(ICollection existingTags, IEnumerable tags, Action? action = null) { diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index 81238d7a3..98c3c6aec 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -13,14 +13,12 @@ public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; - private readonly IHostEnvironment _env; - public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) + public ExceptionMiddleware(RequestDelegate next, ILogger logger) { _next = next; _logger = logger; - _env = env; } public async Task InvokeAsync(HttpContext context) diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index d23905d50..7188663e4 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -22,8 +22,6 @@ public interface IBookmarkService [DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)] Task ConvertAllBookmarkToWebP(); Task ConvertAllCoverToWebP(); - Task ConvertBookmarkToWebP(int bookmarkId); - } public class BookmarkService : IBookmarkService @@ -74,6 +72,31 @@ public class BookmarkService : IBookmarkService } } } + + /// + /// This is a job that runs after a bookmark is saved + /// + private async Task ConvertBookmarkToWebP(int bookmarkId) + { + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var convertBookmarkToWebP = + (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; + + if (!convertBookmarkToWebP) 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)); + _unitOfWork.UserRepository.Update(bookmark); + + await _unitOfWork.CommitAsync(); + } + + /// /// Creates a new entry in the AppUserBookmarks and copies an image to BookmarkDirectory. /// @@ -206,14 +229,24 @@ public class BookmarkService : IBookmarkService [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 chapters = await _unitOfWork.ChapterRepository.GetAllChaptersWithNonWebPCovers(); + 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; - foreach (var chapter in chapters) + _logger.LogInformation("[BookmarkService] Starting conversion of chapters"); + foreach (var chapter in chapterCovers) { if (string.IsNullOrEmpty(chapter.CoverImage)) continue; @@ -222,38 +255,91 @@ public class BookmarkService : IBookmarkService _unitOfWork.ChapterRepository.Update(chapter); await _unitOfWork.CommitAsync(); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.ConvertCoverProgressEvent(count / chapters.Count, ProgressEventType.Started)); + 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"); } - /// - /// This is a job that runs after a bookmark is saved - /// - public async Task ConvertBookmarkToWebP(int bookmarkId) - { - var bookmarkDirectory = - (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; - var convertBookmarkToWebP = - (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertBookmarkToWebP; - - if (!convertBookmarkToWebP) 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)); - _unitOfWork.UserRepository.Update(bookmark); - - await _unitOfWork.CommitAsync(); - } /// /// Converts an image file, deletes original and returns the new path back diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index b51d1d502..198b16763 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -125,6 +125,11 @@ public class ImageService : IImageService return Task.FromResult(outputFile); } + /// + /// Performs I/O to determine if the file is a valid Image + /// + /// + /// public async Task IsImage(string filePath) { try diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index a87136d1b..103bd8dea 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -418,7 +418,7 @@ public class ReadingListService : IReadingListService public async Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems); - _logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user.UserName); + _logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user!.UserName); var importSummary = new CblImportSummaryDto() { CblName = cblReading.Name, diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 9a7dad1f5..d098e195e 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -30,6 +30,7 @@ public interface ITaskScheduler void CancelStatsTasks(); Task RunStatCollection(); void ScanSiteThemes(); + Task CovertAllCoversToWebP(); } public class TaskScheduler : ITaskScheduler { @@ -46,6 +47,7 @@ public class TaskScheduler : ITaskScheduler private readonly IThemeService _themeService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; private readonly IStatisticService _statisticService; + private readonly IBookmarkService _bookmarkService; public static BackgroundJobServer Client => new BackgroundJobServer(); public const string ScanQueue = "scan"; @@ -66,7 +68,8 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService) + IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, + IBookmarkService bookmarkService) { _cacheService = cacheService; _logger = logger; @@ -80,6 +83,7 @@ public class TaskScheduler : ITaskScheduler _themeService = themeService; _wordCountAnalyzerService = wordCountAnalyzerService; _statisticService = statisticService; + _bookmarkService = bookmarkService; } public async Task ScheduleTasks() @@ -174,6 +178,17 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _themeService.Scan()); } + public async Task CovertAllCoversToWebP() + { + await _bookmarkService.ConvertAllCoverToWebP(); + _logger.LogInformation("[BookmarkService] Queuing tasks to update Series and Volume references via Cover Refresh"); + var libraryIds = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); + foreach (var lib in libraryIds) + { + RefreshMetadata(lib.Id, false); + } + } + #endregion #region UpdateTasks diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index bc44b81ff..098246de1 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -311,7 +311,7 @@ public class ParseScannedFiles .ToList(); - MergeLocalizedSeriesWithSeries(infos!); + MergeLocalizedSeriesWithSeries(infos); foreach (var info in infos) { diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index ba5ad1855..177f35e97 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -907,7 +907,7 @@ public static class Parser public static bool IsImage(string filePath) { - return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath)); + return !filePath.StartsWith('.') && ImageRegex.IsMatch(Path.GetExtension(filePath)); } public static bool IsXml(string filePath) diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 34578ca26..a5c896334 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -537,12 +537,18 @@ export class ActionService implements OnDestroy { * @param chapters? Chapters, should have id * @param callback Optional callback to perform actions after API completes */ - deleteMultipleSeries(seriesIds: Array, callback?: VoidActionCallback) { + async deleteMultipleSeries(seriesIds: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm('Are you sure you want to delete ' + seriesIds.length + ' series? It will not modify files on disk.')) { + if (callback) { + callback(false); + } + return; + } this.seriesService.deleteMultipleSeries(seriesIds.map(s => s.id)).pipe(take(1)).subscribe(() => { this.toastr.success('Series deleted'); if (callback) { - callback(); + callback(true); } }); } diff --git a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts index 0817c55af..a820b419c 100644 --- a/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/_components/all-series/all-series.component.ts @@ -78,7 +78,8 @@ export class AllSeriesComponent implements OnInit, OnDestroy { }); break; case Action.Delete: - this.actionService.deleteMultipleSeries(selectedSeries, () => { + this.actionService.deleteMultipleSeries(selectedSeries, (successful) => { + if (!successful) return; this.loadPage(); this.bulkSelectionService.deselectAll(); }); diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index e0484a38e..787ea8a41 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -103,7 +103,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten }); break; case Action.Delete: - this.actionService.deleteMultipleSeries(selectedSeries, () => { + this.actionService.deleteMultipleSeries(selectedSeries, successful => { + if (!successful) return; this.bulkSelectionService.deselectAll(); this.loadPage(); this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index adf884a6b..c3a62a590 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -95,7 +95,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { }); break; case Action.Delete: - this.actionService.deleteMultipleSeries(selectedSeries, () => { + this.actionService.deleteMultipleSeries(selectedSeries, (successful) => { + if (!successful) return; this.bulkSelectionService.deselectAll(); this.loadPage(); }); diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index c0be87a77..3dea2ebbb 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -318,7 +318,7 @@
-   + Series name will filter against Name, Sort Name, or Localized Name diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index a7c1798e9..a3db6dc67 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -622,6 +622,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { this.readProgressGroup.get('inProgress')?.setValue(true); this.sortGroup.get('sortField')?.setValue(SortField.SortName); this.isAscendingSort = true; + this.seriesNameGroup.get('seriesNameQuery')?.setValue(''); this.cdRef.markForCheck(); // Apply any presets which will trigger the apply this.loadFromPresetsAndSetup(); diff --git a/openapi.json b/openapi.json index 4972715c1..22143c1d8 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.1.12" + "version": "0.7.1.13" }, "servers": [ { @@ -3427,10 +3427,12 @@ "tags": [ "Opds" ], + "summary": "This returns a streamed image following OPDS-PS v1.2", "parameters": [ { "name": "apiKey", "in": "path", + "description": "", "required": true, "schema": { "type": "string" @@ -3439,6 +3441,7 @@ { "name": "libraryId", "in": "query", + "description": "", "schema": { "type": "integer", "format": "int32" @@ -3447,6 +3450,7 @@ { "name": "seriesId", "in": "query", + "description": "", "schema": { "type": "integer", "format": "int32" @@ -3455,6 +3459,7 @@ { "name": "volumeId", "in": "query", + "description": "", "schema": { "type": "integer", "format": "int32" @@ -3463,6 +3468,7 @@ { "name": "chapterId", "in": "query", + "description": "", "schema": { "type": "integer", "format": "int32" @@ -3471,6 +3477,7 @@ { "name": "pageNumber", "in": "query", + "description": "", "schema": { "type": "integer", "format": "int32" @@ -12236,6 +12243,10 @@ "type": "string", "description": "For EPUB reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".", "nullable": true + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" } }, "additionalProperties": false @@ -14993,7 +15004,8 @@ "description": "Manga Reader Option: Background color of the reader" }, "swipeToPaginate": { - "type": "boolean" + "type": "boolean", + "description": "Manga Reader Option: Should swiping trigger pagination" }, "autoCloseMenu": { "type": "boolean",