diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 689327d98..9b6bf212d 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -100,6 +100,7 @@ public class ComicParserTests [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "0")] [InlineData("Superman v1 024 (09-10 1943)", "1")] + [InlineData("Superman v1.5 024 (09-10 1943)", "1.5")] [InlineData("Amazing Man Comics chapter 25", "0")] [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")] [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] @@ -118,6 +119,7 @@ public class ComicParserTests [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "0")] [InlineData("Daredevil - v6 - 10 - (2019)", "6")] + [InlineData("Daredevil - v6.5", "6.5")] // Tome Tests [InlineData("Daredevil - t6 - 10 - (2019)", "6")] [InlineData("Batgirl T2000 #57", "2000")] diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index e3be8dce5..d05cc9113 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -41,7 +41,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService return 1; } - public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format) + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP) { return string.Empty; } diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 5c60baf4d..0ffb61bb8 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -6,9 +6,11 @@ using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Helpers; using API.Helpers.Converters; using API.Services; @@ -129,7 +131,6 @@ public class CleanupServiceTests #endregion - #region DeleteSeriesCoverImages [Fact] @@ -469,6 +470,105 @@ public class CleanupServiceTests #endregion + #region CleanupDbEntries + + [Fact] + public async Task CleanupDbEntries_CleanupAbandonedChapters() + { + var c = EntityFactory.CreateChapter("1", false, new List(), 1); + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + c, + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); + await readerService.MarkChaptersUntilAsRead(user, 1, 5); + await _context.SaveChangesAsync(); + + // Validate correct chapters have read status + Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + // Delete the Chapter + _context.Chapter.Remove(c); + await _unitOfWork.CommitAsync(); + Assert.Single(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + + await cleanupService.CleanupDbEntries(); + + Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + } + + [Fact] + public async Task CleanupDbEntries_RemoveTagsWithoutSeries() + { + var c = new CollectionTag() + { + Title = "Test Tag" + }; + var s = new Series() + { + Name = "Test", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List(), + Metadata = new SeriesMetadata() + { + CollectionTags = new List() + { + c + } + } + }; + _context.Series.Add(s); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + // Delete the Chapter + _context.Series.Remove(s); + await _unitOfWork.CommitAsync(); + + await cleanupService.CleanupDbEntries(); + + Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); + } + + #endregion // #region CleanupBookmarks // // [Fact] diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index d5e235a80..5ead303ae 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -46,7 +46,7 @@ internal class MockReadingItemService : IReadingItemService return 1; } - public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format) + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP) { return string.Empty; } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index f43bcf271..c19af1956 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -19,7 +19,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using TaskScheduler = System.Threading.Tasks.TaskScheduler; +using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers; @@ -119,6 +119,8 @@ public class ServerController : BaseApiController [HttpPost("convert-bookmarks")] public ActionResult ScheduleConvertBookmarks() { + if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty(), + TaskScheduler.DefaultQueue, true)) return Ok(); BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP()); return Ok(); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 739cb6e18..2ea4698e9 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -176,6 +176,12 @@ public class SettingsController : BaseApiController _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.ConvertCoverToWebP && updateSettingsDto.ConvertCoverToWebP + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.ConvertCoverToWebP + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) { diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 20ec4a0c4..c0465a988 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -41,6 +41,13 @@ public class WantToReadController : BaseApiController return Ok(pagedList); } + [HttpGet] + public async Task>> GetWantToRead([FromQuery] int seriesId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(user.Id, seriesId)); + } + /// /// Given a list of Series Ids, add them to the current logged in user's Want To Read list /// diff --git a/API/DTOs/SeriesDetail/SeriesDetailDto.cs b/API/DTOs/SeriesDetail/SeriesDetailDto.cs index 9bc8a97d8..2438755c6 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailDto.cs @@ -24,5 +24,13 @@ public class SeriesDetailDto /// These are chapters that are in Volume 0 and should be read AFTER the volumes /// public IEnumerable StorylineChapters { get; set; } + /// + /// How many chapters are unread + /// + public int UnreadCount { get; set; } + /// + /// How many chapters are there + /// + public int TotalCount { get; set; } } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 041c9300d..332d06a69 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -65,4 +65,8 @@ public class ServerSettingDto /// /// Value should be between 1 and 30 public int TotalLogs { get; set; } + /// + /// If the server should save covers as WebP encoding + /// + public bool ConvertCoverToWebP { get; set; } } diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a5ea582f3..e4fcf5e50 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System.IO; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data.Misc; @@ -12,6 +12,13 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +[Flags] +public enum CollectionTagIncludes +{ + None = 1, + SeriesMetadata = 2, +} + public interface ICollectionTagRepository { void Add(CollectionTag tag); @@ -21,7 +28,7 @@ public interface ICollectionTagRepository Task GetCoverImageAsync(int collectionTagId); Task> GetAllPromotedTagDtosAsync(int userId); Task GetTagAsync(int tagId); - Task GetFullTagAsync(int tagId); + Task GetFullTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.SeriesMetadata); void Update(CollectionTag tag); Task RemoveTagsWithoutSeries(); Task> GetAllTagsAsync(); @@ -76,6 +83,15 @@ public class CollectionTagRepository : ICollectionTagRepository .ToListAsync(); } + public async Task GetCoverImageAsync(int collectionTagId) + { + return await _context.CollectionTag + .Where(c => c.Id == collectionTagId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + public async Task> GetAllCoverImagesAsync() { return await _context.CollectionTag @@ -114,11 +130,11 @@ public class CollectionTagRepository : ICollectionTagRepository .SingleOrDefaultAsync(); } - public async Task GetFullTagAsync(int tagId) + public async Task GetFullTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.SeriesMetadata) { return await _context.CollectionTag .Where(c => c.Id == tagId) - .Include(c => c.SeriesMetadatas) + .Includes(includes) .AsSplitQuery() .SingleOrDefaultAsync(); } @@ -143,19 +159,9 @@ public class CollectionTagRepository : ICollectionTagRepository .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.Title) + .OrderBy(s => s.NormalizedTitle) .AsNoTracking() - .OrderBy(c => c.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - - public async Task GetCoverImageAsync(int collectionTagId) - { - return await _context.CollectionTag - .Where(c => c.Id == collectionTagId) - .Select(c => c.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } } diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index df7fb5069..464914b70 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -80,7 +80,7 @@ public class GenreRepository : IGenreRepository .SelectMany(s => s.Metadata.Genres) .AsSplitQuery() .Distinct() - .OrderBy(p => p.Title) + .OrderBy(p => p.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } @@ -101,6 +101,7 @@ public class GenreRepository : IGenreRepository var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Genre .RestrictAgainstAgeRestriction(ageRating) + .OrderBy(g => g.NormalizedTitle) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 4423db98d..9151e6861 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -101,6 +101,7 @@ public interface ISeriesRepository Task GetSeriesForMangaFile(int mangaFileId, int userId); Task GetSeriesForChapter(int chapterId, int userId); Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter); + Task IsSeriesInWantToRead(int userId, int seriesId); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); @@ -161,12 +162,10 @@ public class SeriesRepository : ISeriesRepository public async Task> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None) { - var query = _context.Series - .Where(s => s.LibraryId == libraryId); - - query = AddIncludesToQuery(query, includes); - - return await query.OrderBy(s => s.SortName).ToListAsync(); + return await _context.Series + .Where(s => s.LibraryId == libraryId) + .Includes(includes) + .OrderBy(s => s.SortName).ToListAsync(); } /// @@ -427,13 +426,10 @@ public class SeriesRepository : ISeriesRepository /// public async Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata) { - var query = _context.Series + return await _context.Series .Where(s => s.Id == seriesId) - .AsSplitQuery(); - - query = AddIncludesToQuery(query, includes); - - return await query.SingleOrDefaultAsync(); + .Includes(includes) + .SingleOrDefaultAsync(); } /// @@ -833,8 +829,8 @@ public class SeriesRepository : ISeriesRepository { var metadataDto = await _context.SeriesMetadata .Where(metadata => metadata.SeriesId == seriesId) - .Include(m => m.Genres) - .Include(m => m.Tags) + .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) + .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) .Include(m => m.People) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) @@ -848,6 +844,7 @@ public class SeriesRepository : ISeriesRepository .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() + .OrderBy(t => t.Title) .AsSplitQuery() .ToListAsync(); } @@ -1147,11 +1144,10 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None) { var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); - var query = _context.Series.Where(s => s.FolderPath.Equals(normalized)); - - query = AddIncludesToQuery(query, includes); - - return await query.SingleOrDefaultAsync(); + return await _context.Series + .Where(s => s.FolderPath.Equals(normalized)) + .Includes(includes) + .SingleOrDefaultAsync(); } /// @@ -1479,6 +1475,17 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(filteredQuery.ProjectTo(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize); } + public async Task IsSeriesInWantToRead(int userId, int seriesId) + { + var libraryIds = GetLibraryIdsForUser(userId); + return await _context.AppUser + .Where(user => user.Id == userId) + .SelectMany(u => u.WantToRead) + .AsSplitQuery() + .AsNoTracking() + .AnyAsync(s => libraryIds.Contains(s.LibraryId) && s.Id == seriesId); + } + public async Task>> GetFolderPathMap(int libraryId) { var info = await _context.Series @@ -1528,40 +1535,4 @@ public class SeriesRepository : ISeriesRepository .OrderBy(s => s) .LastOrDefaultAsync(); } - - private static IQueryable AddIncludesToQuery(IQueryable query, SeriesIncludes includeFlags) - { - // TODO: Move this to an Extension Method - if (includeFlags.HasFlag(SeriesIncludes.Library)) - { - query = query.Include(u => u.Library); - } - - if (includeFlags.HasFlag(SeriesIncludes.Volumes)) - { - query = query.Include(s => s.Volumes); - } - - if (includeFlags.HasFlag(SeriesIncludes.Related)) - { - query = query.Include(s => s.Relations) - .ThenInclude(r => r.TargetSeries) - .Include(s => s.RelationOf); - } - - if (includeFlags.HasFlag(SeriesIncludes.Metadata)) - { - query = query.Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags) - .Include(s => s.Metadata) - .ThenInclude(m => m.Genres) - .Include(s => s.Metadata) - .ThenInclude(m => m.People) - .Include(s => s.Metadata) - .ThenInclude(m => m.Tags); - } - - - return query.AsSplitQuery(); - } } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index e4e3987d0..ac88d04a7 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -64,7 +64,7 @@ public class TagRepository : ITagRepository .SelectMany(s => s.Metadata.Tags) .AsSplitQuery() .Distinct() - .OrderBy(t => t.Title) + .OrderBy(t => t.NormalizedTitle) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -81,7 +81,7 @@ public class TagRepository : ITagRepository return await _context.Tag .AsNoTracking() .RestrictAgainstAgeRestriction(userRating) - .OrderBy(t => t.Title) + .OrderBy(t => t.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 61f3b086d..db8c788ff 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -102,6 +102,7 @@ public static class Seed new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, new() {Key = ServerSettingKey.TotalLogs, Value = "30"}, new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, + new() {Key = ServerSettingKey.ConvertCoverToWebP, Value = "false"}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 5c4ac7bf8..604d286ff 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -101,4 +101,9 @@ public enum ServerSettingKey /// [Description("TotalLogs")] TotalLogs = 18, + /// + /// If Kavita should save covers as WebP images + /// + [Description("ConvertCoverToWebP")] + ConvertCoverToWebP = 19, } diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs index ec0b81257..cf8c0faa0 100644 --- a/API/Extensions/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data.Misc; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using Microsoft.EntityFrameworkCore; @@ -110,4 +111,51 @@ public static class QueryableExtensions }) .SingleAsync(); } + + public static IQueryable Includes(this IQueryable queryable, + CollectionTagIncludes includes) + { + if (includes.HasFlag(CollectionTagIncludes.SeriesMetadata)) + { + queryable = queryable.Include(c => c.SeriesMetadatas); + } + + return queryable.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable query, + SeriesIncludes includeFlags) + { + if (includeFlags.HasFlag(SeriesIncludes.Library)) + { + query = query.Include(u => u.Library); + } + + if (includeFlags.HasFlag(SeriesIncludes.Volumes)) + { + query = query.Include(s => s.Volumes); + } + + if (includeFlags.HasFlag(SeriesIncludes.Related)) + { + query = query.Include(s => s.Relations) + .ThenInclude(r => r.TargetSeries) + .Include(s => s.RelationOf); + } + + if (includeFlags.HasFlag(SeriesIncludes.Metadata)) + { + query = query.Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle)) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle)) + .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle)); + } + + + return query.AsSplitQuery(); + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index d89a3f9e0..bef39e90d 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -37,66 +37,88 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Writers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + opt.MapFrom( + src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.CoverArtists, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Characters, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Publishers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Colorists, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Inkers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Letterers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Pencillers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Translators, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) + opt.MapFrom(src => + src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Editors, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + opt.MapFrom( + src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))) + .ForMember(dest => dest.Genres, + opt => + opt.MapFrom( + src => src.Genres.OrderBy(p => p.NormalizedTitle))) + .ForMember(dest => dest.CollectionTags, + opt => + opt.MapFrom( + src => src.CollectionTags.OrderBy(p => p.NormalizedTitle))) + .ForMember(dest => dest.Tags, + opt => + opt.MapFrom( + src => src.Tags.OrderBy(p => p.NormalizedTitle))); CreateMap() .ForMember(dest => dest.Writers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.CoverArtists, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Colorists, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Inkers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Letterers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Pencillers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Publishers, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Translators, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Characters, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Editors, opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))); CreateMap() .ForMember(dest => dest.AgeRestriction, diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index f23fddca7..008e08e90 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -51,6 +51,9 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.ConvertBookmarkToWebP: destination.ConvertBookmarkToWebP = bool.Parse(row.Value); break; + case ServerSettingKey.ConvertCoverToWebP: + destination.ConvertCoverToWebP = bool.Parse(row.Value); + break; case ServerSettingKey.EnableSwaggerUi: destination.EnableSwaggerUi = bool.Parse(row.Value); break; diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 211d85df7..a547dc36d 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -20,7 +20,7 @@ public interface IArchiveService { void ExtractArchive(string archivePath, string extractPath); int GetNumberOfPagesFromArchive(string archivePath); - string GetCoverImage(string archivePath, string fileName, string outputDirectory); + string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false); bool IsValidArchive(string archivePath); ComicInfo GetComicInfo(string archivePath); ArchiveLibrary CanOpen(string archivePath); @@ -196,8 +196,9 @@ public class ArchiveService : IArchiveService /// /// File name to use based on context of entity. /// Where to output the file, defaults to covers directory + /// When saving the file, use WebP encoding instead of PNG /// - public string GetCoverImage(string archivePath, string fileName, string outputDirectory) + public string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false) { if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; try @@ -213,7 +214,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); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); } case ArchiveLibrary.SharpCompress: { @@ -224,7 +225,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); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 8156a56ff..e358a46e2 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -31,7 +32,7 @@ namespace API.Services; public interface IBookService { int GetNumberOfPages(string filePath); - string GetCoverImage(string fileFilePath, string fileName, string outputDirectory); + string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false); Task> CreateKeyToPageMappingAsync(EpubBookRef book); /// @@ -432,7 +433,7 @@ public class BookService : IBookService Year = year, Title = epubBook.Title, Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), - LanguageISO = epubBook.Schema.Package.Metadata.Languages.FirstOrDefault() ?? string.Empty + LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages.FirstOrDefault()) }; ComicInfo.CleanComicInfo(info); @@ -477,6 +478,24 @@ public class BookService : IBookService return null; } + #nullable enable + private static string ValidateLanguage(string? language) + { + if (string.IsNullOrEmpty(language)) return string.Empty; + + try + { + CultureInfo.GetCultureInfo(language); + } + catch (Exception) + { + return string.Empty; + } + + return language; + } + #nullable disable + private bool IsValidFile(string filePath) { if (!File.Exists(filePath)) @@ -880,14 +899,15 @@ public class BookService : IBookService /// /// Name of the new file. /// Where to output the file, defaults to covers directory + /// When saving the file, use WebP encoding instead of PNG /// - public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory) + public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP = false) { if (!IsValidFile(fileFilePath)) return string.Empty; if (Tasks.Scanner.Parser.Parser.IsPdf(fileFilePath)) { - return GetPdfCoverImage(fileFilePath, fileName, outputDirectory); + return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, saveAsWebP); } using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); @@ -902,7 +922,7 @@ public class BookService : IBookService if (coverImageContent == null) return string.Empty; using var stream = coverImageContent.GetContentStream(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); } catch (Exception ex) { @@ -913,7 +933,7 @@ public class BookService : IBookService } - private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory) + private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, bool saveAsWebP) { try { @@ -923,7 +943,7 @@ public class BookService : IBookService using var stream = StreamManager.GetStream("BookService.GetPdfPage"); GetPdfPage(docReader, 0, stream); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP); } catch (Exception ex) diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 4d9b88ff4..72dd72279 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -27,6 +27,7 @@ public interface IBookmarkService public class BookmarkService : IBookmarkService { + public const string Name = "BookmarkService"; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly IDirectoryService _directoryService; diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index bebb40d93..ab88e6f18 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -10,7 +10,7 @@ namespace API.Services; public interface IImageService { void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1); - string GetCoverImage(string path, string fileName, string outputDirectory); + string GetCoverImage(string path, string fileName, string outputDirectory, bool saveAsWebP = false); /// /// Creates a Thumbnail version of a base64 image @@ -20,7 +20,7 @@ public interface IImageService /// File name with extension of the file. This will always write to string CreateThumbnailFromBase64(string encodedImage, string fileName); - string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory); + string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false); /// /// Converts the passed image to webP and outputs it in the same directory /// @@ -67,14 +67,14 @@ public class ImageService : IImageService } } - public string GetCoverImage(string path, string fileName, string outputDirectory) + public string GetCoverImage(string path, string fileName, string outputDirectory, bool saveAsWebP = false) { if (string.IsNullOrEmpty(path)) return string.Empty; try { using var thumbnail = Image.Thumbnail(path, ThumbnailWidth); - var filename = fileName + ".png"; + var filename = fileName + (saveAsWebP ? ".webp" : ".png"); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; } @@ -93,11 +93,12 @@ public class ImageService : IImageService /// Stream to write to disk. Ensure this is rewinded. /// filename to save as without extension /// Where to output the file, defaults to covers directory + /// Export the file as webP otherwise will default to png /// File name with extension of the file. This will always write to - public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory) + public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false) { using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); - var filename = fileName + ".png"; + var filename = fileName + (saveAsWebP ? ".webp" : ".png"); _directoryService.ExistOrCreate(outputDirectory); try { diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 6be15bf8e..833ee83c8 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -39,7 +39,7 @@ public interface IMetadataService /// Overrides any cache logic and forces execution Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true); - Task GenerateCoversForSeries(Series series, bool forceUpdate = false); + Task GenerateCoversForSeries(Series series, bool convertToWebP, bool forceUpdate = false); Task RemoveAbandonedMetadataKeys(); } @@ -70,7 +70,8 @@ public class MetadataService : IMetadataService /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) + /// Convert image to WebP when extracting the cover + private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, bool convertToWebPOnWrite) { var firstFile = chapter.Files.MinBy(x => x.Chapter); @@ -80,7 +81,9 @@ public class MetadataService : IMetadataService if (firstFile == null) return Task.FromResult(false); _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); - chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format); + + chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, + ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, convertToWebPOnWrite); _unitOfWork.ChapterRepository.Update(chapter); _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); return Task.FromResult(true); @@ -143,7 +146,8 @@ public class MetadataService : IMetadataService /// /// /// - private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate) + /// + private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, bool convertToWebP) { _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); try @@ -156,7 +160,7 @@ public class MetadataService : IMetadataService var index = 0; foreach (var chapter in volume.Chapters) { - var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate); + var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, convertToWebP); // 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) @@ -194,7 +198,7 @@ public class MetadataService : IMetadataService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false) { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); _logger.LogInformation("[MetadataService] Beginning cover generation refresh of {LibraryName}", library.Name); _updateEvents.Clear(); @@ -207,6 +211,8 @@ 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; + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) { if (chunkInfo.TotalChunks == 0) continue; @@ -235,7 +241,7 @@ public class MetadataService : IMetadataService try { - await ProcessSeriesCoverGen(series, forceUpdate); + await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP); } catch (Exception ex) { @@ -285,21 +291,23 @@ public class MetadataService : IMetadataService return; } - await GenerateCoversForSeries(series, forceUpdate); + var convertToWebP = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).ConvertCoverToWebP; + await GenerateCoversForSeries(series, convertToWebP, forceUpdate); } /// /// Generate Cover for a Series. This is used by Scan Loop and should not be invoked directly via User Interaction. /// /// A full Series, with metadata, chapters, etc + /// When saving the file, use WebP encoding instead of PNG /// - public async Task GenerateCoversForSeries(Series series, bool forceUpdate = false) + public async Task GenerateCoversForSeries(Series series, bool convertToWebP, 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); + await ProcessSeriesCoverGen(series, forceUpdate, convertToWebP); if (_unitOfWork.HasChanges()) diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 551d1b668..a70f7f38e 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -10,7 +10,7 @@ public interface IReadingItemService { ComicInfo GetComicInfo(string filePath); int GetNumberOfPages(string filePath, MangaFormat format); - string GetCoverImage(string filePath, string fileName, MangaFormat format); + string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); ParserInfo Parse(string path, string rootPath, LibraryType type); ParserInfo ParseFile(string path, string rootPath, LibraryType type); @@ -162,19 +162,20 @@ public class ReadingItemService : IReadingItemService } } - public string GetCoverImage(string filePath, string fileName, MangaFormat format) + public string GetCoverImage(string filePath, string fileName, MangaFormat format, bool saveAsWebP) { if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName)) { return string.Empty; } + return format switch { - MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), - MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), - MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), - MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), + 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), _ => string.Empty }; } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index bba9876d2..cfd62d898 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -553,7 +553,9 @@ public class SeriesService : ISeriesService Specials = specials, Chapters = retChapters, Volumes = processedVolumes, - StorylineChapters = storylineChapters + StorylineChapters = storylineChapters, + TotalCount = chapters.Count, + UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages) }; } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 13cce0feb..4361a6433 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -408,7 +408,7 @@ public static class Parser { // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( - @"^(?.*)(?: |_)(t|v)(?\d+)", + @"^(?.+?)(?: |_)(t|v)(?" + NumberRange + @")", MatchOptions, RegexTimeout), // Batgirl Vol.2000 #57 (December, 2004) new Regex( diff --git a/UI/Web/src/app/_models/series-detail/series-detail.ts b/UI/Web/src/app/_models/series-detail/series-detail.ts index ddba7dec7..29e6e262b 100644 --- a/UI/Web/src/app/_models/series-detail/series-detail.ts +++ b/UI/Web/src/app/_models/series-detail/series-detail.ts @@ -9,4 +9,6 @@ export interface SeriesDetail { chapters: Array; volumes: Array; storylineChapters: Array; + unreadCount: number; + totalCount: number; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 3332fc771..f60767e85 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -130,6 +130,10 @@ export class SeriesService { })); } + isWantToRead(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'want-to-read?seriesId=' + seriesId, {responseType: 'text' as 'json'}); + } + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.filterUtilitySerivce.createSeriesFilter(filter); diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index f7e05f895..b52c2ec9d 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -10,6 +10,7 @@ export interface ServerSettings { bookmarksDirectory: string; emailServiceUrl: string; convertBookmarkToWebP: boolean; + convertCoverToWebP: boolean; enableSwaggerUi: boolean; totalBackups: number; totalLogs: number; diff --git a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html index 7447a4a56..22178a052 100644 --- a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html +++ b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html @@ -1,15 +1,29 @@
+
+

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 Can I Use.

-   - When saving bookmarks, covert them to WebP. WebP is not supported on Safari devices and will not render at all. WebP can drastically reduce space requirements for files. + + + When saving bookmarks, covert them to WebP.
+ +
+ + + When generating covers, covert them to WebP. + +
+ + +
+
diff --git a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts index f3b19e012..12a97481c 100644 --- a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts +++ b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.ts @@ -21,17 +21,20 @@ export class ManageMediaSettingsComponent implements OnInit { 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])); }); } resetForm() { this.settingsForm.get('convertBookmarkToWebP')?.setValue(this.serverSettings.convertBookmarkToWebP); + this.settingsForm.get('convertCoverToWebP')?.setValue(this.serverSettings.convertCoverToWebP); this.settingsForm.markAsPristine(); } async saveSettings() { const modelSettings = Object.assign({}, this.serverSettings); modelSettings.convertBookmarkToWebP = this.settingsForm.get('convertBookmarkToWebP')?.value; + modelSettings.convertCoverToWebP = this.settingsForm.get('convertCoverToWebP')?.value; this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.serverSettings = settings; diff --git a/UI/Web/src/app/all-series/all-series.component.html b/UI/Web/src/app/all-series/all-series.component.html index d0e95234a..74296fe97 100644 --- a/UI/Web/src/app/all-series/all-series.component.html +++ b/UI/Web/src/app/all-series/all-series.component.html @@ -9,7 +9,6 @@ [isLoading]="loadingSeries" [items]="series" [trackByIdentity]="trackByIdentity" - [pagination]="pagination" [filterSettings]="filterSettings" [filterOpen]="filterOpen" [jumpBarKeys]="jumpbarKeys" diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts index 3066d1bc7..f9ae5be77 100644 --- a/UI/Web/src/app/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; @@ -13,13 +13,15 @@ import { Series } from '../_models/series'; import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { Action, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; +import { JumpbarService } from '../_services/jumpbar.service'; import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; @Component({ selector: 'app-all-series', templateUrl: './all-series.component.html', - styleUrls: ['./all-series.component.scss'] + styleUrls: ['./all-series.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class AllSeriesComponent implements OnInit, OnDestroy { @@ -85,7 +87,8 @@ export class AllSeriesComponent implements OnInit, OnDestroy { private titleService: Title, private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, private utilityService: UtilityService, private route: ActivatedRoute, - private filterUtilityService: FilterUtilitiesService) { + private filterUtilityService: FilterUtilitiesService, private jumpbarService: JumpbarService, + private readonly cdRef: ChangeDetectorRef) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.titleService.setTitle('Kavita - All Series'); @@ -93,6 +96,7 @@ export class AllSeriesComponent implements OnInit, OnDestroy { this.pagination = this.filterUtilityService.pagination(this.route.snapshot); [this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot); this.filterActiveCheck = this.filterUtilityService.createSeriesFilter(); + this.cdRef.markForCheck(); } ngOnInit(): void { @@ -131,33 +135,14 @@ export class AllSeriesComponent implements OnInit, OnDestroy { loadPage() { this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck); + this.loadingSeries = true; + this.cdRef.markForCheck(); this.seriesService.getAllSeries(undefined, undefined, this.filter).pipe(take(1)).subscribe(series => { this.series = series.result; - const keys: {[key: string]: number} = {}; - series.result.forEach(s => { - let ch = s.name.charAt(0); - if (/\d|\#|!|%|@|\(|\)|\^|\*/g.test(ch)) { - ch = '#'; - } - if (!keys.hasOwnProperty(ch)) { - keys[ch] = 0; - } - keys[ch] += 1; - }); - this.jumpbarKeys = Object.keys(keys).map(k => { - return { - key: k, - size: keys[k], - title: k.toUpperCase() - } - }).sort((a, b) => { - if (a.key < b.key) return -1; - if (a.key > b.key) return 1; - return 0; - }); + this.jumpbarKeys = this.jumpbarService.getJumpKeys(this.series, (s: Series) => s.name); this.pagination = series.pagination; this.loadingSeries = false; - window.scrollTo(0, 0); + this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html index 106194377..ed15628ed 100644 --- a/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html +++ b/UI/Web/src/app/cards/entity-info-cards/entity-info-cards.component.html @@ -19,8 +19,8 @@
- - {{totalPages | number:''}} Pages + + {{totalPages | compactNumber}} Pages
diff --git a/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html b/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html index 309dab25f..9675711b5 100644 --- a/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html +++ b/UI/Web/src/app/cards/series-info-cards/series-info-cards.component.html @@ -71,8 +71,8 @@
- - {{series.pages | number:''}} Pages + + {{series.pages | compactNumber}} Pages
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 bc199c1a6..c0be87a77 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -328,10 +328,10 @@
- +
-
+
diff --git a/UI/Web/src/app/pipe/compact-number.pipe.ts b/UI/Web/src/app/pipe/compact-number.pipe.ts index f8c017e17..78ff2ef82 100644 --- a/UI/Web/src/app/pipe/compact-number.pipe.ts +++ b/UI/Web/src/app/pipe/compact-number.pipe.ts @@ -3,7 +3,8 @@ import { Pipe, PipeTransform } from '@angular/core'; const formatter = new Intl.NumberFormat('en-GB', { //@ts-ignore - notation: 'compact' // https://github.com/microsoft/TypeScript/issues/36533 + notation: 'compact', // https://github.com/microsoft/TypeScript/issues/36533 + maximumSignificantDigits: 3 }); @Pipe({ diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index 6cefcbeed..e322e6f8c 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -59,8 +59,13 @@
+
+ {{unreadCount}} +
- +
+ From {{ContinuePointTitle}} +
@@ -72,8 +77,15 @@ {{(hasReadingProgress) ? 'Continue' : 'Read'}}
+
+ +
-