From 83f8e2547801448099d917133662903d1e4b555f Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 11 Sep 2021 11:47:12 -0700 Subject: [PATCH] Continuous Reading for Webtoons & I Just Couldn't Stop Coding (#574) * Fixed an issue from perf tuning where I forgot to send Pages to frontend, breaking reader. * Built out continuous reading for webtoon reader. Still has some issues with triggering. * Refactored GetUserByUsernameAsync to have a new flavor and allow the caller to pass in bitwise flags for what to include. This has a get by username or id variant. Code is much cleaner and snappier as we avoid many extra joins when not needed. * Cleanup old code from UserRepository.cs * Refactored OPDS to use faster API lookups for User * Refactored more code to be cleaner and faster. * Refactored GetNext/Prev ChapterIds to ReaderService. * Refactored Repository methods to their correct entity repos. * Refactored DTOs and overall cleanup of the code. * Added ability to press 'b' to bookmark a page * On hitting last page, save progress forcing last page to be read. Adjusted logic for the top and bottom spacers for triggering next/prev chapter load * When at top or moving between chapters, scrolling down then up will now trigger page load. Show a toastr to inform the user of a change in chapter (it can be really fast to switch) * Cleaned up scroll code * Fixed an issue where loading a chapter with last page bookmarked, we'd load lastpage - 1 * Fixed last page of webtoon reader not being resumed on loading said chapter due to a difference in how max page is handled between infinite scroller and manga reader. * Removed some comments * Book reader shouldn't look at left/right tap to paginate elems for position bookmarking. Missed a few areas for saving while in incognito mode * Added a benchmark to test out a sort code * Updated the read status on reading list to use same style as other places * Refactored GetNextChapterId to bring the average response time from 1.2 seconds to 400ms. * Added a filter to add to list when there are more than 5 reading lists * Added download reading list (will be removed, just saving for later). Fixes around styling on reading lists * Removed ability to download reading lists * Tweaked the logic for infinite scroller to be much smoother loading next/prev chapter. Added a bug marker for a concurrency bug. * Updated the top spacer so that when you hit the top, you stay at the page height and can now just scroll up. * Got the logic for scrolling up. Now just need the CSS then cont infinite scroller will be working * More polishing on infinite scroller * Removed IsSpecial on volumeDto, which is not used anywhere. * Cont Reading inf scroller edition is done. * Code smells and fixed package.json explore script --- API.Benchmark/Program.cs | 3 +- API.Benchmark/TestBenchmark.cs | 69 +++++++ API/Comparators/ChapterSortComparer.cs | 9 + API/Comparators/NaturalSortComparer.cs | 16 +- API/Controllers/AccountController.cs | 1 + API/Controllers/BookController.cs | 6 +- API/Controllers/DownloadController.cs | 8 +- API/Controllers/ImageController.cs | 4 +- API/Controllers/OPDSController.cs | 78 ++++---- API/Controllers/ReaderController.cs | 153 ++++------------ API/Controllers/ReadingListController.cs | 2 +- API/Controllers/SeriesController.cs | 7 +- API/Controllers/UploadController.cs | 4 +- API/DTOs/{ => Account}/LoginDto.cs | 0 API/DTOs/{ => Account}/ResetPasswordDto.cs | 4 +- API/DTOs/Downloads/DownloadBookmarkDto.cs | 1 + API/DTOs/ImageDto.cs | 15 -- API/DTOs/InProgressChapterDto.cs | 24 --- API/DTOs/{ => Reader}/BookmarkDto.cs | 2 +- API/DTOs/{ => Reader}/MarkReadDto.cs | 4 +- API/DTOs/{ => Reader}/MarkVolumeReadDto.cs | 4 +- .../RemoveBookmarkForSeriesDto.cs | 2 +- API/DTOs/{ => Settings}/ServerSettingDTO.cs | 0 API/DTOs/VolumeDto.cs | 1 - .../Repositories/AppUserProgressRepository.cs | 13 ++ API/Data/Repositories/ChapterRepository.cs | 91 +++++++++- API/Data/Repositories/SeriesRepository.cs | 14 +- API/Data/Repositories/UserRepository.cs | 112 +++++++++--- API/Data/Repositories/VolumeRepository.cs | 91 ++-------- API/Data/UnitOfWork.cs | 4 +- API/Entities/Volume.cs | 3 +- API/Helpers/AutoMapperProfiles.cs | 1 + .../IAppUserProgressRepository.cs | 3 + .../Repositories/IChapterRepository.cs | 8 + .../Repositories/ISeriesRepository.cs | 2 +- .../Repositories/IUserRepository.cs | 15 +- .../Repositories/IVolumeRepository.cs | 7 +- API/Interfaces/Services/IReaderService.cs | 6 +- API/Interfaces/Services/ReaderService.cs | 168 ++++++++++++++++-- API/Services/CacheService.cs | 4 +- UI/Web/package-lock.json | 126 +++++++++++++ UI/Web/package.json | 3 +- UI/Web/src/app/_models/series.ts | 1 - .../directory-picker.component.html | 2 +- UI/Web/src/app/admin/admin.module.ts | 4 +- .../book-reader/book-reader.component.html | 4 +- .../book-reader/book-reader.component.ts | 16 +- .../edit-series-modal.component.html | 1 - .../infinite-scroller.component.html | 32 +++- .../infinite-scroller.component.scss | 24 +++ .../infinite-scroller.component.ts | 76 +++++++- .../manga-reader/manga-reader.component.html | 2 +- .../manga-reader/manga-reader.component.ts | 25 ++- UI/Web/src/app/{admin => pipe}/filter.pipe.ts | 0 UI/Web/src/app/pipe/pipe.module.ts | 18 ++ .../add-to-list-modal.component.html | 33 ++-- .../add-to-list-modal.component.ts | 5 + .../reading-list-detail.component.html | 17 +- .../reading-list-detail.component.ts | 7 +- .../app/reading-list/reading-list.module.ts | 6 +- .../reading-list.router.module.ts | 7 +- .../reading-lists/reading-lists.component.ts | 10 +- .../app/shared/_services/download.service.ts | 4 +- .../app/shared/_services/utility.service.ts | 1 + 64 files changed, 937 insertions(+), 446 deletions(-) create mode 100644 API.Benchmark/TestBenchmark.cs rename API/DTOs/{ => Account}/LoginDto.cs (100%) rename API/DTOs/{ => Account}/ResetPasswordDto.cs (90%) delete mode 100644 API/DTOs/ImageDto.cs delete mode 100644 API/DTOs/InProgressChapterDto.cs rename API/DTOs/{ => Reader}/BookmarkDto.cs (89%) rename API/DTOs/{ => Reader}/MarkReadDto.cs (73%) rename API/DTOs/{ => Reader}/MarkVolumeReadDto.cs (81%) rename API/DTOs/{ => Reader}/RemoveBookmarkForSeriesDto.cs (78%) rename API/DTOs/{ => Settings}/ServerSettingDTO.cs (100%) rename UI/Web/src/app/{admin => pipe}/filter.pipe.ts (100%) create mode 100644 UI/Web/src/app/pipe/pipe.module.ts diff --git a/API.Benchmark/Program.cs b/API.Benchmark/Program.cs index 05c296f8b..0b35a82f0 100644 --- a/API.Benchmark/Program.cs +++ b/API.Benchmark/Program.cs @@ -12,7 +12,8 @@ namespace API.Benchmark { static void Main(string[] args) { - BenchmarkRunner.Run(); + //BenchmarkRunner.Run(); + BenchmarkRunner.Run(); } } } diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs new file mode 100644 index 000000000..a2aabdd8a --- /dev/null +++ b/API.Benchmark/TestBenchmark.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Comparators; +using API.DTOs; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; + +namespace API.Benchmark +{ + /// + /// This is used as a scratchpad for testing + /// + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [RankColumn] + public class TestBenchmark + { + private readonly NaturalSortComparer _naturalSortComparer = new (); + + + private static IEnumerable GenerateVolumes(int max) + { + var random = new Random(); + var maxIterations = random.Next(max) + 1; + var list = new List(); + for (var i = 0; i < maxIterations; i++) + { + list.Add(new VolumeDto() + { + Number = random.Next(10) > 5 ? 1 : 0, + Chapters = GenerateChapters() + }); + } + + return list; + } + + private static List GenerateChapters() + { + var list = new List(); + for (var i = 1; i < 40; i++) + { + list.Add(new ChapterDto() + { + Range = i + string.Empty + }); + } + + return list; + } + + private void SortSpecialChapters(IEnumerable volumes) + { + foreach (var v in volumes.Where(vDto => vDto.Number == 0)) + { + v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList(); + } + } + + [Benchmark] + public void TestSortSpecialChapters() + { + var volumes = GenerateVolumes(10); + SortSpecialChapters(volumes); + } + + } +} diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs index a56693fca..3791e05ff 100644 --- a/API/Comparators/ChapterSortComparer.cs +++ b/API/Comparators/ChapterSortComparer.cs @@ -2,8 +2,17 @@ namespace API.Comparators { + /// + /// Sorts chapters based on their Number. Uses natural ordering of doubles. + /// public class ChapterSortComparer : IComparer { + /// + /// Normal sort for 2 doubles. 0 always comes before anything else + /// + /// + /// + /// public int Compare(double x, double y) { if (x == 0.0 && y == 0.0) return 0; diff --git a/API/Comparators/NaturalSortComparer.cs b/API/Comparators/NaturalSortComparer.cs index ac10e09ae..e558f94ae 100644 --- a/API/Comparators/NaturalSortComparer.cs +++ b/API/Comparators/NaturalSortComparer.cs @@ -10,7 +10,7 @@ namespace API.Comparators { private readonly bool _isAscending; private Dictionary _table = new(); - + private bool _disposed; @@ -23,9 +23,9 @@ namespace API.Comparators { if (x == y) return 0; + // BUG: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct. if (!_table.TryGetValue(x ?? Empty, out var x1)) { - // .Replace(" ", Empty) x1 = Regex.Split(x ?? Empty, "([0-9]+)"); _table.Add(x ?? Empty, x1); } @@ -50,8 +50,8 @@ namespace API.Comparators returnVal = 1; } else if (x1.Length > y1.Length) - { - returnVal = -1; + { + returnVal = -1; } else { @@ -78,12 +78,12 @@ namespace API.Comparators { if (disposing) { - // called via myClass.Dispose(). + // called via myClass.Dispose(). _table.Clear(); _table = null; } // Release unmanaged resources. - // Set large fields to null. + // Set large fields to null. _disposed = true; } } @@ -93,10 +93,10 @@ namespace API.Comparators Dispose(true); SuppressFinalize(this); } - + ~NaturalSortComparer() // the finalizer { Dispose(false); } } -} \ No newline at end of file +} diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 8e108cd28..58478e2f8 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.DTOs.Account; using API.Entities; using API.Errors; using API.Extensions; diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 1a42505c7..448d45c02 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -39,7 +39,7 @@ namespace API.Controllers var bookTitle = string.Empty; if (dto.SeriesFormat == MangaFormat.Epub) { - var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First(); + var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath); bookTitle = book.Title; } @@ -62,7 +62,7 @@ namespace API.Controllers [HttpGet("{chapterId}/book-resources")] public async Task GetBookPageResources(int chapterId, [FromQuery] string file) { - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); var key = BookService.CleanContentKeys(file); @@ -81,7 +81,7 @@ namespace API.Controllers { // This will return a list of mappings from ID -> pagenum. ID will be the xhtml key and pagenum will be the reading order // this is used to rewrite anchors in the book text so that we always load properly in FE - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath); var mappings = await _bookService.CreateKeyToPageMappingAsync(book); diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index acff575cf..3000e1f22 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -48,7 +48,7 @@ namespace API.Controllers [HttpGet("chapter-size")] public async Task> GetChapterSize(int chapterId) { - var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath))); } @@ -90,8 +90,8 @@ namespace API.Controllers [HttpGet("chapter")] public async Task DownloadChapter(int chapterId) { - var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try @@ -154,7 +154,7 @@ namespace API.Controllers var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}"); var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId) .Select(b => b.Page).ToList(); - var mangaFiles = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); + var mangaFiles = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); switch (series.Format) { case MangaFormat.Image: diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 31da9c54b..bdcd27b77 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -27,7 +27,7 @@ namespace API.Controllers [HttpGet("chapter-cover")] public async Task GetChapterCoverImage(int chapterId) { - var content = await _unitOfWork.VolumeRepository.GetChapterCoverImageAsync(chapterId); + var content = await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId); if (content == null) return BadRequest("No cover image"); Response.AddCacheHeader(content); @@ -42,7 +42,7 @@ namespace API.Controllers [HttpGet("volume-cover")] public async Task GetVolumeCoverImage(int volumeId) { - var content = await _unitOfWork.SeriesRepository.GetVolumeCoverImageAsync(volumeId); + var content = await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId); if (content == null) return BadRequest("No cover image"); Response.AddCacheHeader(content); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 8302c648a..ef83c2a69 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -141,8 +141,8 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); - var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id); + var userId = await GetUser(apiKey); + var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey); @@ -168,7 +168,8 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); IEnumerable tags; @@ -210,7 +211,8 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); IEnumerable tags; @@ -229,7 +231,7 @@ namespace API.Controllers return BadRequest("Collection does not exist or you don't have access"); } - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, user.Id, new UserParams() + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, new UserParams() { PageNumber = pageNumber, PageSize = 20 @@ -253,9 +255,9 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); - var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(user.Id, true, new UserParams() + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, true, new UserParams() { PageNumber = pageNumber }); @@ -286,7 +288,8 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName); var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); @@ -297,7 +300,7 @@ namespace API.Controllers var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); - var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, user.Id); + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); foreach (var item in items) { feed.Entries.Add(new FeedEntry() @@ -323,16 +326,16 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); var library = - (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).SingleOrDefault(l => + (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l => l.Id == libraryId); if (library == null) { return BadRequest("User does not have access to this library"); } - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, new UserParams() + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, new UserParams() { PageNumber = pageNumber, PageSize = 20 @@ -355,8 +358,8 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); - var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, user.Id, new UserParams() + var userId = await GetUser(apiKey); + var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, new UserParams() { PageNumber = pageNumber, PageSize = 20 @@ -380,13 +383,13 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); var userParams = new UserParams() { PageNumber = pageNumber, PageSize = 20 }; - var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, 0, userParams, _filterDto); + var results = await _unitOfWork.SeriesRepository.GetInProgress(userId, 0, userParams, _filterDto); var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) .Take(userParams.PageSize).ToList(); var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); @@ -410,14 +413,14 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); + var userId = await GetUser(apiKey); if (string.IsNullOrEmpty(query)) { return BadRequest("You must pass a query parameter"); } query = query.Replace(@"%", ""); // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); @@ -462,9 +465,9 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id); + var userId = await GetUser(apiKey); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); foreach (var volumeDto in volumes) @@ -481,11 +484,11 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id); + var userId = await GetUser(apiKey); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); var chapters = - (await _unitOfWork.VolumeRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), + (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), _chapterSortComparer); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); @@ -512,11 +515,11 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var user = await GetUser(apiKey); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id); + var userId = await GetUser(apiKey); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); - var chapter = await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId); - var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey); foreach (var mangaFile in files) @@ -540,7 +543,7 @@ namespace API.Controllers { if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest("OPDS is not enabled on this server"); - var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files); return File(bytes, contentType, fileDownloadName); } @@ -628,7 +631,7 @@ namespace API.Controllers return new FeedEntry() { Id = volumeDto.Id.ToString(), - Title = volumeDto.IsSpecial ? "Specials" : "Volume " + volumeDto.Name, + Title = "Volume " + volumeDto.Name, Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"), @@ -723,15 +726,18 @@ namespace API.Controllers /// Gets the user from the API key /// /// - private async Task GetUser(string apiKey) + private async Task GetUser(string apiKey) { - var user = await _unitOfWork.UserRepository.GetUserByApiKeyAsync(apiKey); - if (user == null) + try { - throw new KavitaException("User does not exist"); + var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + return user; } - - return user; + catch + { + /* Do nothing */ + } + throw new KavitaException("User does not exist"); } private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 9578d8470..cf759fcb3 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Reader; using API.Entities; @@ -25,9 +26,6 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IReaderService _readerService; - private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer(); /// public ReaderController(IDirectoryService directoryService, ICacheService cacheService, @@ -85,7 +83,7 @@ namespace API.Controllers if (chapter == null) return BadRequest("Could not find Chapter"); var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); - var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First(); + var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); return Ok(new ChapterInfoDto() { @@ -106,7 +104,7 @@ namespace API.Controllers [HttpPost("mark-read")] public async Task MarkRead(MarkReadDto markReadDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId); user.Progresses ??= new List(); foreach (var volume in volumes) @@ -178,7 +176,7 @@ namespace API.Controllers [HttpPost("mark-unread")] public async Task MarkUnread(MarkReadDto markReadDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId); user.Progresses ??= new List(); foreach (var volume in volumes) @@ -213,9 +211,9 @@ namespace API.Controllers [HttpPost("mark-volume-unread")] public async Task MarkVolumeAsUnread(MarkVolumeReadDto markVolumeReadDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var chapters = await _unitOfWork.VolumeRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); + var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); foreach (var chapter in chapters) { user.Progresses ??= new List(); @@ -257,9 +255,9 @@ namespace API.Controllers [HttpPost("mark-volume-read")] public async Task MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var chapters = await _unitOfWork.VolumeRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); + var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); foreach (var chapter in chapters) { user.Progresses ??= new List(); @@ -301,7 +299,7 @@ namespace API.Controllers [HttpGet("get-progress")] public async Task> GetProgress(int chapterId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); var progressBookmark = new ProgressDto() { PageNum = 0, @@ -331,7 +329,8 @@ namespace API.Controllers public async Task BookmarkProgress(ProgressDto progressDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (await _readerService.SaveReadingProgress(progressDto, user)) return Ok(true); + + if (await _readerService.SaveReadingProgress(progressDto, user.Id)) return Ok(true); return BadRequest("Could not save progress"); } @@ -344,7 +343,7 @@ namespace API.Controllers [HttpGet("get-bookmarks")] public async Task>> GetBookmarks(int chapterId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId)); } @@ -356,7 +355,7 @@ namespace API.Controllers [HttpGet("get-all-bookmarks")] public async Task>> GetAllBookmarks() { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id)); } @@ -369,7 +368,7 @@ namespace API.Controllers [HttpPost("remove-bookmarks")] public async Task RemoveBookmarks(RemoveBookmarkForSeriesDto dto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok("Nothing to remove"); try { @@ -399,7 +398,7 @@ namespace API.Controllers [HttpGet("get-volume-bookmarks")] public async Task>> GetBookmarksForVolume(int volumeId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId)); } @@ -412,7 +411,7 @@ namespace API.Controllers [HttpGet("get-series-bookmarks")] public async Task>> GetBookmarksForSeries(int seriesId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok(Array.Empty()); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId)); @@ -426,45 +425,28 @@ namespace API.Controllers [HttpPost("bookmark")] public async Task BookmarkPage(BookmarkDto bookmarkDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - // Don't let user save past total pages. - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId); - if (bookmarkDto.Page > chapter.Pages) - { - bookmarkDto.Page = chapter.Pages; - } - - if (bookmarkDto.Page < 0) - { - bookmarkDto.Page = 0; - } - + bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page); try { - user.Bookmarks ??= new List(); - var userBookmark = - user.Bookmarks.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id && x.Page == bookmarkDto.Page); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + var userBookmark = + await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id); if (userBookmark == null) { - user.Bookmarks.Add(new AppUserBookmark() - { - Page = bookmarkDto.Page, - VolumeId = bookmarkDto.VolumeId, - SeriesId = bookmarkDto.SeriesId, - ChapterId = bookmarkDto.ChapterId, - }); - } - else - { - userBookmark.Page = bookmarkDto.Page; - userBookmark.SeriesId = bookmarkDto.SeriesId; - userBookmark.VolumeId = bookmarkDto.VolumeId; + user.Bookmarks ??= new List(); + user.Bookmarks.Add(new AppUserBookmark() + { + Page = bookmarkDto.Page, + VolumeId = bookmarkDto.VolumeId, + SeriesId = bookmarkDto.SeriesId, + ChapterId = bookmarkDto.ChapterId, + }); + _unitOfWork.UserRepository.Update(user); } - _unitOfWork.UserRepository.Update(user); if (await _unitOfWork.CommitAsync()) { @@ -487,7 +469,7 @@ namespace API.Controllers [HttpPost("unbookmark")] public async Task UnBookmarkPage(BookmarkDto bookmarkDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user.Bookmarks == null) return Ok(); try { @@ -496,7 +478,6 @@ namespace API.Controllers && x.AppUserId == user.Id && x.Page != bookmarkDto.Page).ToList(); - _unitOfWork.UserRepository.Update(user); if (await _unitOfWork.CommitAsync()) @@ -526,57 +507,9 @@ namespace API.Controllers public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); - var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); - var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId); - if (currentVolume.Number == 0) - { - // Handle specials by sorting on their Filename aka Range - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer), currentChapter.Number); - if (chapterId > 0) return Ok(chapterId); - } - - foreach (var volume in volumes) - { - if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1) - { - // Handle Chapters within current Volume - // In this case, i need 0 first because 0 represents a full volume file. - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number); - if (chapterId > 0) return Ok(chapterId); - } - - if (volume.Number == currentVolume.Number + 1) - { - // Handle Chapters within next Volume - // ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+ - var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList(); - if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0")) - { - return chapters.Last().Id; - } - - return Ok(chapters.FirstOrDefault()?.Id); - } - } - return Ok(-1); + return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, userId); } - private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) - { - var next = false; - var chaptersList = chapters.ToList(); - foreach (var chapter in chaptersList) - { - if (next) - { - return chapter.Id; - } - if (currentChapterNumber.Equals(chapter.Number)) next = true; - } - - return -1; - } /// /// Returns the previous logical chapter from the series. @@ -592,29 +525,7 @@ namespace API.Controllers public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); - var currentVolume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); - var currentChapter = await _unitOfWork.VolumeRepository.GetChapterAsync(currentChapterId); - - if (currentVolume.Number == 0) - { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer).Reverse(), currentChapter.Number); - if (chapterId > 0) return Ok(chapterId); - } - - foreach (var volume in volumes.Reverse()) - { - if (volume.Number == currentVolume.Number) - { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number); - if (chapterId > 0) return Ok(chapterId); - } - if (volume.Number == currentVolume.Number - 1) - { - return Ok(volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault()?.Id); - } - } - return Ok(-1); + return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId); } } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index c82b77d11..caed9f995 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -265,7 +265,7 @@ namespace API.Controllers var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); if (readingList == null) return BadRequest("Reading List does not exist"); var chapterIdsForVolume = - (await _unitOfWork.VolumeRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); + (await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); // If there are adds, tell tracking this has been modified if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index d64c78fd3..776570dae 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.Entities; @@ -109,16 +110,14 @@ namespace API.Controllers [HttpGet("chapter")] public async Task> GetChapter(int chapterId) { - return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId)); + return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId)); } - - [HttpPost("update-rating")] public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); var userRating = await _unitOfWork.UserRepository.GetUserRating(updateSeriesRatingDto.SeriesId, user.Id) ?? new AppUserRating(); diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 0d924c66d..05274d2de 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -142,7 +142,7 @@ namespace API.Controllers if (bytes.Length > 0) { - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); chapter.CoverImage = bytes; chapter.CoverImageLocked = true; _unitOfWork.ChapterRepository.Update(chapter); @@ -178,7 +178,7 @@ namespace API.Controllers { try { - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); chapter.CoverImage = Array.Empty(); chapter.CoverImageLocked = false; _unitOfWork.ChapterRepository.Update(chapter); diff --git a/API/DTOs/LoginDto.cs b/API/DTOs/Account/LoginDto.cs similarity index 100% rename from API/DTOs/LoginDto.cs rename to API/DTOs/Account/LoginDto.cs diff --git a/API/DTOs/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs similarity index 90% rename from API/DTOs/ResetPasswordDto.cs rename to API/DTOs/Account/ResetPasswordDto.cs index 4b3ee3580..2e7ef4d66 100644 --- a/API/DTOs/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs +namespace API.DTOs.Account { public class ResetPasswordDto { @@ -10,4 +10,4 @@ namespace API.DTOs [StringLength(32, MinimumLength = 6)] public string Password { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs index 5239b4aae..b7ccf9569 100644 --- a/API/DTOs/Downloads/DownloadBookmarkDto.cs +++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Reader; namespace API.DTOs.Downloads { diff --git a/API/DTOs/ImageDto.cs b/API/DTOs/ImageDto.cs deleted file mode 100644 index e66591001..000000000 --- a/API/DTOs/ImageDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace API.DTOs -{ - public class ImageDto - { - public int Page { get; init; } - public string Filename { get; init; } - public string FullPath { get; init; } - public int Width { get; init; } - public int Height { get; init; } - public string Format { get; init; } - public byte[] Content { get; init; } - public string MangaFileName { get; init; } - public bool NeedsSplitting { get; init; } - } -} \ No newline at end of file diff --git a/API/DTOs/InProgressChapterDto.cs b/API/DTOs/InProgressChapterDto.cs deleted file mode 100644 index 08bce3fc6..000000000 --- a/API/DTOs/InProgressChapterDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace API.DTOs -{ - public class InProgressChapterDto - { - public int Id { get; init; } - /// - /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". - /// - public string Range { get; init; } - /// - /// Smallest number of the Range. - /// - public string Number { get; init; } - /// - /// Total number of pages in all MangaFiles - /// - public int Pages { get; init; } - public int SeriesId { get; init; } - public int LibraryId { get; init; } - public string SeriesName { get; init; } - public int VolumeId { get; init; } - - } -} \ No newline at end of file diff --git a/API/DTOs/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs similarity index 89% rename from API/DTOs/BookmarkDto.cs rename to API/DTOs/Reader/BookmarkDto.cs index c45a183c3..3653bcaa0 100644 --- a/API/DTOs/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs +namespace API.DTOs.Reader { public class BookmarkDto { diff --git a/API/DTOs/MarkReadDto.cs b/API/DTOs/Reader/MarkReadDto.cs similarity index 73% rename from API/DTOs/MarkReadDto.cs rename to API/DTOs/Reader/MarkReadDto.cs index 1b39df2a8..3d94e3a9d 100644 --- a/API/DTOs/MarkReadDto.cs +++ b/API/DTOs/Reader/MarkReadDto.cs @@ -1,7 +1,7 @@ -namespace API.DTOs +namespace API.DTOs.Reader { public class MarkReadDto { public int SeriesId { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/MarkVolumeReadDto.cs b/API/DTOs/Reader/MarkVolumeReadDto.cs similarity index 81% rename from API/DTOs/MarkVolumeReadDto.cs rename to API/DTOs/Reader/MarkVolumeReadDto.cs index ffae155a2..757f23aee 100644 --- a/API/DTOs/MarkVolumeReadDto.cs +++ b/API/DTOs/Reader/MarkVolumeReadDto.cs @@ -1,8 +1,8 @@ -namespace API.DTOs +namespace API.DTOs.Reader { public class MarkVolumeReadDto { public int SeriesId { get; init; } public int VolumeId { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/RemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs similarity index 78% rename from API/DTOs/RemoveBookmarkForSeriesDto.cs rename to API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs index 7ec76b081..a269b7095 100644 --- a/API/DTOs/RemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs +namespace API.DTOs.Reader { public class RemoveBookmarkForSeriesDto { diff --git a/API/DTOs/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs similarity index 100% rename from API/DTOs/ServerSettingDTO.cs rename to API/DTOs/Settings/ServerSettingDTO.cs diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 7fffb79a0..3e719346f 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -13,7 +13,6 @@ namespace API.DTOs public int PagesRead { get; set; } public DateTime LastModified { get; set; } public DateTime Created { get; set; } - public bool IsSpecial { get; set; } public int SeriesId { get; set; } public ICollection Chapters { get; set; } } diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 1511c0162..9a4d3aa73 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using API.Entities; using API.Entities.Enums; using API.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; @@ -15,6 +16,11 @@ namespace API.Data.Repositories _context = context; } + public void Update(AppUserProgress userProgress) + { + _context.Entry(userProgress).State = EntityState.Modified; + } + /// /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// @@ -62,5 +68,12 @@ namespace API.Data.Repositories .AsNoTracking() .AnyAsync(); } + + public async Task GetUserProgressAsync(int chapterId, int userId) + { + return await _context.AppUserProgresses + .Where(p => p.ChapterId == chapterId && p.AppUserId == userId) + .SingleOrDefaultAsync(); + } } } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 0b7acaeab..a60c0a819 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -1,9 +1,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs; using API.DTOs.Reader; using API.Entities; using API.Interfaces.Repositories; +using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories @@ -11,10 +14,12 @@ namespace API.Data.Repositories public class ChapterRepository : IChapterRepository { private readonly DataContext _context; + private readonly IMapper _mapper; - public ChapterRepository(DataContext context) + public ChapterRepository(DataContext context, IMapper mapper) { _context = context; + _mapper = mapper; } public void Update(Chapter chapter) @@ -30,8 +35,6 @@ namespace API.Data.Repositories .ToListAsync(); } - // TODO: Move over Chapter based queries here - /// /// Populates a partial IChapterInfoDto /// @@ -76,5 +79,87 @@ namespace API.Data.Repositories .AsNoTracking() .SingleAsync(); } + + public Task GetChapterTotalPagesAsync(int chapterId) + { + return _context.Chapter + .Where(c => c.Id == chapterId) + .Select(c => c.Pages) + .SingleOrDefaultAsync(); + } + public async Task GetChapterDtoAsync(int chapterId) + { + var chapter = await _context.Chapter + .Include(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .SingleOrDefaultAsync(c => c.Id == chapterId); + + return chapter; + } + + /// + /// Returns non-tracked files for a given chapterId + /// + /// + /// + public async Task> GetFilesForChapterAsync(int chapterId) + { + return await _context.MangaFile + .Where(c => chapterId == c.ChapterId) + .AsNoTracking() + .ToListAsync(); + } + + /// + /// Returns a Chapter for an Id. Includes linked s. + /// + /// + /// + public async Task GetChapterAsync(int chapterId) + { + return await _context.Chapter + .Include(c => c.Files) + .SingleOrDefaultAsync(c => c.Id == chapterId); + } + + /// + /// Returns Chapters for a volume id. + /// + /// + /// + public async Task> GetChaptersAsync(int volumeId) + { + return await _context.Chapter + .Where(c => c.VolumeId == volumeId) + .ToListAsync(); + } + + /// + /// Returns the cover image for a chapter id. + /// + /// + /// + public async Task GetChapterCoverImageAsync(int chapterId) + { + return await _context.Chapter + .Where(c => c.Id == chapterId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + + /// + /// Returns non-tracked files for a set of chapterIds + /// + /// + /// + public async Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds) + { + return await _context.MangaFile + .Where(c => chapterIds.Contains(c.ChapterId)) + .AsNoTracking() + .ToListAsync(); + } } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 20b140933..7fa26fcc2 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -115,8 +115,6 @@ namespace API.Data.Repositories await AddVolumeModifiers(userId, volumes); SortSpecialChapters(volumes); - - return volumes; } @@ -258,15 +256,6 @@ namespace API.Data.Repositories } } - public async Task GetVolumeCoverImageAsync(int volumeId) - { - return await _context.Volume - .Where(v => v.Id == volumeId) - .Select(v => v.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } - public async Task GetSeriesCoverImageAsync(int seriesId) { return await _context.Series @@ -278,8 +267,9 @@ namespace API.Data.Repositories private async Task AddVolumeModifiers(int userId, IReadOnlyCollection volumes) { + var volIds = volumes.Select(s => s.Id); var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId)) + .Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId)) .AsNoTracking() .ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index b0ed3c14e..4e20039c7 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -1,8 +1,10 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.DTOs.Reader; using API.Entities; using API.Interfaces.Repositories; using AutoMapper; @@ -12,6 +14,16 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories { + [Flags] + public enum AppUserIncludes + { + None = 1, + Progress = 2, + Bookmarks = 4, + ReadingLists = 8, + Ratings = 16 + } + public class UserRepository : IUserRepository { private readonly DataContext _context; @@ -35,24 +47,81 @@ namespace API.Data.Repositories _context.Entry(preferences).State = EntityState.Modified; } + public void Update(AppUserBookmark bookmark) + { + _context.Entry(bookmark).State = EntityState.Modified; + } + public void Delete(AppUser user) { _context.AppUser.Remove(user); } /// - /// Gets an AppUser by username. Returns back Progress information. + /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// /// + /// Includes() you want. Pass multiple with flag1 | flag2 /// - public async Task GetUserByUsernameAsync(string username) + public async Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None) { - return await _context.Users - .Include(u => u.Progresses) - .Include(u => u.Bookmarks) - .SingleOrDefaultAsync(x => x.UserName == username); + var query = _context.Users + .Where(x => x.UserName == username); + + query = AddIncludesToQuery(query, includeFlags); + + return await query.SingleOrDefaultAsync(); } + /// + /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. + /// + /// + /// Includes() you want. Pass multiple with flag1 | flag2 + /// + public async Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None) + { + var query = _context.Users + .Where(x => x.Id == userId); + + query = AddIncludesToQuery(query, includeFlags); + + return await query.SingleOrDefaultAsync(); + } + + public async Task GetBookmarkForPage(int page, int chapterId, int userId) + { + return await _context.AppUserBookmark + .Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId) + .SingleOrDefaultAsync(); + } + + private static IQueryable AddIncludesToQuery(IQueryable query, AppUserIncludes includeFlags) + { + if (includeFlags.HasFlag(AppUserIncludes.Bookmarks)) + { + query = query.Include(u => u.Bookmarks); + } + + if (includeFlags.HasFlag(AppUserIncludes.Progress)) + { + query = query.Include(u => u.Progresses); + } + + if (includeFlags.HasFlag(AppUserIncludes.ReadingLists)) + { + query = query.Include(u => u.ReadingLists); + } + + if (includeFlags.HasFlag(AppUserIncludes.Ratings)) + { + query = query.Include(u => u.Ratings); + } + + return query; + } + + /// /// This fetches the Id for a user. Use whenever you just need an ID. /// @@ -79,19 +148,6 @@ namespace API.Data.Repositories .SingleOrDefaultAsync(x => x.UserName == username); } - /// - /// Gets an AppUser by id. Returns back Progress information. - /// - /// - /// - public async Task GetUserByIdAsync(int id) - { - return await _context.Users - .Include(u => u.Progresses) - .Include(u => u.Bookmarks) - .SingleOrDefaultAsync(x => x.Id == id); - } - public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); @@ -103,11 +159,6 @@ namespace API.Data.Repositories .SingleOrDefaultAsync(); } - public void AddRatingTracking(AppUserRating userRating) - { - _context.AppUserRating.Add(userRating); - } - public async Task GetPreferencesAsync(string username) { return await _context.AppUserPreferences @@ -155,10 +206,17 @@ namespace API.Data.Repositories .ToListAsync(); } - public async Task GetUserByApiKeyAsync(string apiKey) + /// + /// Fetches the UserId by API Key. This does not include any extra information + /// + /// + /// + public async Task GetUserIdByApiKeyAsync(string apiKey) { return await _context.AppUser - .SingleOrDefaultAsync(u => u.ApiKey.Equals(apiKey)); + .Where(u => u.ApiKey.Equals(apiKey)) + .Select(u => u.Id) + .SingleOrDefaultAsync(); } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 0537a2966..bc6257af1 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.DTOs.Reader; using API.Entities; using API.Interfaces.Repositories; using AutoMapper; @@ -13,12 +14,10 @@ namespace API.Data.Repositories public class VolumeRepository : IVolumeRepository { private readonly DataContext _context; - private readonly IMapper _mapper; - public VolumeRepository(DataContext context, IMapper mapper) + public VolumeRepository(DataContext context) { _context = context; - _mapper = mapper; } public void Update(Volume volume) @@ -26,83 +25,6 @@ namespace API.Data.Repositories _context.Entry(volume).State = EntityState.Modified; } - /// - /// Returns a Chapter for an Id. Includes linked s. - /// - /// - /// - public async Task GetChapterAsync(int chapterId) - { - return await _context.Chapter - .Include(c => c.Files) - .SingleOrDefaultAsync(c => c.Id == chapterId); - } - - /// - /// Returns Chapters for a volume id. - /// - /// - /// - public async Task> GetChaptersAsync(int volumeId) - { - return await _context.Chapter - .Where(c => c.VolumeId == volumeId) - .ToListAsync(); - } - - /// - /// Returns the cover image for a chapter id. - /// - /// - /// - public async Task GetChapterCoverImageAsync(int chapterId) - { - return await _context.Chapter - .Where(c => c.Id == chapterId) - .Select(c => c.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } - - - - - public async Task GetChapterDtoAsync(int chapterId) - { - var chapter = await _context.Chapter - .Include(c => c.Files) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .SingleOrDefaultAsync(c => c.Id == chapterId); - - return chapter; - } - - /// - /// Returns non-tracked files for a given chapterId - /// - /// - /// - public async Task> GetFilesForChapterAsync(int chapterId) - { - return await _context.MangaFile - .Where(c => chapterId == c.ChapterId) - .AsNoTracking() - .ToListAsync(); - } - /// - /// Returns non-tracked files for a set of chapterIds - /// - /// - /// - public async Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds) - { - return await _context.MangaFile - .Where(c => chapterIds.Contains(c.ChapterId)) - .AsNoTracking() - .ToListAsync(); - } - public async Task> GetFilesForVolume(int volumeId) { return await _context.Chapter @@ -112,5 +34,14 @@ namespace API.Data.Repositories .AsNoTracking() .ToListAsync(); } + + public async Task GetVolumeCoverImageAsync(int volumeId) + { + return await _context.Volume + .Where(v => v.Id == volumeId) + .Select(v => v.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } } } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 5d931c790..017293be0 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -25,14 +25,14 @@ namespace API.Data public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper); public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); - public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); + public IVolumeRepository VolumeRepository => new VolumeRepository(_context); public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); public IFileRepository FileRepository => new FileRepository(_context); - public IChapterRepository ChapterRepository => new ChapterRepository(_context); + public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper); public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); /// diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index dab9f2e1b..f02c9f848 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; namespace API.Entities { @@ -21,4 +22,4 @@ namespace API.Entities public Series Series { get; set; } public int SeriesId { get; set; } } -} \ No newline at end of file +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 138a9d593..03445ccb2 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using API.DTOs; +using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.Entities; using API.Helpers.Converters; diff --git a/API/Interfaces/Repositories/IAppUserProgressRepository.cs b/API/Interfaces/Repositories/IAppUserProgressRepository.cs index 62449e895..d37198fb2 100644 --- a/API/Interfaces/Repositories/IAppUserProgressRepository.cs +++ b/API/Interfaces/Repositories/IAppUserProgressRepository.cs @@ -1,11 +1,14 @@ using System.Threading.Tasks; +using API.Entities; using API.Entities.Enums; namespace API.Interfaces.Repositories { public interface IAppUserProgressRepository { + void Update(AppUserProgress userProgress); Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); + Task GetUserProgressAsync(int chapterId, int userId); } } diff --git a/API/Interfaces/Repositories/IChapterRepository.cs b/API/Interfaces/Repositories/IChapterRepository.cs index 02ef94eed..3f9713020 100644 --- a/API/Interfaces/Repositories/IChapterRepository.cs +++ b/API/Interfaces/Repositories/IChapterRepository.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.DTOs; using API.DTOs.Reader; using API.Entities; @@ -10,5 +11,12 @@ namespace API.Interfaces.Repositories void Update(Chapter chapter); Task> GetChaptersByIdsAsync(IList chapterIds); Task GetChapterInfoDtoAsync(int chapterId); + Task GetChapterTotalPagesAsync(int chapterId); + Task GetChapterAsync(int chapterId); + Task GetChapterDtoAsync(int chapterId); + Task> GetFilesForChapterAsync(int chapterId); + Task> GetChaptersAsync(int volumeId); + Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); + Task GetChapterCoverImageAsync(int chapterId); } } diff --git a/API/Interfaces/Repositories/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs index 7388dee48..a66b8bbe5 100644 --- a/API/Interfaces/Repositories/ISeriesRepository.cs +++ b/API/Interfaces/Repositories/ISeriesRepository.cs @@ -56,7 +56,7 @@ namespace API.Interfaces.Repositories /// Task AddSeriesModifiers(int userId, List series); - Task GetVolumeCoverImageAsync(int volumeId); + Task GetSeriesCoverImageAsync(int seriesId); Task> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter); Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); diff --git a/API/Interfaces/Repositories/IUserRepository.cs b/API/Interfaces/Repositories/IUserRepository.cs index 01bbd4e3a..22bd9dc92 100644 --- a/API/Interfaces/Repositories/IUserRepository.cs +++ b/API/Interfaces/Repositories/IUserRepository.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Data.Repositories; using API.DTOs; +using API.DTOs.Reader; using API.Entities; namespace API.Interfaces.Repositories @@ -9,20 +11,21 @@ namespace API.Interfaces.Repositories { void Update(AppUser user); void Update(AppUserPreferences preferences); + void Update(AppUserBookmark bookmark); public void Delete(AppUser user); - Task GetUserByUsernameAsync(string username); - Task GetUserIdByUsernameAsync(string username); - Task GetUserWithReadingListsByUsernameAsync(string username); - Task GetUserByIdAsync(int id); Task> GetMembersAsync(); Task> GetAdminUsersAsync(); Task GetUserRating(int seriesId, int userId); - void AddRatingTracking(AppUserRating userRating); Task GetPreferencesAsync(string username); Task> GetBookmarkDtosForSeries(int userId, int seriesId); Task> GetBookmarkDtosForVolume(int userId, int volumeId); Task> GetBookmarkDtosForChapter(int userId, int chapterId); Task> GetAllBookmarkDtos(int userId); - Task GetUserByApiKeyAsync(string apiKey); + Task GetBookmarkForPage(int page, int chapterId, int userId); + Task GetUserIdByApiKeyAsync(string apiKey); + Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); + Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); + Task GetUserIdByUsernameAsync(string username); + Task GetUserWithReadingListsByUsernameAsync(string username); } } diff --git a/API/Interfaces/Repositories/IVolumeRepository.cs b/API/Interfaces/Repositories/IVolumeRepository.cs index e621b643c..98f93746a 100644 --- a/API/Interfaces/Repositories/IVolumeRepository.cs +++ b/API/Interfaces/Repositories/IVolumeRepository.cs @@ -8,12 +8,7 @@ namespace API.Interfaces.Repositories public interface IVolumeRepository { void Update(Volume volume); - Task GetChapterAsync(int chapterId); - Task GetChapterDtoAsync(int chapterId); - Task> GetFilesForChapterAsync(int chapterId); - Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); - Task> GetChaptersAsync(int volumeId); - Task GetChapterCoverImageAsync(int chapterId); Task> GetFilesForVolume(int volumeId); + Task GetVolumeCoverImageAsync(int volumeId); } } diff --git a/API/Interfaces/Services/IReaderService.cs b/API/Interfaces/Services/IReaderService.cs index 5bb9baeb1..e536a7e71 100644 --- a/API/Interfaces/Services/IReaderService.cs +++ b/API/Interfaces/Services/IReaderService.cs @@ -1,11 +1,13 @@ using System.Threading.Tasks; using API.DTOs; -using API.Entities; namespace API.Interfaces.Services { public interface IReaderService { - Task SaveReadingProgress(ProgressDto progressDto, AppUser user); + Task SaveReadingProgress(ProgressDto progressDto, int userId); + Task CapPageToChapter(int chapterId, int page); + Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); + Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); } } diff --git a/API/Interfaces/Services/ReaderService.cs b/API/Interfaces/Services/ReaderService.cs index f71a10a7a..e5be064c5 100644 --- a/API/Interfaces/Services/ReaderService.cs +++ b/API/Interfaces/Services/ReaderService.cs @@ -3,49 +3,50 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Comparators; +using API.Data.Repositories; using API.DTOs; using API.Entities; +using Microsoft.Extensions.Logging; namespace API.Interfaces.Services { public class ReaderService : IReaderService { private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer(); - public ReaderService(IUnitOfWork unitOfWork) + public ReaderService(IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; + _logger = logger; } /// /// Saves progress to DB /// /// - /// + /// /// - public async Task SaveReadingProgress(ProgressDto progressDto, AppUser user) + public async Task SaveReadingProgress(ProgressDto progressDto, int userId) { // Don't let user save past total pages. - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId); - if (progressDto.PageNum > chapter.Pages) - { - progressDto.PageNum = chapter.Pages; - } - - if (progressDto.PageNum < 0) - { - progressDto.PageNum = 0; - } + progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum); try { - user.Progresses ??= new List(); var userProgress = - user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id); + await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId); if (userProgress == null) { - user.Progresses.Add(new AppUserProgress + // Create a user object + var userWithProgress = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress); + userWithProgress.Progresses ??= new List(); + userWithProgress.Progresses.Add(new AppUserProgress { PagesRead = progressDto.PageNum, VolumeId = progressDto.VolumeId, @@ -54,6 +55,7 @@ namespace API.Interfaces.Services BookScrollId = progressDto.BookScrollId, LastModified = DateTime.Now }); + _unitOfWork.UserRepository.Update(userWithProgress); } else { @@ -62,21 +64,149 @@ namespace API.Interfaces.Services userProgress.VolumeId = progressDto.VolumeId; userProgress.BookScrollId = progressDto.BookScrollId; userProgress.LastModified = DateTime.Now; + _unitOfWork.AppUserProgressRepository.Update(userProgress); } - _unitOfWork.UserRepository.Update(user); - if (await _unitOfWork.CommitAsync()) { return true; } } - catch (Exception) + catch (Exception exception) { + // When opening a fresh chapter, this seems to fail (sometimes) + _logger.LogError(exception, "Could not save progress"); await _unitOfWork.RollbackAsync(); } return false; } + + public async Task CapPageToChapter(int chapterId, int page) + { + var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId); + if (page > totalPages) + { + page = totalPages; + } + + if (page < 0) + { + page = 0; + } + + return page; + } + + /// + /// Tries to find the next logical Chapter + /// + /// + /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02 + /// + /// + /// + /// + /// + /// -1 if nothing can be found + public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) + { + var volumes = (await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); + var currentVolume = volumes.Single(v => v.Id == volumeId); + var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); + + if (currentVolume.Number == 0) + { + // Handle specials by sorting on their Filename aka Range + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + + foreach (var volume in volumes) + { + if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1) + { + // Handle Chapters within current Volume + // In this case, i need 0 first because 0 represents a full volume file. + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + + if (volume.Number != currentVolume.Number + 1) continue; + + // Handle Chapters within next Volume + // ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+ + var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList(); + if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0")) + { + return chapters.Last().Id; + } + + var firstChapter = chapters.FirstOrDefault(); + if (firstChapter == null) return -1; + return firstChapter.Id; + + } + + return -1; + } + /// + /// Tries to find the prev logical Chapter + /// + /// + /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02 + /// + /// + /// + /// + /// + /// -1 if nothing can be found + public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) + { + var volumes = (await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList(); + var currentVolume = volumes.Single(v => v.Id == volumeId); + var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); + + if (currentVolume.Number == 0) + { + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, _naturalSortComparer).Reverse(), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + + foreach (var volume in volumes) + { + if (volume.Number == currentVolume.Number) + { + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + if (volume.Number == currentVolume.Number - 1) + { + var lastChapter = volume.Chapters + .OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault(); + if (lastChapter == null) return -1; + return lastChapter.Id; + } + } + return -1; + } + + private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) + { + var next = false; + var chaptersList = chapters.ToList(); + foreach (var chapter in chaptersList) + { + if (next) + { + return chapter.Id; + } + if (currentChapterNumber.Equals(chapter.Number)) next = true; + } + + return -1; + } + + } } diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 968abaa72..8decdeccd 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -67,7 +67,7 @@ namespace API.Services public async Task Ensure(int chapterId) { EnsureCacheDirectory(); - var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var extractPath = GetCachePath(chapterId); if (!Directory.Exists(extractPath)) @@ -192,7 +192,7 @@ namespace API.Services { // Calculate what chapter the page belongs to var pagesSoFar = 0; - var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapter.Id); + var chapterFiles = chapter.Files ?? await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); foreach (var mangaFile in chapterFiles) { if (page <= (mangaFile.Pages + pagesSoFar)) diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 408f8d920..2ae36e55d 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2602,6 +2602,11 @@ } } }, + "@polka/url": { + "version": "1.0.0-next.20", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.20.tgz", + "integrity": "sha512-88p7+M0QGxKpmnkfXjS4V26AnoC/eiqZutE8GLdaI5X12NY75bXSdTY9NkmYb2Xyk1O+MmkuO6Frmsj84V6I8Q==" + }, "@schematics/angular": { "version": "11.2.11", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-11.2.11.tgz", @@ -5784,6 +5789,11 @@ "is-obj": "^2.0.0" } }, + "duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -6979,6 +6989,14 @@ "dev": true, "optional": true }, + "gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "requires": { + "duplexer": "^0.1.2" + } + }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -11188,6 +11206,11 @@ "is-wsl": "^2.1.1" } }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==" + }, "opn": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz", @@ -14039,6 +14062,23 @@ } } }, + "sirv": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-1.0.17.tgz", + "integrity": "sha512-qx9go5yraB7ekT7bCMqUHJ5jEaOC/GXBxUWv+jeWnb7WzHUFdcQPGWk7YmAwFBaQBrogpuSqd/azbC2lZRqqmw==", + "requires": { + "@polka/url": "^1.0.0-next.20", + "mime": "^2.3.1", + "totalist": "^1.0.0" + }, + "dependencies": { + "mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==" + } + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -15088,6 +15128,11 @@ "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", "dev": true }, + "totalist": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", + "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==" + }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -16242,6 +16287,87 @@ } } }, + "webpack-bundle-analyzer": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.2.tgz", + "integrity": "sha512-PIagMYhlEzFfhMYOzs5gFT55DkUdkyrJi/SxJp8EF3YMWhS+T9vvs2EoTetpk5qb6VsCq02eXTlRDOydRhDFAQ==", + "requires": { + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "chalk": "^4.1.0", + "commander": "^6.2.0", + "gzip-size": "^6.0.0", + "lodash": "^4.17.20", + "opener": "^1.5.2", + "sirv": "^1.0.7", + "ws": "^7.3.1" + }, + "dependencies": { + "acorn": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", + "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==" + }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "ws": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", + "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==" + } + } + }, "webpack-dev-middleware": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index ba19d7e47..515c35df8 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -6,7 +6,7 @@ "start": "ng serve", "build": "ng build", "prod": "ng build --prod", - "explore": "ng build --stats-json && webpack-bundle-analyzer ../kavita/API/wwwroot/stats.json", + "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", @@ -46,6 +46,7 @@ "rxjs": "~6.6.0", "swiper": "^6.5.8", "tslib": "^2.0.0", + "webpack-bundle-analyzer": "^4.4.2", "zone.js": "~0.10.2" }, "devDependencies": { diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index 5d163cddf..be233122b 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -8,7 +8,6 @@ export interface Series { localizedName: string; sortName: string; summary: string; - coverImage: string; // This is not passed from backend any longer. TODO: Remove this field coverImageLocked: boolean; volumes: Volume[]; pages: number; // Total pages in series diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 479107e0b..6f6d02630 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -9,7 +9,7 @@
- +
diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 73b3717e3..5ffadf3a1 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -12,10 +12,10 @@ import { DirectoryPickerComponent } from './_modals/directory-picker/directory-p import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component'; import { ManageSettingsComponent } from './manage-settings/manage-settings.component'; -import { FilterPipe } from './filter.pipe'; import { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.component'; import { ManageSystemComponent } from './manage-system/manage-system.component'; import { ChangelogComponent } from './changelog/changelog.component'; +import { PipeModule } from '../pipe/pipe.module'; @@ -30,7 +30,6 @@ import { ChangelogComponent } from './changelog/changelog.component'; DirectoryPickerComponent, ResetPasswordModalComponent, ManageSettingsComponent, - FilterPipe, EditRbsModalComponent, ManageSystemComponent, ChangelogComponent, @@ -44,6 +43,7 @@ import { ChangelogComponent } from './changelog/changelog.component'; NgbTooltipModule, NgbDropdownModule, SharedModule, + PipeModule ], providers: [] }) diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index e06c77153..93f458d35 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -112,9 +112,9 @@
-
+
-
+
diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index c37993c2d..d40134a3d 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -288,14 +288,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); if (alreadyReached.length > 0) { this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); + + if (!this.incognitoMode) { + this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); + } return; } else { this.currentPageAnchor = ''; } } - if (this.lastSeenScrollPartPath !== '') { + if (this.lastSeenScrollPartPath !== '' && !this.incognitoMode) { this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); } }); @@ -443,7 +446,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } handleIntersection(entries: IntersectionObserverEntry[]) { - const intersectingEntries = Array.from(entries).filter(entry => entry.isIntersecting).map(entry => entry.target); + let intersectingEntries = Array.from(entries) + .filter(entry => entry.isIntersecting) + .map(entry => entry.target) intersectingEntries.sort((a: Element, b: Element) => { const aTop = a.getBoundingClientRect().top; const bTop = b.getBoundingClientRect().top; @@ -457,6 +462,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return 0; }); + if (intersectingEntries.length > 0) { let path = this.getXPathTo(intersectingEntries[0]); if (path === '') { return; } @@ -643,7 +649,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { setupPageAnchors() { this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => { - this.intersectionObserver.observe(elem); + if (!elem.classList.contains('no-observe')) { + this.intersectionObserver.observe(elem); + } }); this.pageAnchors = {}; diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index b968228b3..3bb42a9bd 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -117,7 +117,6 @@
- diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html index 92be7cb04..51285ac77 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html @@ -6,10 +6,38 @@ Prefetched {{minPageLoaded}}-{{maxPageLoaded}} Current Page:{{pageNum}} Width: {{webtoonImageWidth}} + Pages: {{pageNum}} / {{totalPages}} + At Top: {{atTop}} + At Bottom: {{atBottom}} +
- + image - + diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss index c7308aa33..4277b6760 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss @@ -4,4 +4,28 @@ } .active { border: 2px solid red; +} + +.spacer { + width: 100%; + height: 300px; + cursor: pointer; + + .animate { + animation: move-up-down 1s linear infinite; + } + + .text { + z-index: 101; + + } +} + +@keyframes move-up-down { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } } \ No newline at end of file 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 5e6973ecd..ad92be9fe 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 @@ -1,10 +1,16 @@ import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core'; +import { ToastrService } from 'ngx-toastr'; import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs'; -import { debounceTime, takeUntil } from 'rxjs/operators'; +import { debounceTime, take, takeUntil } from 'rxjs/operators'; import { ReaderService } from '../../_services/reader.service'; import { PAGING_DIRECTION } from '../_models/reader-enums'; import { WebtoonImage } from '../_models/webtoon-image'; +/** + * How much additional space should pass, past the original bottom of the document height before we trigger the next chapter load + */ +const SPACER_SCROLL_INTO_PX = 200; + @Component({ selector: 'app-infinite-scroller', templateUrl: './infinite-scroller.component.html', @@ -29,6 +35,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { */ @Input() urlProvider!: (page: number) => string; @Output() pageNumberChange: EventEmitter = new EventEmitter(); + @Output() loadNextChapter: EventEmitter = new EventEmitter(); + @Output() loadPrevChapter: EventEmitter = new EventEmitter(); @Input() goToPage: ReplaySubject = new ReplaySubject(); @@ -70,6 +78,18 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * Denotes each page that has been loaded or not. If pruning is implemented, the key will be deleted. */ imagesLoaded: {[key: number]: number} = {}; + /** + * If the user has scrolled all the way to the bottom. This is used solely for continuous reading + */ + atBottom: boolean = false; + /** + * If the user has scrolled all the way to the top. This is used solely for continuous reading + */ + atTop: boolean = false; + /** + * Keeps track of the previous scrolling height for restoring scroll position after we inject spacer block + */ + previousScrollHeightMinusTop: number = 0; /** * Debug mode. Will show extra information */ @@ -87,7 +107,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { private readonly onDestroy = new Subject(); - constructor(private readerService: ReaderService, private renderer: Renderer2) { } + constructor(private readerService: ReaderService, private renderer: Renderer2, private toastr: ToastrService) {} ngOnChanges(changes: SimpleChanges): void { if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) { @@ -104,7 +124,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { ngOnInit(): void { fromEvent(window, 'scroll') - .pipe(debounceTime(20), takeUntil(this.onDestroy)) + .pipe(debounceTime(20), takeUntil(this.onDestroy)) .subscribe((event) => this.handleScrollEvent(event)); if (this.goToPage) { @@ -145,6 +165,48 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { this.scrollingDirection = PAGING_DIRECTION.BACKWARDS; } this.prevScrollPosition = verticalOffset; + + // Check if we hit the last page + this.checkIfShouldTriggerContinuousReader(); + + } + + checkIfShouldTriggerContinuousReader() { + if (this.isScrolling) return; + + if (this.scrollingDirection === PAGING_DIRECTION.FORWARD) { + let totalHeight = 0; + document.querySelectorAll('img[id^="page-"]').forEach(img => totalHeight += img.getBoundingClientRect().height); + const totalScroll = document.documentElement.offsetHeight + document.documentElement.scrollTop; + + // If we were at top but have started scrolling down past page 0, remove top spacer + if (this.atTop && this.pageNum > 0) { + this.atTop = false; + } + if (totalScroll === totalHeight) { + this.atBottom = true; + this.setPageNum(this.totalPages); + // Scroll user back to original location + this.previousScrollHeightMinusTop = document.documentElement.scrollTop; + setTimeout(() => document.documentElement.scrollTop = this.previousScrollHeightMinusTop + (SPACER_SCROLL_INTO_PX / 2), 10); + } else if (totalScroll >= totalHeight + SPACER_SCROLL_INTO_PX && this.atBottom) { + // This if statement will fire once we scroll into the spacer at all + this.loadNextChapter.emit(); + } + } else { + if (document.documentElement.scrollTop === 0 && this.pageNum === 0) { + this.atBottom = false; + if (this.atTop) { + // If already at top, then we moving on + this.loadPrevChapter.emit(); + } + this.atTop = true; + // Scroll user back to original location + this.previousScrollHeightMinusTop = document.documentElement.scrollHeight - document.documentElement.scrollTop; + setTimeout(() => document.documentElement.scrollTop = document.documentElement.scrollHeight - this.previousScrollHeightMinusTop - (SPACER_SCROLL_INTO_PX / 2), 10); + } + } + } /** @@ -170,6 +232,9 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { initWebtoonReader() { this.imagesLoaded = {}; this.webtoonImages.next([]); + this.atBottom = false; + //this.atTop = document.documentElement.scrollTop === 0 && this.pageNum === 0; + this.checkIfShouldTriggerContinuousReader(); const [startingIndex, endingIndex] = this.calculatePrefetchIndecies(); @@ -236,6 +301,11 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { * @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page */ setPageNum(pageNum: number, scrollToPage: boolean = false) { + if (pageNum > this.totalPages) { + pageNum = this.totalPages; + } else if (pageNum < 0) { + pageNum = 0; + } this.pageNum = pageNum; this.pageNumberChange.emit(this.pageNum); 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 6b3122388..04d3b4a5d 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -28,7 +28,7 @@ ondragstart="return false;" onselectstart="return false;">
- +
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 fd9176d1b..93d7c9cf8 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -365,6 +365,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { const goToPageNum = this.promptForPage(); if (goToPageNum === null) { return; } this.goToPage(parseInt(goToPageNum.trim(), 10)); + } else if (event.key === KEY_CODES.B) { + this.bookmarkPage(); } } @@ -374,7 +376,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.nextChapterDisabled = false; this.prevChapterDisabled = false; this.nextChapterPrefetched = false; - this.pageNum = 0; // ?! Why was this 1 + this.pageNum = 0; forkJoin({ progress: this.readerService.getProgress(this.chapterId), @@ -391,11 +393,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.volumeId = results.chapterInfo.volumeId; this.maxPages = results.chapterInfo.pages; - console.log('results: ', results); let page = results.progress.pageNum; - console.log('page: ', page); - console.log('this.pageNum: ', this.pageNum); - if (page >= this.maxPages) { + if (page > this.maxPages) { page = this.maxPages - 1; } this.setPageNum(page); @@ -704,10 +703,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) { this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.nextChapterId = chapterId; - this.loadChapter(chapterId, 'next'); + this.loadChapter(chapterId, 'Next'); }); } else { - this.loadChapter(this.nextChapterId, 'next'); + this.loadChapter(this.nextChapterId, 'Next'); } } @@ -727,14 +726,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) { this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.prevChapterId = chapterId; - this.loadChapter(chapterId, 'prev'); + this.loadChapter(chapterId, 'Prev'); }); } else { - this.loadChapter(this.prevChapterId, 'prev'); + this.loadChapter(this.prevChapterId, 'Prev'); } } - loadChapter(chapterId: number, direction: 'next' | 'prev') { + loadChapter(chapterId: number, direction: 'Next' | 'Prev') { if (chapterId >= 0) { this.chapterId = chapterId; this.continuousChaptersStack.push(chapterId); @@ -742,11 +741,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); this.init(); + this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000}); } else { // This will only happen if no actual chapter can be found - this.toastr.warning('Could not find ' + direction + ' chapter'); + this.toastr.warning('Could not find ' + direction.toLowerCase() + ' chapter'); this.isLoading = false; - if (direction === 'prev') { + if (direction === 'Prev') { this.prevPageDisabled = true; } else { this.nextPageDisabled = true; @@ -1010,7 +1010,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { bookmarkPage() { const pageNum = this.pageNum; if (this.pageBookmarked) { - // Remove bookmark this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => { delete this.bookmarks[pageNum]; }); diff --git a/UI/Web/src/app/admin/filter.pipe.ts b/UI/Web/src/app/pipe/filter.pipe.ts similarity index 100% rename from UI/Web/src/app/admin/filter.pipe.ts rename to UI/Web/src/app/pipe/filter.pipe.ts diff --git a/UI/Web/src/app/pipe/pipe.module.ts b/UI/Web/src/app/pipe/pipe.module.ts new file mode 100644 index 000000000..4361180d5 --- /dev/null +++ b/UI/Web/src/app/pipe/pipe.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FilterPipe } from './filter.pipe'; + + + +@NgModule({ + declarations: [ + FilterPipe + ], + imports: [ + CommonModule, + ], + exports: [ + FilterPipe + ] +}) +export class PipeModule { } diff --git a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html index 8ef6a61a4..fbcf8083d 100644 --- a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html +++ b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.html @@ -5,11 +5,19 @@
+
+ diff --git a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts index 846c054c1..4c51f88af 100644 --- a/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts +++ b/UI/Web/src/app/reading-list/_modals/add-to-list-modal/add-to-list-modal.component.ts @@ -41,6 +41,7 @@ export class AddToListModalComponent implements OnInit, AfterViewInit { ngOnInit(): void { this.listForm.addControl('title', new FormControl(this.title, [])); + this.listForm.addControl('filterQuery', new FormControl('', [])); this.loading = true; this.readingListService.getReadingLists(false).subscribe(lists => { @@ -87,4 +88,8 @@ export class AddToListModalComponent implements OnInit, AfterViewInit { } + filterList = (listItem: ReadingList) => { + return listItem.title.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0; + } + } diff --git a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html index 623d0cf9f..46c8d6209 100644 --- a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html @@ -29,7 +29,9 @@
-

{{readingList.summary}}

+
+ +
@@ -44,14 +46,21 @@
-
{{formatTitle(item)}}
- {{utilityService.mangaFormat(item.seriesFormat)}}  +
{{formatTitle(item)}}  + + {{item.pagesRead}} / {{item.pagesTotal}} + UNREAD + READ + +
+ + {{utilityService.mangaFormat(item.seriesFormat)}}  + {{item.seriesName}}
-
diff --git a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts index 6e29c4e22..bd89855a1 100644 --- a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts @@ -10,7 +10,6 @@ import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; -import { ReaderService } from 'src/app/_services/reader.service'; import { ReadingListService } from 'src/app/_services/reading-list.service'; import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component'; @@ -28,6 +27,11 @@ export class ReadingListDetailComponent implements OnInit { isAdmin: boolean = false; isLoading: boolean = false; + // Downloading + hasDownloadingRole: boolean = false; + downloadInProgress: boolean = false; + + get MangaFormat(): typeof MangaFormat { return MangaFormat; } @@ -58,6 +62,7 @@ export class ReadingListDetailComponent implements OnInit { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); + this.hasDownloadingRole = this.accountService.hasDownloadRole(user); this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)).filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); } diff --git a/UI/Web/src/app/reading-list/reading-list.module.ts b/UI/Web/src/app/reading-list/reading-list.module.ts index 4f8182b58..8b22b67c5 100644 --- a/UI/Web/src/app/reading-list/reading-list.module.ts +++ b/UI/Web/src/app/reading-list/reading-list.module.ts @@ -9,6 +9,8 @@ import { ReactiveFormsModule } from '@angular/forms'; import { CardsModule } from '../cards/cards.module'; import { ReadingListsComponent } from './reading-lists/reading-lists.component'; import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.component'; +import { PipeModule } from '../pipe/pipe.module'; +import { SharedModule } from '../shared/shared.module'; @@ -25,7 +27,9 @@ import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal ReadingListRoutingModule, ReactiveFormsModule, DragDropModule, - CardsModule + CardsModule, + PipeModule, + SharedModule ], exports: [ AddToListModalComponent, diff --git a/UI/Web/src/app/reading-list/reading-list.router.module.ts b/UI/Web/src/app/reading-list/reading-list.router.module.ts index 0a30ff09e..3e664265f 100644 --- a/UI/Web/src/app/reading-list/reading-list.router.module.ts +++ b/UI/Web/src/app/reading-list/reading-list.router.module.ts @@ -5,13 +5,12 @@ import { ReadingListDetailComponent } from "./reading-list-detail/reading-list-d const routes: Routes = [ { - path: '', + path: '', runGuardsAndResolvers: 'always', - canActivate: [AuthGuard], // TODO: Add a guard if they have access to said :id + canActivate: [AuthGuard], children: [ {path: '', component: ReadingListDetailComponent, pathMatch: 'full'}, {path: ':id', component: ReadingListDetailComponent, pathMatch: 'full'}, - // {path: ':id', component: CollectionDetailComponent}, ] } ]; @@ -21,4 +20,4 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) -export class ReadingListRoutingModule { } \ No newline at end of file +export class ReadingListRoutingModule { } diff --git a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts index 7b90d0cfb..d7051df2e 100644 --- a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts @@ -6,6 +6,7 @@ import { PaginatedResult, Pagination } from 'src/app/_models/pagination'; import { ReadingList } from 'src/app/_models/reading-list'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; +import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; import { ReadingListService } from 'src/app/_services/reading-list.service'; @@ -23,7 +24,7 @@ export class ReadingListsComponent implements OnInit { isAdmin: boolean = false; constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, - private accountService: AccountService, private toastr: ToastrService, private router: Router) { } + private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService) { } ngOnInit(): void { this.loadPage(); @@ -53,6 +54,13 @@ export class ReadingListsComponent implements OnInit { this.toastr.success('Reading list deleted'); this.loadPage(); }); + break; + case Action.Edit: + this.actionService.editReadingList(readingList, (updatedList: ReadingList) => { + // Reload information around list + readingList = updatedList; + }); + break; } } diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 0919eb0e7..882d789c8 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -73,7 +73,7 @@ export class DownloadService { })); } - async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series') { + async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series' | 'reading list') { return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')); } @@ -85,6 +85,8 @@ export class DownloadService { })); } + + /** * Format bytes as human-readable text. * diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 3c2ed50d5..a5ee94a56 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -13,6 +13,7 @@ export enum KEY_CODES { SPACE = ' ', ENTER = 'Enter', G = 'g', + B = 'b', BACKSPACE = 'Backspace', DELETE = 'Delete' }