OPDS-PS v1.2 Support + a few bugfixes (#1869)

* Fixed up a localization lookup test case

* Refactored some webp to a unified method

* Cleaned up some code

* Expanded webp conversion for covers to all entities

* Code cleanup

* Prompt the user when they are about to delete multiple series via bulk actions

* Aligned Kavita to OPDS-PS 1.2.

* Fixed a bug where clearing metadata filter of series name didn't clear the actual field.

* Added some documentation

* Refactored how covert covers to webp works. Now we will handle all custom covers for all entities. Volumes and Series will not be touched but instead be updated via a RefreshCovers call. This will fix up the references much faster.

* Fixed up the OPDS-PS 1.2 attributes to only show on PS links
This commit is contained in:
Joe Milazzo 2023-03-09 18:41:42 -06:00 committed by GitHub
parent 1f34068662
commit 47269b4c51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 334 additions and 99 deletions

View File

@ -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<FolderPath>()
{
new FolderPath() {Path = "C:/data/manga/"}
},
Series = new List<Series>()
{
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<Series>()
{
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);

View File

@ -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;
}
/// <summary>
/// This returns a streamed image following OPDS-PS v1.2
/// </summary>
/// <param name="apiKey"></param>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="chapterId"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/image")]
public async Task<ActionResult> 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<FeedLink> 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;
}

View File

@ -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);
}

View File

@ -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;
}
/// <summary>

View File

@ -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<ServerController> 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;
}
/// <summary>
@ -151,7 +152,7 @@ public class ServerController : BaseApiController
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP());
BackgroundJob.Enqueue(() => _taskScheduler.CovertAllCoversToWebP());
return Ok();
}

View File

@ -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<string> 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); ;
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>
@ -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))
{

View File

@ -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/")]

View File

@ -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; }
/// <summary>
/// Relation on the Link
/// </summary>
@ -25,6 +28,34 @@ public class FeedLink
[XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")]
public int TotalPages { get; set; }
/// <summary>
/// lastRead MUST provide the last page read for this document. The numbering starts at 1.
/// </summary>
[XmlAttribute("lastRead", Namespace = "http://vaemendis.net/opds-pse/ns")]
public int LastRead { get; set; } = -1;
/// <summary>
/// lastReadDate MAY provide the date of when the lastRead attribute was last updated.
/// </summary>
/// <remarks>Attribute MUST conform Atom's Date construct</remarks>
[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;

View File

@ -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".
/// </summary>
public string? BookScrollId { get; set; }
public DateTime LastModifiedUtc { get; set; }
}

View File

@ -46,10 +46,10 @@ public class UserPreferencesDto
/// </summary>
[Required]
public string BackgroundColor { get; set; } = "#000000";
[Required]
/// <summary>
/// Manga Reader Option: Should swiping trigger pagination
/// </summary>
[Required]
public bool SwipeToPaginate { get; set; }
/// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction

View File

@ -33,6 +33,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();
}
public class CollectionTagRepository : ICollectionTagRepository
{
@ -106,6 +107,13 @@ public class CollectionTagRepository : ICollectionTagRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
}
public async Task<IList<CollectionTag>> GetAllWithNonWebPCovers()
{
return await _context.CollectionTag
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
.ToListAsync();
}
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
{

View File

@ -51,6 +51,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();
}
public class LibraryRepository : ILibraryRepository
@ -368,4 +369,11 @@ public class LibraryRepository : ILibraryRepository
return dict;
}
public async Task<IList<Library>> GetAllWithNonWebPCovers()
{
return await _context.Library
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
.ToListAsync();
}
}

View File

@ -36,6 +36,7 @@ public interface IReadingListRepository
Task<bool> ReadingListExists(string name);
Task<List<ReadingList>> GetAllReadingListsAsync();
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
Task<IList<ReadingList>> GetAllWithNonWebPCovers();
}
public class ReadingListRepository : IReadingListRepository
@ -106,6 +107,13 @@ public class ReadingListRepository : IReadingListRepository
.AsEnumerable();
}
public async Task<IList<ReadingList>> 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);

