From 9cf4cf742bac8a1598ccf5ad8197ff9a47d661d0 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Fri, 16 Sep 2022 08:06:33 -0500 Subject: [PATCH] Reader Bugs + New Features (#1536) * Updated a typo in manage tasks of Reoccuring -> Recurring * Fixed a bug in MinimumNumberFromRange where a regex wasn't properly constructed which could skew results. * Fixed a bug where Volume numbers that were a float wouldn't render correctly in the manga reader menu. * Added the ability to double click on the image to bookmark it. Optimized the bookmark and unbookmark flows to remove 2 DB calls and reworked some flow of calls to speed it up. Fixed some logic where when using double (manga) flow, both of the images wouldn't show the bookmark effect, despite both of them being saved. Likewise, fixed a bug where both images weren't updating UI state, so switching from double (manga) to single, the second image wouldn't show as bookmarked without a refresh. * Double click works perfectly for bookmarking * Collection cover image chooser will now prompt with all series covers by default. Reset button is now moved up to the first slot if applicable. * When a Completed series is fully read by a user, a nightly task will now remove that series from their Want to Read list. * Added ability to trigger Want to Read cleanup from Tasks page. * Moved the brightness readout to the label line and fixed a bootstrap migration bug where small buttons weren't actually small. * Implemented ability to filter against release year (min or max or both). * Fixed a log message that wasn't properly formatted when scan finished an no files changes. * Cleaned up some code and merged some methods * Implemented sort by Release year metadata filter. * Fixed the code that finds ComicInfo.xml inside archives to only check the root and check explicitly for casing, so it must be ComicInfo.xml. * Dependency updates * Refactored some strings into consts and used TriggerJob rather than just enqueuing * Fixed the prefetcher which wasn't properly loading in the correct order as it was designed. * Cleaned up all traces of CircularArray from MangaReader * Removed a debug code * Fixed a bug with webtoon reader in fullscreen mode where continuous reader wouldn't trigger * When cleaning up series from users' want to read lists, include both completed and cancelled. * Fixed a bug where small images wouldn't have the pagination area extend to the bottom on manga reader * Added a new method for hashing during prod builds and ensure we always use aot * Fixed a bug where the save button wouldn't enable when color change occured. * Cleaned up some issues in one of contributor's PR. --- API.Benchmark/API.Benchmark.csproj | 4 +- API.Tests/API.Tests.csproj | 6 +- API.Tests/Parser/ParserTest.cs | 6 + API.Tests/Services/ArchiveServiceTests.cs | 21 ++-- API/API.csproj | 34 +++--- API/Controllers/BookController.cs | 5 +- API/Controllers/ReaderController.cs | 27 ++--- API/Controllers/ReadingListController.cs | 1 + API/Controllers/ServerController.cs | 15 ++- API/DTOs/Filtering/FilterDto.cs | 4 + API/DTOs/Filtering/Range.cs | 14 +++ API/DTOs/Filtering/SortField.cs | 6 +- API/Data/Repositories/ChapterRepository.cs | 2 +- API/Data/Repositories/SeriesRepository.cs | 43 ++++--- API/Data/Repositories/UserRepository.cs | 9 +- API/Helpers/Filters/ETagFromFilename.cs | 16 ++- API/Logging/LogLevelOptions.cs | 4 +- API/Services/AccountService.cs | 2 +- API/Services/ArchiveService.cs | 7 +- API/Services/BookService.cs | 7 +- API/Services/BookmarkService.cs | 23 ++-- API/Services/DirectoryService.cs | 23 ++-- API/Services/ImageService.cs | 2 +- API/Services/ReaderService.cs | 16 +++ API/Services/TaskScheduler.cs | 25 ++-- API/Services/Tasks/CleanupService.cs | 49 ++++++++ .../Tasks/Scanner/ParseScannedFiles.cs | 4 + API/Services/Tasks/Scanner/Parser/Parser.cs | 4 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 2 +- API/Services/Tasks/ScannerService.cs | 4 +- Kavita.Common/Kavita.Common.csproj | 2 +- UI/Web/package.json | 2 +- UI/Web/src/app/_models/series-filter.ts | 9 +- UI/Web/src/app/_services/server.service.ts | 6 +- .../manage-tasks-settings.component.html | 6 +- .../manage-tasks-settings.component.ts | 15 ++- .../edit-collection-tags.component.ts | 1 + .../cover-image-chooser.component.html | 20 ++-- .../infinite-scroller.component.ts | 5 +- .../manga-reader/manga-reader.component.html | 19 ++- .../manga-reader/manga-reader.component.ts | 111 +++++++++--------- .../app/metadata-filter/filter-settings.ts | 1 + .../metadata-filter.component.html | 17 ++- .../metadata-filter.component.ts | 17 ++- .../_services/filter-utilities.service.ts | 1 + UI/Web/src/app/shared/shared.module.ts | 2 +- .../user-preferences.component.html | 2 +- .../user-preferences.component.ts | 6 + UI/Web/src/theme/themes/dark.scss | 2 +- 49 files changed, 408 insertions(+), 221 deletions(-) create mode 100644 API/DTOs/Filtering/Range.cs diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index 31af4f2c6..b6f60b873 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index fba1b24f8..ec4a37c81 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -7,10 +7,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 6760d41d5..fb88f2d3e 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -138,6 +138,9 @@ public class ParserTests [InlineData("40", 40)] [InlineData("40a-040b", 0)] [InlineData("40.1_a", 0)] + [InlineData("3.5", 3.5)] + [InlineData("3.5-4.0", 3.5)] + [InlineData("asdfasdf", 0.0)] public void MinimumNumberFromRangeTest(string input, float expected) { Assert.Equal(expected, MinNumberFromRange(input)); @@ -151,6 +154,9 @@ public class ParserTests [InlineData("40", 40)] [InlineData("40a-040b", 0)] [InlineData("40.1_a", 0)] + [InlineData("3.5", 3.5)] + [InlineData("3.5-4.0", 4.0)] + [InlineData("asdfasdf", 0.0)] public void MaximumNumberFromRangeTest(string input, float expected) { Assert.Equal(expected, MaxNumberFromRange(input)); diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 35e26757a..7fc267869 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -39,7 +39,7 @@ public class ArchiveServiceTests { var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); var file = Path.Join(testDirectory, archivePath); - using ZipArchive archive = ZipFile.OpenRead(file); + using var archive = ZipFile.OpenRead(file); Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive)); } @@ -279,15 +279,16 @@ public class ArchiveServiceTests var comicInfo = _archiveService.GetComicInfo(archive); Assert.NotNull(comicInfo); - Assert.Equal(comicInfo.Publisher, "Yen Press"); - Assert.Equal(comicInfo.Genre, "Manga, Movies & TV"); - Assert.Equal(comicInfo.Summary, "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"); - Assert.Equal(comicInfo.PageCount, 194); - Assert.Equal(comicInfo.LanguageISO, "en"); - Assert.Equal(comicInfo.Notes, "Scraped metadata from Comixology [CMXDB450184]"); - Assert.Equal(comicInfo.Series, "BTOOOM!"); - Assert.Equal(comicInfo.Title, "v01"); - Assert.Equal(comicInfo.Web, "https://www.comixology.com/BTOOOM/digital-comic/450184"); + Assert.Equal("Yen Press", comicInfo.Publisher); + Assert.Equal("Manga, Movies & TV", comicInfo.Genre); + Assert.Equal("By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?", + comicInfo.Summary); + Assert.Equal(194, comicInfo.PageCount); + Assert.Equal("en", comicInfo.LanguageISO); + Assert.Equal("Scraped metadata from Comixology [CMXDB450184]", comicInfo.Notes); + Assert.Equal("BTOOOM!", comicInfo.Series); + Assert.Equal("v01", comicInfo.Title); + Assert.Equal("https://www.comixology.com/BTOOOM/digital-comic/450184", comicInfo.Web); } #endregion diff --git a/API/API.csproj b/API/API.csproj index c11a79a21..21dd48ce7 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -52,46 +52,46 @@ - - + + - + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - + - + - + - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index dd44b9a7b..a3cae9d80 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -61,13 +61,10 @@ public class BookController : BaseApiController break; } case MangaFormat.Image: - break; case MangaFormat.Archive: - break; case MangaFormat.Unknown: - break; default: - throw new ArgumentOutOfRangeException(); + break; } return Ok(new BookInfoDto() diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 2fa6f22e4..2ac1d3bf3 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -17,6 +17,7 @@ using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; namespace API.Controllers; @@ -657,6 +658,7 @@ public class ReaderController : BaseApiController /// /// Bookmarks a page against a Chapter /// + /// This has a side effect of caching the chapter files to disk /// /// [HttpPost("bookmark")] @@ -669,18 +671,16 @@ public class ReaderController : BaseApiController if (!await _accountService.HasBookmarkPermission(user)) return BadRequest("You do not have permission to bookmark"); - bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page); var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); if (chapter == null) return BadRequest("Could not find cached image. Reload and try again."); + + bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page); var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); - if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) - { - BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); - return Ok(); - } + if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) return BadRequest("Could not save bookmark"); - return BadRequest("Could not save bookmark"); + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); + return Ok(); } /// @@ -693,18 +693,15 @@ public class ReaderController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user == null) return new UnauthorizedResult(); - if (user.Bookmarks == null) return Ok(); + if (user.Bookmarks.IsNullOrEmpty()) return Ok(); if (!await _accountService.HasBookmarkPermission(user)) return BadRequest("You do not have permission to unbookmark"); - if (await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) - { - BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); - return Ok(); - } - - return BadRequest("Could not remove bookmark"); + if (!await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) + return BadRequest("Could not remove bookmark"); + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); + return Ok(); } /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 3ed67d84d..b150bdc01 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -46,6 +46,7 @@ public class ReadingListController : BaseApiController /// Returns reading lists (paginated) for a given user. /// /// Defaults to true + /// Pagination parameters /// [HttpPost("lists")] public async Task>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true) diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 799f47bca..13e991065 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -77,6 +77,19 @@ public class ServerController : BaseApiController return Ok(); } + /// + /// Performs an ad-hoc cleanup of Want To Read, by removing want to read series for users, where the series are fully read and in Completed publication status. + /// + /// + [HttpPost("cleanup-want-to-read")] + public ActionResult CleanupWantToRead() + { + _logger.LogInformation("{UserName} is clearing running want to read cleanup from admin dashboard", User.GetUsername()); + RecurringJob.TriggerJob(API.Services.TaskScheduler.RemoveFromWantToReadTaskId); + + return Ok(); + } + /// /// Performs an ad-hoc backup of the Database /// @@ -85,7 +98,7 @@ public class ServerController : BaseApiController public ActionResult BackupDatabase() { _logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername()); - RecurringJob.Trigger("backup"); + RecurringJob.TriggerJob(API.Services.TaskScheduler.BackupTaskId); return Ok(); } diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 2e09a0b72..a8c7aa1cc 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -99,4 +99,8 @@ public class FilterDto /// An optional name string to filter by. Empty string will ignore. /// public string SeriesNameQuery { get; init; } = string.Empty; + /// + /// An optional release year to filter by. Null will ignore. You can pass 0 for an individual field to ignore it. + /// + public Range? ReleaseYearRange { get; init; } = null; } diff --git a/API/DTOs/Filtering/Range.cs b/API/DTOs/Filtering/Range.cs new file mode 100644 index 000000000..383ce7887 --- /dev/null +++ b/API/DTOs/Filtering/Range.cs @@ -0,0 +1,14 @@ +namespace API.DTOs.Filtering; +/// +/// Represents a range between two int/float/double +/// +public class Range +{ + public T Min { get; set; } + public T Max { get; set; } + + public override string ToString() + { + return $"{Min}-{Max}"; + } +} diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs index fbb1d511a..918b74279 100644 --- a/API/DTOs/Filtering/SortField.cs +++ b/API/DTOs/Filtering/SortField.cs @@ -21,5 +21,9 @@ public enum SortField /// /// Time it takes to read. Uses Average. /// - TimeToRead = 5 + TimeToRead = 5, + /// + /// Release Year of the Series + /// + ReleaseYear = 6 } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index ab3684fa0..ce65883cc 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -63,7 +63,7 @@ public class ChapterRepository : IChapterRepository .Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new { ChapterNumber = chapter.Range, - VolumeNumber = volume.Number, + VolumeNumber = volume.Name, VolumeId = volume.Id, chapter.IsSpecial, chapter.TitleName, diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 43b748a2a..e656de29c 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -605,7 +605,7 @@ public class SeriesRepository : ISeriesRepository private IList ExtractFilters(int libraryId, int userId, FilterDto filter, ref List userLibraries, out List allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter, out bool hasRatingFilter, out bool hasProgressFilter, out IList seriesIds, out bool hasAgeRating, out bool hasTagsFilter, - out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter) + out bool hasLanguageFilter, out bool hasPublicationFilter, out bool hasSeriesNameFilter, out bool hasReleaseYearMinFilter, out bool hasReleaseYearMaxFilter) { var formats = filter.GetSqlFilter(); @@ -640,6 +640,9 @@ public class SeriesRepository : ISeriesRepository hasLanguageFilter = filter.Languages.Count > 0; hasPublicationFilter = filter.PublicationStatus.Count > 0; + hasReleaseYearMinFilter = filter.ReleaseYearRange != null && filter.ReleaseYearRange.Min != 0; + hasReleaseYearMaxFilter = filter.ReleaseYearRange != null && filter.ReleaseYearRange.Max != 0; + bool ProgressComparison(int pagesRead, int totalPages) { @@ -731,7 +734,8 @@ public class SeriesRepository : ISeriesRepository var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, - out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter); + out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, + out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter); var query = _context.Series .Where(s => userLibraries.Contains(s.LibraryId) @@ -745,6 +749,8 @@ public class SeriesRepository : ISeriesRepository && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) + && (!hasReleaseYearMinFilter || s.Metadata.ReleaseYear >= filter.ReleaseYearRange.Min) + && (!hasReleaseYearMaxFilter || s.Metadata.ReleaseYear <= filter.ReleaseYearRange.Max) && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))) .Where(s => !hasSeriesNameFilter || EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") @@ -768,6 +774,7 @@ public class SeriesRepository : ISeriesRepository SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead), + SortField.ReleaseYear => query.OrderBy(s => s.Metadata.ReleaseYear), _ => query }; } @@ -780,6 +787,7 @@ public class SeriesRepository : ISeriesRepository SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead), + SortField.ReleaseYear => query.OrderByDescending(s => s.Metadata.ReleaseYear), _ => query }; } @@ -793,7 +801,8 @@ public class SeriesRepository : ISeriesRepository var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, - out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter); + out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, + out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter); var query = sQuery .Where(s => userLibraries.Contains(s.LibraryId) @@ -807,6 +816,8 @@ public class SeriesRepository : ISeriesRepository && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) + && (!hasReleaseYearMinFilter || s.Metadata.ReleaseYear >= filter.ReleaseYearRange.Min) + && (!hasReleaseYearMaxFilter || s.Metadata.ReleaseYear <= filter.ReleaseYearRange.Max) && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus))) .Where(s => !hasSeriesNameFilter || EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") @@ -1069,14 +1080,6 @@ public class SeriesRepository : ISeriesRepository .ToListAsync(); } - private IQueryable GetLibraryIdsForUser(int userId) - { - return _context.AppUser - .Where(u => u.Id == userId) - .AsSplitQuery() - .SelectMany(l => l.Libraries.Select(lib => lib.Id)); - } - public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { var libraryIds = GetLibraryIdsForUser(userId, libraryId); @@ -1219,6 +1222,7 @@ 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) @@ -1375,11 +1379,20 @@ public class SeriesRepository : ISeriesRepository /// /// 0 for no library filter /// - private IQueryable GetLibraryIdsForUser(int userId, int libraryId) + private IQueryable GetLibraryIdsForUser(int userId, int libraryId = 0) { - return _context.AppUser - .Where(u => u.Id == userId) - .SelectMany(l => l.Libraries.Where(l => l.Id == libraryId || libraryId == 0).Select(lib => lib.Id)); + var query = _context.AppUser + .AsSplitQuery() + .AsNoTracking() + .Where(u => u.Id == userId); + + if (libraryId == 0) + { + return query.SelectMany(l => l.Libraries.Select(lib => lib.Id)); + } + + return query.SelectMany(l => + l.Libraries.Where(lib => lib.Id == libraryId).Select(lib => lib.Id)); } public async Task GetRelatedSeries(int userId, int seriesId) diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index fdfccf95f..7aaa4b040 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -35,6 +35,7 @@ public interface IUserRepository void Update(AppUser user); void Update(AppUserPreferences preferences); void Update(AppUserBookmark bookmark); + void Add(AppUserBookmark bookmark); public void Delete(AppUser user); void Delete(AppUserBookmark bookmark); Task> GetEmailConfirmedMemberDtosAsync(); @@ -90,6 +91,11 @@ public class UserRepository : IUserRepository _context.Entry(bookmark).State = EntityState.Modified; } + public void Add(AppUserBookmark bookmark) + { + _context.AppUserBookmark.Add(bookmark); + } + public void Delete(AppUser user) { _context.AppUser.Remove(user); @@ -229,7 +235,8 @@ public class UserRepository : IUserRepository public async Task> GetAllUsers() { - return await _context.AppUser.ToListAsync(); + return await _context.AppUser + .ToListAsync(); } public async Task> GetAllPreferencesByThemeAsync(int themeId) diff --git a/API/Helpers/Filters/ETagFromFilename.cs b/API/Helpers/Filters/ETagFromFilename.cs index 30b798ea4..4160aaa49 100644 --- a/API/Helpers/Filters/ETagFromFilename.cs +++ b/API/Helpers/Filters/ETagFromFilename.cs @@ -82,11 +82,11 @@ namespace API.Helpers.Filters; // } [AttributeUsage(AttributeTargets.Method)] -public class ETagFilter : Attribute, IActionFilter +public class ETagFilterAttribute : Attribute, IActionFilter { private readonly int[] _statusCodes; - public ETagFilter(params int[] statusCodes) + public ETagFilterAttribute(params int[] statusCodes) { _statusCodes = statusCodes; if (statusCodes.Length == 0) _statusCodes = new[] { 200 }; @@ -94,6 +94,7 @@ public class ETagFilter : Attribute, IActionFilter public void OnActionExecuting(ActionExecutingContext context) { + /* Nothing needs to be done here */ } public void OnActionExecuted(ActionExecutedContext context) @@ -101,16 +102,13 @@ public class ETagFilter : Attribute, IActionFilter if (context.HttpContext.Request.Method != "GET" || context.HttpContext.Request.Method != "HEAD") return; if (!_statusCodes.Contains(context.HttpContext.Response.StatusCode)) return; - var etag = string.Empty;; + var etag = string.Empty; //I just serialize the result to JSON, could do something less costly - if (context.Result is PhysicalFileResult) + if (context.Result is PhysicalFileResult fileResult) { // Do a cheap LastWriteTime etag gen - if (context.Result is PhysicalFileResult fileResult) - { - etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName); - context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString(); - } + etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName); + context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString(); } if (string.IsNullOrEmpty(etag)) diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index 0032fa421..55a8bb9d5 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -64,7 +64,7 @@ public static class LogLevelOptions AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; break; case "Information": - LogLevelSwitch.MinimumLevel = LogEventLevel.Information; + LogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; break; @@ -79,7 +79,7 @@ public static class LogLevelOptions AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; break; case "Critical": - LogLevelSwitch.MinimumLevel = LogEventLevel.Error; + LogLevelSwitch.MinimumLevel = LogEventLevel.Fatal; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; break; diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 1423451ae..1eb50f281 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -19,7 +19,7 @@ public interface IAccountService Task> ValidateUsername(string username); Task> ValidateEmail(string email); Task HasBookmarkPermission(AppUser user); - Task HasDownloadPermission(AppUser appuser); + Task HasDownloadPermission(AppUser user); } public class AccountService : IAccountService diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 271fb7b56..3ec81ea7e 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -44,7 +44,7 @@ public class ArchiveService : IArchiveService private readonly ILogger _logger; private readonly IDirectoryService _directoryService; private readonly IImageService _imageService; - private const string ComicInfoFilename = "comicinfo"; + private const string ComicInfoFilename = "ComicInfo.xml"; public ArchiveService(ILogger logger, IDirectoryService directoryService, IImageService imageService) { @@ -332,9 +332,8 @@ public class ArchiveService : IArchiveService { var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower(); return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName) - && filenameWithoutExtension.Equals(ComicInfoFilename, StringComparison.InvariantCultureIgnoreCase) - && !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith) - && Tasks.Scanner.Parser.Parser.IsXml(name); + && fullName.Equals(ComicInfoFilename) + && !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith); } /// diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 39bf64b95..3267218f4 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -561,8 +561,6 @@ public class BookService : IBookService var seriesIndex = string.Empty; var series = string.Empty; var specialName = string.Empty; - var groupPosition = string.Empty; - var titleSort = string.Empty; foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) @@ -578,7 +576,6 @@ public class BookService : IBookService break; case "calibre:title_sort": specialName = metadataItem.Content; - titleSort = metadataItem.Content; break; } @@ -592,7 +589,7 @@ public class BookService : IBookService series = metadataItem.Content; break; case "collection-type": - groupPosition = metadataItem.Content; + // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html break; } } @@ -965,7 +962,7 @@ public class BookService : IBookService } catch (Exception) { - /* Swallow exception. Some css doesn't have style rules ending in ; */ + /* Swallow exception. Some css don't have style rules ending in ; */ } body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1"); diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index b15fdd465..4d9b88ff4 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -79,15 +79,14 @@ public class BookmarkService : IBookmarkService /// If the save to DB and copy was successful public async Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark) { + if (userWithBookmarks == null || userWithBookmarks.Bookmarks == null) return false; try { - var userBookmark = - await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, userWithBookmarks.Id); - + var userBookmark = userWithBookmarks.Bookmarks.SingleOrDefault(b => b.Page == bookmarkDto.Page && b.ChapterId == bookmarkDto.ChapterId); if (userBookmark != null) { _logger.LogError("Bookmark already exists for Series {SeriesId}, Volume {VolumeId}, Chapter {ChapterId}, Page {PageNum}", bookmarkDto.SeriesId, bookmarkDto.VolumeId, bookmarkDto.ChapterId, bookmarkDto.Page); - return false; + return true; } var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(imageToBookmark); @@ -101,14 +100,13 @@ public class BookmarkService : IBookmarkService VolumeId = bookmarkDto.VolumeId, SeriesId = bookmarkDto.SeriesId, ChapterId = bookmarkDto.ChapterId, - FileName = Path.Join(targetFolderStem, fileInfo.Name) + FileName = Path.Join(targetFolderStem, fileInfo.Name), + AppUserId = userWithBookmarks.Id }; _directoryService.CopyFileToDirectory(imageToBookmark, targetFilepath); - userWithBookmarks.Bookmarks ??= new List(); - userWithBookmarks.Bookmarks.Add(bookmark); - _unitOfWork.UserRepository.Update(userWithBookmarks); + _unitOfWork.UserRepository.Add(bookmark); await _unitOfWork.CommitAsync(); if (settings.ConvertBookmarkToWebP) @@ -136,15 +134,12 @@ public class BookmarkService : IBookmarkService public async Task RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto) { if (userWithBookmarks.Bookmarks == null) return true; + var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x => + x.ChapterId == bookmarkDto.ChapterId && x.Page == bookmarkDto.Page); try { - var bookmarkToDelete = userWithBookmarks.Bookmarks.SingleOrDefault(x => - x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == userWithBookmarks.Id && x.Page == bookmarkDto.Page && - x.SeriesId == bookmarkDto.SeriesId); - if (bookmarkToDelete != null) { - await DeleteBookmarkFiles(new[] {bookmarkToDelete}); _unitOfWork.UserRepository.Delete(bookmarkToDelete); } @@ -152,10 +147,10 @@ public class BookmarkService : IBookmarkService } catch (Exception) { - await _unitOfWork.RollbackAsync(); return false; } + await DeleteBookmarkFiles(new[] {bookmarkToDelete}); return true; } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index af4ed6c3c..21caab496 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -368,15 +368,22 @@ public class DirectoryService : IDirectoryService { var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); if (!di.Exists) return; + try + { + foreach (var file in di.EnumerateFiles()) + { + file.Delete(); + } + foreach (var dir in di.EnumerateDirectories()) + { + dir.Delete(true); + } + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "[ClearDirectory] Could not delete {DirectoryPath} due to permission issue", directoryPath); + } - foreach (var file in di.EnumerateFiles()) - { - file.Delete(); - } - foreach (var dir in di.EnumerateDirectories()) - { - dir.Delete(true); - } } /// diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 1d1271ad5..bebb40d93 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -128,7 +128,7 @@ public class ImageService : IImageService return true; } - catch (Exception ex) + catch (Exception) { /* Swallow Exception */ } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 197731a8b..d9002c689 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -25,6 +25,7 @@ public interface IReaderService Task MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); Task SaveReadingProgress(ProgressDto progressDto, int userId); Task CapPageToChapter(int chapterId, int page); + int CapPageToChapter(Chapter chapter, int page); Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task GetContinuePoint(int seriesId, int userId); @@ -273,6 +274,21 @@ public class ReaderService : IReaderService return page; } + public int CapPageToChapter(Chapter chapter, int page) + { + if (page > chapter.Pages) + { + page = chapter.Pages; + } + + if (page < 0) + { + page = 0; + } + + return page; + } + /// /// Tries to find the next logical Chapter /// diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index affbec32b..b14e7c8bf 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,14 +1,13 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; -using System.Threading; using System.Threading.Tasks; using API.Data; using API.Entities.Enums; using API.Helpers.Converters; using API.Services.Tasks; using API.Services.Tasks.Metadata; -using API.Services.Tasks.Scanner; using Hangfire; using Microsoft.Extensions.Logging; @@ -49,9 +48,13 @@ public class TaskScheduler : ITaskScheduler public static BackgroundJobServer Client => new BackgroundJobServer(); public const string ScanQueue = "scan"; public const string DefaultQueue = "default"; + public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; + public const string CleanupDbTaskId = "cleanup-db"; + public const string CleanupTaskId = "cleanup"; + public const string BackupTaskId = "backup"; + public const string ScanLibrariesTaskId = "scan-libraries"; - public static readonly IList ScanTasks = new List() - {"ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"}; + private static readonly ImmutableArray ScanTasks = ImmutableArray.Create("ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"); private static readonly Random Rnd = new Random(); @@ -83,27 +86,28 @@ public class TaskScheduler : ITaskScheduler { var scanLibrarySetting = setting; _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); - RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), + RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(), () => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); } else { - RecurringJob.AddOrUpdate("scan-libraries", () => ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); } setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; if (setting != null) { _logger.LogDebug("Scheduling Backup Task for {Setting}", setting); - RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); } else { - RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); } - RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); - RecurringJob.AddOrUpdate("cleanup-db", () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(CleanupDbTaskId, () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, TimeZoneInfo.Local); } #region StatsTasks @@ -154,7 +158,6 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _themeService.Scan()); } - #endregion #region UpdateTasks diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 785cc49ed..1044c7ef1 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -1,9 +1,14 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; +using API.DTOs.Filtering; +using API.Entities; using API.Entities.Enums; +using API.Helpers; using API.SignalR; using Hangfire; using Microsoft.AspNetCore.SignalR; @@ -21,6 +26,11 @@ public interface ICleanupService Task DeleteTagCoverImages(); Task CleanupBackups(); void CleanupTemp(); + /// + /// Responsible to remove Series from Want To Read when user's have fully read the series and the series has Publication Status of Completed or Cancelled. + /// + /// + Task CleanupWantToRead(); } /// /// Cleans up after operations on reoccurring basis @@ -195,4 +205,43 @@ public class CleanupService : ICleanupService _logger.LogInformation("Temp directory purged"); } + + public async Task CleanupWantToRead() + { + _logger.LogInformation("Performing cleanup of Series that are Completed and have been fully read that are in Want To Read list"); + + var libraryIds = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Select(l => l.Id).ToList(); + var filter = new FilterDto() + { + PublicationStatus = new List() + { + PublicationStatus.Completed, + PublicationStatus.Cancelled + }, + Libraries = libraryIds, + ReadStatus = new ReadStatus() + { + Read = true, + InProgress = false, + NotRead = false + } + }; + foreach (var user in await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead)) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, user.Id, new UserParams(), filter); + var seriesIds = series.Select(s => s.Id).ToList(); + if (seriesIds.Count == 0) continue; + + user.WantToRead ??= new List(); + user.WantToRead = user.WantToRead.Where(s => !seriesIds.Contains(s.Id)).ToList(); + _unitOfWork.UserRepository.Update(user); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + _logger.LogInformation("Performing cleanup of Series that are Completed and have been fully read that are in Want To Read list, completed"); + } } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 023bc1d2e..98e533353 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -215,6 +215,10 @@ public class ParseScannedFiles /// /// /// + /// If true, does a directory scan first (resulting in folders being tackled in parallel), else does an immediate scan files + /// A map of Series names -> existing folder paths to handle skipping folders + /// Action which returns if the folder was skipped and the infos from said folder + /// Defaults to false /// public async Task ScanLibrariesForSeries(LibraryType libraryType, IEnumerable folders, string libraryName, bool isLibraryScan, diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 07f515724..4b4f62130 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1029,7 +1029,7 @@ public static class Parser { try { - if (!Regex.IsMatch(range, @"^[\d-.]+$")) + if (!Regex.IsMatch(range, @"^[\d\-.]+$")) { return (float) 0.0; } @@ -1047,7 +1047,7 @@ public static class Parser { try { - if (!Regex.IsMatch(range, @"^[\d-.]+$")) + if (!Regex.IsMatch(range, @"^[\d\-.]+$")) { return (float) 0.0; } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 6db859b3b..465d7c624 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -210,13 +210,13 @@ public class ProcessSeries : IProcessSeries if (!library.Folders.Select(f => f.Path).Contains(seriesDirs.Keys.First())) { series.FolderPath = Parser.Parser.NormalizePath(seriesDirs.Keys.First()); + _logger.LogDebug("Updating {Series} FolderPath to {FolderPath}", series.Name, series.FolderPath); } } } public void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false) { - //BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 662016415..02684c792 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -25,6 +25,7 @@ public interface IScannerService /// cover images if forceUpdate is true. /// /// Library to scan against + /// Don't perform optimization checks, defaults to false [Queue(TaskScheduler.ScanQueue)] [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] @@ -396,6 +397,7 @@ public class ScannerService : IScannerService /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes /// /// + /// Defaults to false [Queue(TaskScheduler.ScanQueue)] [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] @@ -484,7 +486,7 @@ public class ScannerService : IScannerService { _logger.LogInformation( "[ScannerService] Finished library scan of {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}. There were no changes", - totalFiles, seenSeries.Count, sw.ElapsedMilliseconds, library.Name); + seenSeries.Count, sw.ElapsedMilliseconds, library.Name); } else { diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 5616e69f5..4f7cafedd 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/package.json b/UI/Web/package.json index a71a77182..12405c3bc 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -5,7 +5,7 @@ "ng": "ng", "start": "ng serve", "build": "ng build", - "prod": "ng build --configuration production", + "prod": "ng build --configuration production --aot --output-hashing=all", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "test": "jest", "test:watch": "jest --watch", diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index e346ccd4f..439d7f508 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -6,6 +6,11 @@ export interface FilterItem { selected: boolean; } +export interface Range { + min: T; + max: T; +} + export interface SeriesFilter { formats: Array; libraries: Array, @@ -30,6 +35,7 @@ export interface SeriesFilter { languages: Array; publicationStatus: Array; seriesNameQuery: string; + releaseYearRange: Range | null; } export interface SortOptions { @@ -42,7 +48,8 @@ export enum SortField { Created = 2, LastModified = 3, LastChapterAdded = 4, - TimeToRead = 5 + TimeToRead = 5, + ReleaseYear = 6, } export interface ReadStatus { diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 0d51a9120..bd6e649c1 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -26,6 +26,10 @@ export class ServerService { return this.httpClient.post(this.baseUrl + 'server/clear-cache', {}); } + cleanupWantToRead() { + return this.httpClient.post(this.baseUrl + 'server/cleanup-want-to-read', {}); + } + backupDatabase() { return this.httpClient.post(this.baseUrl + 'server/backup-db', {}); } @@ -42,7 +46,7 @@ export class ServerService { return this.httpClient.get(this.baseUrl + 'server/accessible'); } - getReoccuringJobs() { + getRecurringJobs() { return this.httpClient.get(this.baseUrl + 'server/jobs'); } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html index e0dc4211a..23aca97ab 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html @@ -1,6 +1,6 @@
-

Reoccuring Tasks

+

Recurring Tasks

  How often Kavita will scan and refresh metadata around manga files. @@ -43,7 +43,7 @@ -

Reoccuring Tasks

+

Recurring Tasks

@@ -53,7 +53,7 @@ - + diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index f0ca52d75..b34b80e56 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -1,10 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; -import { ConfirmService } from 'src/app/shared/confirm.service'; import { SettingsService } from '../settings.service'; import { ServerSettings } from '../_models/server-settings'; -import { catchError, finalize, shareReplay, take, takeWhile } from 'rxjs/operators'; +import { shareReplay, take } from 'rxjs/operators'; import { defer, forkJoin, Observable, of } from 'rxjs'; import { ServerService } from 'src/app/_services/server.service'; import { Job } from 'src/app/_models/job/job'; @@ -32,7 +31,7 @@ export class ManageTasksSettingsComponent implements OnInit { taskFrequencies: Array = []; logLevels: Array = []; - reoccuringTasks$: Observable> = of([]); + recurringTasks$: Observable> = of([]); adhocTasks: Array = [ { name: 'Convert Bookmarks to WebP', @@ -46,6 +45,12 @@ export class ManageTasksSettingsComponent implements OnInit { api: this.serverService.clearCache(), successMessage: 'Cache has been cleared' }, + { + name: 'Clean up Want to Read', + description: 'Removes any series that users have fully read that are within want to read and have a publication status of Completed. Runs every 24 hours.', + api: this.serverService.cleanupWantToRead(), + successMessage: 'Want to Read has been cleaned up' + }, { name: 'Backup Database', description: 'Takes a backup of the database, bookmarks, themes, manually uploaded covers, and config files', @@ -93,7 +98,7 @@ export class ManageTasksSettingsComponent implements OnInit { this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required])); }); - this.reoccuringTasks$ = this.serverService.getReoccuringJobs().pipe(shareReplay()); + this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay()); } resetForm() { @@ -110,7 +115,7 @@ export class ManageTasksSettingsComponent implements OnInit { this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.serverSettings = settings; this.resetForm(); - this.reoccuringTasks$ = this.serverService.getReoccuringJobs().pipe(shareReplay()); + this.recurringTasks$ = this.serverService.getRecurringJobs().pipe(shareReplay()); this.toastr.success('Server settings updated'); }, (err: any) => { console.error('error: ', err); diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts index 6797d90fb..ee084dce4 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts @@ -94,6 +94,7 @@ export class EditCollectionTagsComponent implements OnInit { this.pagination = series.pagination; this.series = series.result; + this.imageUrls.push(...this.series.map(s => this.imageService.getSeriesCoverImage(s.id))); this.selections = new SelectionModel(true, this.series); this.isLoading = false; diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html index 5c4a01952..54e73d8b3 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html @@ -48,6 +48,16 @@
+
+ + +
+ +
+ +
@@ -60,16 +70,6 @@
-
- - -
- -
- -
diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts index b2e464509..4cd219cb3 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts @@ -157,7 +157,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { @Inject(DOCUMENT) private document: Document, private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef) { // This will always exist at this point in time since this is used within manga reader - const reader = document.querySelector('.reader'); + const reader = document.querySelector('.reading-area'); if (reader !== null) { this.readerElemRef = new ElementRef(reader as HTMLDivElement); } @@ -182,7 +182,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * gets promoted to fullscreen. */ initScrollHandler() { + console.log('Setting up Scroll handler on ', this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body); fromEvent(this.isFullscreenMode ? this.readerElemRef.nativeElement : this.document.body, 'scroll') + //fromEvent(this.document.body, 'scroll') .pipe(debounceTime(20), takeUntil(this.onDestroy)) .subscribe((event) => this.handleScrollEvent(event)); } @@ -263,6 +265,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { */ handleScrollEvent(event?: any) { const verticalOffset = this.getVerticalOffset(); + console.log('offset: ', verticalOffset); if (verticalOffset > this.prevScrollPosition) { this.scrollingDirection = PAGING_DIRECTION.FORWARD; diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index 0e40f896f..bbecf422d 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -14,11 +14,11 @@
- -
-
@@ -65,7 +65,7 @@ 'fit-to-width-double-offset' : FittingOption === FITTING_OPTION.WIDTH && ShouldRenderDoublePage, 'fit-to-height-double-offset': FittingOption === FITTING_OPTION.HEIGHT && ShouldRenderDoublePage, 'original-double-offset' : FittingOption === FITTING_OPTION.ORIGINAL && ShouldRenderDoublePage}" - [style.filter]="'brightness(' + generalSettingsForm.get('darkness')?.value + '%)' | safeStyle"> + [style.filter]="'brightness(' + generalSettingsForm.get('darkness')?.value + '%)' | safeStyle" (dblclick)="bookmarkPage($event)"> @@ -98,8 +98,8 @@
- - + +
@@ -108,8 +108,8 @@
- - + +
@@ -217,10 +217,9 @@
- + {{generalSettingsForm.get('darkness')?.value + '%'}} - {{generalSettingsForm.get('darkness')?.value + '%'}}
diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index ab0512e63..23b6efa62 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -13,7 +13,6 @@ import { PageSplitOption } from '../_models/preferences/page-split-option'; import { BehaviorSubject, forkJoin, fromEvent, ReplaySubject, Subject } from 'rxjs'; import { ToastrService } from 'ngx-toastr'; import { Breakpoint, KEY_CODES, UtilityService } from '../shared/_services/utility.service'; -import { CircularArray } from '../shared/data-structures/circular-array'; import { MemberService } from '../_services/member.service'; import { Stack } from '../shared/data-structures/stack'; import { ChangeContext, LabelType, Options } from '@angular-slider/ngx-slider'; @@ -27,7 +26,7 @@ import { ShortcutsModalComponent } from '../reader-shared/_modals/shortcuts-moda import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { LayoutMode } from './_models/layout-mode'; -const PREFETCH_PAGES = 8; +const PREFETCH_PAGES = 10; const CHAPTER_ID_NOT_FETCHED = -2; const CHAPTER_ID_DOESNT_EXIST = -1; @@ -162,10 +161,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { renderWithCanvas: boolean = false; /** - * A circular array of size PREFETCH_PAGES + 2. Maintains prefetched Images around the current page to load from to avoid loading animation. + * A circular array of size PREFETCH_PAGES. Maintains prefetched Images around the current page to load from to avoid loading animation. * @see CircularArray */ - cachedImages!: CircularArray; + cachedImages!: Array; /** * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls. * @see Stack @@ -289,6 +288,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ rightPaginationOffset = 0; + bookmarkPageHandler = this.bookmarkPage.bind(this); + getPageUrl = (pageNum: number) => { if (this.bookmarkMode) return this.readerService.getBookmarkPageUrl(this.seriesId, this.user.apiKey, pageNum); return this.readerService.getPageUrl(this.chapterId, pageNum); @@ -328,7 +329,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { const result = !( this.isCoverImage() - || this.isCoverImage(this.pageNum - 1) + || this.isCoverImage(this.pageNum - 1) // This is because we use prev page and hence the cover will re-show || this.isWideImage(this.canvasImage) || this.isWideImage(this.canvasImageNext) ); @@ -357,7 +358,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.isWideImage() || this.FittingOption === FITTING_OPTION.WIDTH) { return this.WindowHeight; } - return this.image?.nativeElement.height + 'px'; + return Math.max(this.readingArea?.nativeElement?.clientHeight, this.image?.nativeElement.height) + 'px'; } get RightPaginationOffset() { @@ -511,7 +512,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => { - this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; // TODO: Do I need cd check here? + this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; const needsSplitting = this.isWideImage(); // If we need to split on a menu change, then we need to re-render. if (needsSplitting) { @@ -542,6 +543,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.cdRef.markForCheck(); }); + fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200)).subscribe((event: MouseEvent | any) => { + if (event.detail > 1) return; + this.toggleMenu(); + }); + if (this.canvas) { this.ctx = this.canvas.nativeElement.getContext('2d', { alpha: false }); this.canvasImage.onload = () => this.renderPage(); @@ -678,12 +684,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.inSetup = false; this.cdRef.markForCheck(); - const images = []; - for (let i = 0; i < PREFETCH_PAGES + 2; i++) { - images.push(new Image()); + this.cachedImages = []; + for (let i = 0; i < PREFETCH_PAGES; i++) { + this.cachedImages.push(new Image()) } - this.cachedImages = new CircularArray(images, 0); this.goToPageEvent = new BehaviorSubject(this.pageNum); this.render(); @@ -751,14 +756,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } }); - - const images = []; - for (let i = 0; i < PREFETCH_PAGES + 2; i++) { - images.push(new Image()); + this.cachedImages = []; + for (let i = 0; i < PREFETCH_PAGES; i++) { + this.cachedImages.push(new Image()); } - this.cachedImages = new CircularArray(images, 0); - this.render(); }, () => { @@ -1071,7 +1073,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ setCanvasImage() { if (this.layoutMode === LayoutMode.Single) { - const img = this.cachedImages.arr.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum); + const img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum); if (img) { this.canvasImage = img; // If we tried to use this for double, then the loadPage might not render correctly when switching layout mode } else { @@ -1294,26 +1296,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } /** - * Maintains a circular array of images (that are requested from backend) around the user's current page. This allows for quick loading (seemless to user) + * Maintains an array of images (that are requested from backend) around the user's current page. This allows for quick loading (seemless to user) * and also maintains page info (wide image, etc) due to onload event. */ prefetch() { - let index = 1; + for(let i = 1; i <= PREFETCH_PAGES - 3; i++) { + const numOffset = this.pageNum + i; + if (numOffset > this.maxPages - 1) continue; - this.cachedImages.applyFor((item, _) => { - const offsetIndex = this.pageNum + index; - const urlPageNum = this.readerService.imageUrlToPageNum(item.src); + const index = numOffset % this.cachedImages.length; + if (this.readerService.imageUrlToPageNum(this.cachedImages[index].src) !== numOffset) { + this.cachedImages[index].src = this.getPageUrl(numOffset); + } + } - if (urlPageNum === offsetIndex || urlPageNum === this.pageNum) { - index += 1; - return; - } - - if (offsetIndex < this.maxPages - 1) { - item.src = this.getPageUrl(offsetIndex); - index += 1; - } - }, this.cachedImages.size() - 3); + //console.log(this.pageNum, ' Prefetched pages: ', this.cachedImages.map(img => this.readerService.imageUrlToPageNum(img.src))); } @@ -1490,7 +1487,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { // We must set this here because loadPage from render doesn't call if we aren't page splitting if (this.readerMode !== ReaderMode.Webtoon) { - this.canvasImage = this.cachedImages.current(); + this.canvasImage = this.cachedImages[this.pageNum & this.cachedImages.length]; this.isLoading = true; } @@ -1524,7 +1521,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { /** * Bookmarks the current page for the chapter */ - bookmarkPage() { + bookmarkPage(event: MouseEvent | undefined = undefined) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } const pageNum = this.pageNum; const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed; @@ -1533,40 +1534,42 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (isDouble) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1)); forkJoin(apis).pipe(take(1)).subscribe(() => { delete this.bookmarks[pageNum]; + if (isDouble) delete this.bookmarks[pageNum + 1]; }); } else { let apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)]; if (isDouble) apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1)); forkJoin(apis).pipe(take(1)).subscribe(() => { this.bookmarks[pageNum] = 1; + if (isDouble) this.bookmarks[pageNum + 1] = 1; }); } // Show an effect on the image to show that it was bookmarked this.showBookmarkEffectEvent.next(pageNum); - if (this.readerMode != ReaderMode.Webtoon) { + if (this.readerMode === ReaderMode.Webtoon) return; - let elements:Array = []; - if (this.renderWithCanvas && this.canvas) { - elements.push(this.canvas?.nativeElement); - } else { - const image1 = this.document.querySelector('#image-1'); - if (image1 != null) elements.push(image1); + let elements:Array = []; + if (this.renderWithCanvas && this.canvas) { + elements.push(this.canvas?.nativeElement); + } else { + const image1 = this.document.querySelector('#image-1'); + if (image1 != null) elements.push(image1); - if (this.layoutMode === LayoutMode.Double) { - const image2 = this.document.querySelector('#image-2'); - if (image2 != null) elements.push(image2); - } - } - - - if (elements.length > 0) { - elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect')); - setTimeout(() => { - elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect')); - }, 1000); + if (this.layoutMode !== LayoutMode.Single) { + const image2 = this.document.querySelector('#image-2'); + if (image2 != null) elements.push(image2); } } + + + if (elements.length > 0) { + elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect')); + setTimeout(() => { + elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect')); + }, 1000); + } + } /** diff --git a/UI/Web/src/app/metadata-filter/filter-settings.ts b/UI/Web/src/app/metadata-filter/filter-settings.ts index 28f7340e1..cfe504247 100644 --- a/UI/Web/src/app/metadata-filter/filter-settings.ts +++ b/UI/Web/src/app/metadata-filter/filter-settings.ts @@ -14,6 +14,7 @@ export class FilterSettings { languageDisabled = false; publicationStatusDisabled = false; searchNameDisabled = false; + releaseYearDisabled = false; presets: SeriesFilter | undefined; /** * Should the filter section be open by default 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 7284b8c47..f0b97d9ef 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -325,6 +325,21 @@
+
+
+
+ + +
+
+ +
+
+ + +
+ +
@@ -341,11 +356,11 @@ +
-
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 8579ef60d..a6b375076 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; @@ -69,6 +69,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { readProgressGroup!: FormGroup; sortGroup!: FormGroup; seriesNameGroup!: FormGroup; + releaseYearRange!: FormGroup; isAscendingSort: boolean = true; updateApplied: number = 0; @@ -120,6 +121,11 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, []) }); + this.releaseYearRange = new FormGroup({ + min: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)]), + max: new FormControl({value: undefined, disabled: this.filterSettings.releaseYearDisabled}, [Validators.min(1000), Validators.max(9999)]) + }); + this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => { this.filter.readStatus.read = this.readProgressGroup.get('read')?.value; this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value; @@ -163,6 +169,15 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); }); + this.releaseYearRange.valueChanges.pipe( + distinctUntilChanged(), + takeUntil(this.onDestroy) + ) + .subscribe(changes => { + this.filter.releaseYearRange = {min: this.releaseYearRange.get('min')?.value, max: this.releaseYearRange.get('max')?.value}; + this.cdRef.markForCheck(); + }); + this.loadFromPresetsAndSetup(); } diff --git a/UI/Web/src/app/shared/_services/filter-utilities.service.ts b/UI/Web/src/app/shared/_services/filter-utilities.service.ts index 2fc6b144d..ff805b3e4 100644 --- a/UI/Web/src/app/shared/_services/filter-utilities.service.ts +++ b/UI/Web/src/app/shared/_services/filter-utilities.service.ts @@ -336,6 +336,7 @@ export class FilterUtilitiesService { languages: [], publicationStatus: [], seriesNameQuery: '', + releaseYearRange: null }; return data; diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 53be1c743..cde10f531 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -58,7 +58,7 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component PersonBadgeComponent, // Used Series Detail BadgeExpanderComponent, // Used Series Detail/Metadata - IconAndTitleComponent // Used in Series Detail/Metadata + IconAndTitleComponent, // Used in Series Detail/Metadata ], }) diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index 314086490..82f049f03 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -126,7 +126,7 @@ diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 12e1b44d7..bf1210156 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -252,4 +252,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { transformKeyToOpdsUrl(key: string) { return `${location.origin}/api/opds/${key}`; } + + handleBackgroundColorChange() { + this.settingsForm.markAsDirty(); + this.settingsForm.markAsTouched(); + this.cdRef.markForCheck(); + } } diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 06ae21b39..2a71d8497 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -204,7 +204,7 @@ --manga-reader-overlay-filter: blur(10px); --manga-reader-overlay-bg-color: rgba(0,0,0,0.5); --manga-reader-overlay-text-color: white; - --manga-reader-bg-color: black; + --manga-reader-bg-color: black; // TODO: Remove this --manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5); --manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5);
{{task.title | titlecase}}