View File

@ -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<IDictionary<int, int>> GetLibraryIdsForSeriesAsync();
Task<IList<SeriesMetadataDto>> GetSeriesMetadataForIds(IEnumerable<int> seriesIds);
Task<IList<Series>> GetAllWithNonWebPCovers(bool customOnly = true);
}
public class SeriesRepository : ISeriesRepository
@ -560,6 +562,21 @@ public class SeriesRepository : ISeriesRepository
}
/// <summary>
/// Returns custom images only
/// </summary>
/// <returns></returns>
public async Task<IList<Series>> 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<SeriesDto> series)
{
var userProgress = await _context.AppUserProgresses
@ -1262,38 +1279,40 @@ public class SeriesRepository : ISeriesRepository
/// <param name="format"></param>
/// <param name="withFullIncludes">Defaults to true. This will query against all foreign keys (deep). If false, just the series will come back</param>
/// <returns></returns>
public Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true)
public Task<Series?> 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
}

View File

@ -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<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
Task<Volume?> GetVolumeByIdAsync(int volumeId);
Task<IList<Volume>> 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<IList<Volume>> GetAllWithNonWebPCovers()
{
return await _context.Volume
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(".webp"))
.ToListAsync();
}
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{

View File

@ -83,7 +83,6 @@ public static class TagHelper
/// <remarks>Used to remove before we update/add new tags</remarks>
/// <param name="existingTags">Existing tags on Entity</param>
/// <param name="tags">Tags from metadata</param>
/// <param name="isExternal">Remove external tags?</param>
/// <param name="action">Callback which will be executed for each tag removed</param>
public static void RemoveTags(ICollection<Tag> existingTags, IEnumerable<string> tags, Action<Tag>? action = null)
{

View File

@ -13,14 +13,12 @@ public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger, IHostEnvironment env)
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)

View File

@ -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
}
}
}
/// <summary>
/// This is a job that runs after a bookmark is saved
/// </summary>
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();
}
/// <summary>
/// Creates a new entry in the AppUserBookmarks and copies an image to BookmarkDirectory.
/// </summary>
@ -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");
}
/// <summary>
/// This is a job that runs after a bookmark is saved
/// </summary>
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();
}
/// <summary>
/// Converts an image file, deletes original and returns the new path back

View File

@ -125,6 +125,11 @@ public class ImageService : IImageService
return Task.FromResult(outputFile);
}
/// <summary>
/// Performs I/O to determine if the file is a valid Image
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
public async Task<bool> IsImage(string filePath)
{
try

View File

@ -418,7 +418,7 @@ public class ReadingListService : IReadingListService
public async Task<CblImportSummaryDto> 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,

View File

@ -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<TaskScheduler> 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

View File

@ -311,7 +311,7 @@ public class ParseScannedFiles
.ToList();
MergeLocalizedSeriesWithSeries(infos!);
MergeLocalizedSeriesWithSeries(infos);
foreach (var info in infos)
{

View File

@ -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)

View File

@ -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<Series>, callback?: VoidActionCallback) {
async deleteMultipleSeries(seriesIds: Array<Series>, 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);
}
});
}

View File

@ -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();
});

View File

@ -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();

View File

@ -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();
});

View File

@ -318,7 +318,7 @@
<div class="col-md-2 me-3">
<form [formGroup]="seriesNameGroup">
<div class="mb-3">
<label for="series-name" class="form-label">Series Name</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="seriesNameFilterTooltip" role="button" tabindex="0"></i>
<label for="series-name" class="form-label me-1">Series Name</label><i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="seriesNameFilterTooltip" role="button" tabindex="0"></i>
<span class="visually-hidden" id="filter-series-name-help"><ng-container [ngTemplateOutlet]="seriesNameFilterTooltip"></ng-container></span>
<ng-template #seriesNameFilterTooltip>Series name will filter against Name, Sort Name, or Localized Name</ng-template>
<input type="text" id="series-name" formControlName="seriesNameQuery" class="form-control" aria-describedby="filter-series-name-help">

View File

@ -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();

View File

@ -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",