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
This commit is contained in:
Joseph Milazzo 2021-09-11 11:47:12 -07:00 committed by GitHub
parent 38c313adc7
commit 83f8e25478
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
64 changed files with 937 additions and 446 deletions

View File

@ -12,7 +12,8 @@ namespace API.Benchmark
{ {
static void Main(string[] args) static void Main(string[] args)
{ {
BenchmarkRunner.Run<ParseScannedFilesBenchmarks>(); //BenchmarkRunner.Run<ParseScannedFilesBenchmarks>();
BenchmarkRunner.Run<TestBenchmark>();
} }
} }
} }

View File

@ -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
{
/// <summary>
/// This is used as a scratchpad for testing
/// </summary>
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn]
public class TestBenchmark
{
private readonly NaturalSortComparer _naturalSortComparer = new ();
private static IEnumerable<VolumeDto> GenerateVolumes(int max)
{
var random = new Random();
var maxIterations = random.Next(max) + 1;
var list = new List<VolumeDto>();
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<ChapterDto> GenerateChapters()
{
var list = new List<ChapterDto>();
for (var i = 1; i < 40; i++)
{
list.Add(new ChapterDto()
{
Range = i + string.Empty
});
}
return list;
}
private void SortSpecialChapters(IEnumerable<VolumeDto> 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);
}
}
}

View File

@ -2,8 +2,17 @@
namespace API.Comparators namespace API.Comparators
{ {
/// <summary>
/// Sorts chapters based on their Number. Uses natural ordering of doubles.
/// </summary>
public class ChapterSortComparer : IComparer<double> public class ChapterSortComparer : IComparer<double>
{ {
/// <summary>
/// Normal sort for 2 doubles. 0 always comes before anything else
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public int Compare(double x, double y) public int Compare(double x, double y)
{ {
if (x == 0.0 && y == 0.0) return 0; if (x == 0.0 && y == 0.0) return 0;

View File

@ -23,9 +23,9 @@ namespace API.Comparators
{ {
if (x == y) return 0; 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)) if (!_table.TryGetValue(x ?? Empty, out var x1))
{ {
// .Replace(" ", Empty)
x1 = Regex.Split(x ?? Empty, "([0-9]+)"); x1 = Regex.Split(x ?? Empty, "([0-9]+)");
_table.Add(x ?? Empty, x1); _table.Add(x ?? Empty, x1);
} }

View File

@ -5,6 +5,7 @@ using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.DTOs; using API.DTOs;
using API.DTOs.Account;
using API.Entities; using API.Entities;
using API.Errors; using API.Errors;
using API.Extensions; using API.Extensions;

View File

@ -39,7 +39,7 @@ namespace API.Controllers
var bookTitle = string.Empty; var bookTitle = string.Empty;
if (dto.SeriesFormat == MangaFormat.Epub) 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); using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath);
bookTitle = book.Title; bookTitle = book.Title;
} }
@ -62,7 +62,7 @@ namespace API.Controllers
[HttpGet("{chapterId}/book-resources")] [HttpGet("{chapterId}/book-resources")]
public async Task<ActionResult> GetBookPageResources(int chapterId, [FromQuery] string file) public async Task<ActionResult> 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 book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
var key = BookService.CleanContentKeys(file); 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 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 // 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); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
var mappings = await _bookService.CreateKeyToPageMappingAsync(book); var mappings = await _bookService.CreateKeyToPageMappingAsync(book);

View File

@ -48,7 +48,7 @@ namespace API.Controllers
[HttpGet("chapter-size")] [HttpGet("chapter-size")]
public async Task<ActionResult<long>> GetChapterSize(int chapterId) public async Task<ActionResult<long>> 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))); return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
} }
@ -90,8 +90,8 @@ namespace API.Controllers
[HttpGet("chapter")] [HttpGet("chapter")]
public async Task<ActionResult> DownloadChapter(int chapterId) public async Task<ActionResult> DownloadChapter(int chapterId)
{ {
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId); var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try try
@ -154,7 +154,7 @@ namespace API.Controllers
var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}"); var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}");
var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId) var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId)
.Select(b => b.Page).ToList(); .Select(b => b.Page).ToList();
var mangaFiles = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId); var mangaFiles = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
switch (series.Format) switch (series.Format)
{ {
case MangaFormat.Image: case MangaFormat.Image:

View File

@ -27,7 +27,7 @@ namespace API.Controllers
[HttpGet("chapter-cover")] [HttpGet("chapter-cover")]
public async Task<ActionResult> GetChapterCoverImage(int chapterId) public async Task<ActionResult> 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"); if (content == null) return BadRequest("No cover image");
Response.AddCacheHeader(content); Response.AddCacheHeader(content);
@ -42,7 +42,7 @@ namespace API.Controllers
[HttpGet("volume-cover")] [HttpGet("volume-cover")]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId) public async Task<ActionResult> 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"); if (content == null) return BadRequest("No cover image");
Response.AddCacheHeader(content); Response.AddCacheHeader(content);

View File

@ -141,8 +141,8 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id); var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey); var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey);
@ -168,7 +168,8 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); 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); var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
IEnumerable <CollectionTagDto> tags; IEnumerable <CollectionTagDto> tags;
@ -210,7 +211,8 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); 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); var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
IEnumerable <CollectionTagDto> tags; IEnumerable <CollectionTagDto> tags;
@ -229,7 +231,7 @@ namespace API.Controllers
return BadRequest("Collection does not exist or you don't have access"); 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, PageNumber = pageNumber,
PageSize = 20 PageSize = 20
@ -253,9 +255,9 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); 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 PageNumber = pageNumber
}); });
@ -286,7 +288,8 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); 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 userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName);
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); 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 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) foreach (var item in items)
{ {
feed.Entries.Add(new FeedEntry() feed.Entries.Add(new FeedEntry()
@ -323,16 +326,16 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var library = var library =
(await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).SingleOrDefault(l => (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l =>
l.Id == libraryId); l.Id == libraryId);
if (library == null) if (library == null)
{ {
return BadRequest("User does not have access to this library"); 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, PageNumber = pageNumber,
PageSize = 20 PageSize = 20
@ -355,8 +358,8 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, user.Id, new UserParams() var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, new UserParams()
{ {
PageNumber = pageNumber, PageNumber = pageNumber,
PageSize = 20 PageSize = 20
@ -380,13 +383,13 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var userParams = new UserParams() var userParams = new UserParams()
{ {
PageNumber = pageNumber, PageNumber = pageNumber,
PageSize = 20 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) var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize).ToList(); .Take(userParams.PageSize).ToList();
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
@ -410,14 +413,14 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
if (string.IsNullOrEmpty(query)) if (string.IsNullOrEmpty(query))
{ {
return BadRequest("You must pass a query parameter"); return BadRequest("You must pass a query parameter");
} }
query = query.Replace(@"%", ""); query = query.Replace(@"%", "");
// Get libraries user has access to // 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"); 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) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, user.Id); var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId);
var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); 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}")); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
foreach (var volumeDto in volumes) foreach (var volumeDto in volumes)
@ -481,11 +484,11 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
var chapters = 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); _chapterSortComparer);
var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); 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) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); return BadRequest("OPDS is not enabled on this server");
var user = await GetUser(apiKey); var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId);
var chapter = await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(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); var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
foreach (var mangaFile in files) foreach (var mangaFile in files)
@ -540,7 +543,7 @@ namespace API.Controllers
{ {
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server"); 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); var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
return File(bytes, contentType, fileDownloadName); return File(bytes, contentType, fileDownloadName);
} }
@ -628,7 +631,7 @@ namespace API.Controllers
return new FeedEntry() return new FeedEntry()
{ {
Id = volumeDto.Id.ToString(), Id = volumeDto.Id.ToString(),
Title = volumeDto.IsSpecial ? "Specials" : "Volume " + volumeDto.Name, Title = "Volume " + volumeDto.Name,
Links = new List<FeedLink>() Links = new List<FeedLink>()
{ {
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"), CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
@ -723,16 +726,19 @@ namespace API.Controllers
/// Gets the user from the API key /// Gets the user from the API key
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
private async Task<AppUser> GetUser(string apiKey) private async Task<int> GetUser(string apiKey)
{ {
var user = await _unitOfWork.UserRepository.GetUserByApiKeyAsync(apiKey); try
if (user == null)
{ {
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) private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
{ {

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
@ -25,9 +26,6 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderController> _logger; private readonly ILogger<ReaderController> _logger;
private readonly IReaderService _readerService; private readonly IReaderService _readerService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
/// <inheritdoc /> /// <inheritdoc />
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, public ReaderController(IDirectoryService directoryService, ICacheService cacheService,
@ -85,7 +83,7 @@ namespace API.Controllers
if (chapter == null) return BadRequest("Could not find Chapter"); if (chapter == null) return BadRequest("Could not find Chapter");
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); 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() return Ok(new ChapterInfoDto()
{ {
@ -106,7 +104,7 @@ namespace API.Controllers
[HttpPost("mark-read")] [HttpPost("mark-read")]
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto) public async Task<ActionResult> 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); var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId);
user.Progresses ??= new List<AppUserProgress>(); user.Progresses ??= new List<AppUserProgress>();
foreach (var volume in volumes) foreach (var volume in volumes)
@ -178,7 +176,7 @@ namespace API.Controllers
[HttpPost("mark-unread")] [HttpPost("mark-unread")]
public async Task<ActionResult> MarkUnread(MarkReadDto markReadDto) public async Task<ActionResult> 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); var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId);
user.Progresses ??= new List<AppUserProgress>(); user.Progresses ??= new List<AppUserProgress>();
foreach (var volume in volumes) foreach (var volume in volumes)
@ -213,9 +211,9 @@ namespace API.Controllers
[HttpPost("mark-volume-unread")] [HttpPost("mark-volume-unread")]
public async Task<ActionResult> MarkVolumeAsUnread(MarkVolumeReadDto markVolumeReadDto) public async Task<ActionResult> 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) foreach (var chapter in chapters)
{ {
user.Progresses ??= new List<AppUserProgress>(); user.Progresses ??= new List<AppUserProgress>();
@ -257,9 +255,9 @@ namespace API.Controllers
[HttpPost("mark-volume-read")] [HttpPost("mark-volume-read")]
public async Task<ActionResult> MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto) public async Task<ActionResult> 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) foreach (var chapter in chapters)
{ {
user.Progresses ??= new List<AppUserProgress>(); user.Progresses ??= new List<AppUserProgress>();
@ -301,7 +299,7 @@ namespace API.Controllers
[HttpGet("get-progress")] [HttpGet("get-progress")]
public async Task<ActionResult<ProgressDto>> GetProgress(int chapterId) public async Task<ActionResult<ProgressDto>> 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() var progressBookmark = new ProgressDto()
{ {
PageNum = 0, PageNum = 0,
@ -331,7 +329,8 @@ namespace API.Controllers
public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto) public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); 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"); return BadRequest("Could not save progress");
} }
@ -344,7 +343,7 @@ namespace API.Controllers
[HttpGet("get-bookmarks")] [HttpGet("get-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarks(int chapterId) public async Task<ActionResult<IEnumerable<BookmarkDto>>> 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<BookmarkDto>()); if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId)); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
} }
@ -356,7 +355,7 @@ namespace API.Controllers
[HttpGet("get-all-bookmarks")] [HttpGet("get-all-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks() public async Task<ActionResult<IEnumerable<BookmarkDto>>> 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<BookmarkDto>()); if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id)); return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id));
} }
@ -369,7 +368,7 @@ namespace API.Controllers
[HttpPost("remove-bookmarks")] [HttpPost("remove-bookmarks")]
public async Task<ActionResult> RemoveBookmarks(RemoveBookmarkForSeriesDto dto) public async Task<ActionResult> 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"); if (user.Bookmarks == null) return Ok("Nothing to remove");
try try
{ {
@ -399,7 +398,7 @@ namespace API.Controllers
[HttpGet("get-volume-bookmarks")] [HttpGet("get-volume-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForVolume(int volumeId) public async Task<ActionResult<IEnumerable<BookmarkDto>>> 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<BookmarkDto>()); if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId)); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
} }
@ -412,7 +411,7 @@ namespace API.Controllers
[HttpGet("get-series-bookmarks")] [HttpGet("get-series-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForSeries(int seriesId) public async Task<ActionResult<IEnumerable<BookmarkDto>>> 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<BookmarkDto>()); if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId)); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
@ -426,29 +425,18 @@ namespace API.Controllers
[HttpPost("bookmark")] [HttpPost("bookmark")]
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto) public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Don't let user save past total pages. // Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId); bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page);
if (bookmarkDto.Page > chapter.Pages)
{
bookmarkDto.Page = chapter.Pages;
}
if (bookmarkDto.Page < 0)
{
bookmarkDto.Page = 0;
}
try try
{ {
user.Bookmarks ??= new List<AppUserBookmark>(); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
var userBookmark = var userBookmark =
user.Bookmarks.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id && x.Page == bookmarkDto.Page); await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id);
if (userBookmark == null) if (userBookmark == null)
{ {
user.Bookmarks ??= new List<AppUserBookmark>();
user.Bookmarks.Add(new AppUserBookmark() user.Bookmarks.Add(new AppUserBookmark()
{ {
Page = bookmarkDto.Page, Page = bookmarkDto.Page,
@ -456,15 +444,9 @@ namespace API.Controllers
SeriesId = bookmarkDto.SeriesId, SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId, ChapterId = bookmarkDto.ChapterId,
}); });
} _unitOfWork.UserRepository.Update(user);
else
{
userBookmark.Page = bookmarkDto.Page;
userBookmark.SeriesId = bookmarkDto.SeriesId;
userBookmark.VolumeId = bookmarkDto.VolumeId;
} }
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) if (await _unitOfWork.CommitAsync())
{ {
@ -487,7 +469,7 @@ namespace API.Controllers
[HttpPost("unbookmark")] [HttpPost("unbookmark")]
public async Task<ActionResult> UnBookmarkPage(BookmarkDto bookmarkDto) public async Task<ActionResult> 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(); if (user.Bookmarks == null) return Ok();
try { try {
@ -496,7 +478,6 @@ namespace API.Controllers
&& x.AppUserId == user.Id && x.AppUserId == user.Id
&& x.Page != bookmarkDto.Page).ToList(); && x.Page != bookmarkDto.Page).ToList();
_unitOfWork.UserRepository.Update(user); _unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) if (await _unitOfWork.CommitAsync())
@ -526,57 +507,9 @@ namespace API.Controllers
public async Task<ActionResult<int>> GetNextChapter(int seriesId, int volumeId, int currentChapterId) public async Task<ActionResult<int>> GetNextChapter(int seriesId, int volumeId, int currentChapterId)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, 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);
}
private static int GetNextChapterId(IEnumerable<Chapter> 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;
}
/// <summary> /// <summary>
/// Returns the previous logical chapter from the series. /// Returns the previous logical chapter from the series.
@ -592,29 +525,7 @@ namespace API.Controllers
public async Task<ActionResult<int>> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) public async Task<ActionResult<int>> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId)
{ {
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, 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);
} }
} }

View File

@ -265,7 +265,7 @@ namespace API.Controllers
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
if (readingList == null) return BadRequest("Reading List does not exist"); if (readingList == null) return BadRequest("Reading List does not exist");
var chapterIdsForVolume = 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 there are adds, tell tracking this has been modified
if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) if (await AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList))

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering; using API.DTOs.Filtering;
using API.Entities; using API.Entities;
@ -109,16 +110,14 @@ namespace API.Controllers
[HttpGet("chapter")] [HttpGet("chapter")]
public async Task<ActionResult<VolumeDto>> GetChapter(int chapterId) public async Task<ActionResult<VolumeDto>> GetChapter(int chapterId)
{ {
return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId)); return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId));
} }
[HttpPost("update-rating")] [HttpPost("update-rating")]
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) public async Task<ActionResult> 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) ?? var userRating = await _unitOfWork.UserRepository.GetUserRating(updateSeriesRatingDto.SeriesId, user.Id) ??
new AppUserRating(); new AppUserRating();

View File

@ -142,7 +142,7 @@ namespace API.Controllers
if (bytes.Length > 0) if (bytes.Length > 0)
{ {
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
chapter.CoverImage = bytes; chapter.CoverImage = bytes;
chapter.CoverImageLocked = true; chapter.CoverImageLocked = true;
_unitOfWork.ChapterRepository.Update(chapter); _unitOfWork.ChapterRepository.Update(chapter);
@ -178,7 +178,7 @@ namespace API.Controllers
{ {
try try
{ {
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
chapter.CoverImage = Array.Empty<byte>(); chapter.CoverImage = Array.Empty<byte>();
chapter.CoverImageLocked = false; chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter); _unitOfWork.ChapterRepository.Update(chapter);

View File

@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace API.DTOs namespace API.DTOs.Account
{ {
public class ResetPasswordDto public class ResetPasswordDto
{ {

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using API.DTOs.Reader;
namespace API.DTOs.Downloads namespace API.DTOs.Downloads
{ {

View File

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

View File

@ -1,24 +0,0 @@
namespace API.DTOs
{
public class InProgressChapterDto
{
public int Id { get; init; }
/// <summary>
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
/// </summary>
public string Range { get; init; }
/// <summary>
/// Smallest number of the Range.
/// </summary>
public string Number { get; init; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>
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; }
}
}

View File

@ -1,4 +1,4 @@
namespace API.DTOs namespace API.DTOs.Reader
{ {
public class BookmarkDto public class BookmarkDto
{ {

View File

@ -1,4 +1,4 @@
namespace API.DTOs namespace API.DTOs.Reader
{ {
public class MarkReadDto public class MarkReadDto
{ {

View File

@ -1,4 +1,4 @@
namespace API.DTOs namespace API.DTOs.Reader
{ {
public class MarkVolumeReadDto public class MarkVolumeReadDto
{ {

View File

@ -1,4 +1,4 @@
namespace API.DTOs namespace API.DTOs.Reader
{ {
public class RemoveBookmarkForSeriesDto public class RemoveBookmarkForSeriesDto
{ {

View File

@ -13,7 +13,6 @@ namespace API.DTOs
public int PagesRead { get; set; } public int PagesRead { get; set; }
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
public DateTime Created { get; set; } public DateTime Created { get; set; }
public bool IsSpecial { get; set; }
public int SeriesId { get; set; } public int SeriesId { get; set; }
public ICollection<ChapterDto> Chapters { get; set; } public ICollection<ChapterDto> Chapters { get; set; }
} }

View File

@ -1,5 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Interfaces.Repositories; using API.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -15,6 +16,11 @@ namespace API.Data.Repositories
_context = context; _context = context;
} }
public void Update(AppUserProgress userProgress)
{
_context.Entry(userProgress).State = EntityState.Modified;
}
/// <summary> /// <summary>
/// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well.
/// </summary> /// </summary>
@ -62,5 +68,12 @@ namespace API.Data.Repositories
.AsNoTracking() .AsNoTracking()
.AnyAsync(); .AnyAsync();
} }
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
{
return await _context.AppUserProgresses
.Where(p => p.ChapterId == chapterId && p.AppUserId == userId)
.SingleOrDefaultAsync();
}
} }
} }

View File

@ -1,9 +1,12 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Interfaces.Repositories; using API.Interfaces.Repositories;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories namespace API.Data.Repositories
@ -11,10 +14,12 @@ namespace API.Data.Repositories
public class ChapterRepository : IChapterRepository public class ChapterRepository : IChapterRepository
{ {
private readonly DataContext _context; private readonly DataContext _context;
private readonly IMapper _mapper;
public ChapterRepository(DataContext context) public ChapterRepository(DataContext context, IMapper mapper)
{ {
_context = context; _context = context;
_mapper = mapper;
} }
public void Update(Chapter chapter) public void Update(Chapter chapter)
@ -30,8 +35,6 @@ namespace API.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
// TODO: Move over Chapter based queries here
/// <summary> /// <summary>
/// Populates a partial IChapterInfoDto /// Populates a partial IChapterInfoDto
/// </summary> /// </summary>
@ -76,5 +79,87 @@ namespace API.Data.Repositories
.AsNoTracking() .AsNoTracking()
.SingleAsync(); .SingleAsync();
} }
public Task<int> GetChapterTotalPagesAsync(int chapterId)
{
return _context.Chapter
.Where(c => c.Id == chapterId)
.Select(c => c.Pages)
.SingleOrDefaultAsync();
}
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
{
var chapter = await _context.Chapter
.Include(c => c.Files)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == chapterId);
return chapter;
}
/// <summary>
/// Returns non-tracked files for a given chapterId
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
{
return await _context.MangaFile
.Where(c => chapterId == c.ChapterId)
.AsNoTracking()
.ToListAsync();
}
/// <summary>
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<Chapter> GetChapterAsync(int chapterId)
{
return await _context.Chapter
.Include(c => c.Files)
.SingleOrDefaultAsync(c => c.Id == chapterId);
}
/// <summary>
/// Returns Chapters for a volume id.
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
{
return await _context.Chapter
.Where(c => c.VolumeId == volumeId)
.ToListAsync();
}
/// <summary>
/// Returns the cover image for a chapter id.
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<byte[]> GetChapterCoverImageAsync(int chapterId)
{
return await _context.Chapter
.Where(c => c.Id == chapterId)
.Select(c => c.CoverImage)
.AsNoTracking()
.SingleOrDefaultAsync();
}
/// <summary>
/// Returns non-tracked files for a set of chapterIds
/// </summary>
/// <param name="chapterIds"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
{
return await _context.MangaFile
.Where(c => chapterIds.Contains(c.ChapterId))
.AsNoTracking()
.ToListAsync();
}
} }
} }

View File

@ -115,8 +115,6 @@ namespace API.Data.Repositories
await AddVolumeModifiers(userId, volumes); await AddVolumeModifiers(userId, volumes);
SortSpecialChapters(volumes); SortSpecialChapters(volumes);
return volumes; return volumes;
} }
@ -258,15 +256,6 @@ namespace API.Data.Repositories
} }
} }
public async Task<byte[]> GetVolumeCoverImageAsync(int volumeId)
{
return await _context.Volume
.Where(v => v.Id == volumeId)
.Select(v => v.CoverImage)
.AsNoTracking()
.SingleOrDefaultAsync();
}
public async Task<byte[]> GetSeriesCoverImageAsync(int seriesId) public async Task<byte[]> GetSeriesCoverImageAsync(int seriesId)
{ {
return await _context.Series return await _context.Series
@ -278,8 +267,9 @@ namespace API.Data.Repositories
private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes) private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
{ {
var volIds = volumes.Select(s => s.Id);
var userProgress = await _context.AppUserProgresses 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() .AsNoTracking()
.ToListAsync(); .ToListAsync();

View File

@ -1,8 +1,10 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.DTOs; using API.DTOs;
using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Interfaces.Repositories; using API.Interfaces.Repositories;
using AutoMapper; using AutoMapper;
@ -12,6 +14,16 @@ using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories namespace API.Data.Repositories
{ {
[Flags]
public enum AppUserIncludes
{
None = 1,
Progress = 2,
Bookmarks = 4,
ReadingLists = 8,
Ratings = 16
}
public class UserRepository : IUserRepository public class UserRepository : IUserRepository
{ {
private readonly DataContext _context; private readonly DataContext _context;
@ -35,24 +47,81 @@ namespace API.Data.Repositories
_context.Entry(preferences).State = EntityState.Modified; _context.Entry(preferences).State = EntityState.Modified;
} }
public void Update(AppUserBookmark bookmark)
{
_context.Entry(bookmark).State = EntityState.Modified;
}
public void Delete(AppUser user) public void Delete(AppUser user)
{ {
_context.AppUser.Remove(user); _context.AppUser.Remove(user);
} }
/// <summary> /// <summary>
/// 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.
/// </summary> /// </summary>
/// <param name="username"></param> /// <param name="username"></param>
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
/// <returns></returns> /// <returns></returns>
public async Task<AppUser> GetUserByUsernameAsync(string username) public async Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None)
{ {
return await _context.Users var query = _context.Users
.Include(u => u.Progresses) .Where(x => x.UserName == username);
.Include(u => u.Bookmarks)
.SingleOrDefaultAsync(x => x.UserName == username); query = AddIncludesToQuery(query, includeFlags);
return await query.SingleOrDefaultAsync();
} }
/// <summary>
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
/// </summary>
/// <param name="userId"></param>
/// <param name="includeFlags">Includes() you want. Pass multiple with flag1 | flag2 </param>
/// <returns></returns>
public async Task<AppUser> 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<AppUserBookmark> 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<AppUser> AddIncludesToQuery(IQueryable<AppUser> 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;
}
/// <summary> /// <summary>
/// This fetches the Id for a user. Use whenever you just need an ID. /// This fetches the Id for a user. Use whenever you just need an ID.
/// </summary> /// </summary>
@ -79,19 +148,6 @@ namespace API.Data.Repositories
.SingleOrDefaultAsync(x => x.UserName == username); .SingleOrDefaultAsync(x => x.UserName == username);
} }
/// <summary>
/// Gets an AppUser by id. Returns back Progress information.
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
public async Task<AppUser> GetUserByIdAsync(int id)
{
return await _context.Users
.Include(u => u.Progresses)
.Include(u => u.Bookmarks)
.SingleOrDefaultAsync(x => x.Id == id);
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync() public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{ {
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
@ -103,11 +159,6 @@ namespace API.Data.Repositories
.SingleOrDefaultAsync(); .SingleOrDefaultAsync();
} }
public void AddRatingTracking(AppUserRating userRating)
{
_context.AppUserRating.Add(userRating);
}
public async Task<AppUserPreferences> GetPreferencesAsync(string username) public async Task<AppUserPreferences> GetPreferencesAsync(string username)
{ {
return await _context.AppUserPreferences return await _context.AppUserPreferences
@ -155,10 +206,17 @@ namespace API.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
public async Task<AppUser> GetUserByApiKeyAsync(string apiKey) /// <summary>
/// Fetches the UserId by API Key. This does not include any extra information
/// </summary>
/// <param name="apiKey"></param>
/// <returns></returns>
public async Task<int> GetUserIdByApiKeyAsync(string apiKey)
{ {
return await _context.AppUser return await _context.AppUser
.SingleOrDefaultAsync(u => u.ApiKey.Equals(apiKey)); .Where(u => u.ApiKey.Equals(apiKey))
.Select(u => u.Id)
.SingleOrDefaultAsync();
} }

View File

@ -2,6 +2,7 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.DTOs.Reader;
using API.Entities; using API.Entities;
using API.Interfaces.Repositories; using API.Interfaces.Repositories;
using AutoMapper; using AutoMapper;
@ -13,12 +14,10 @@ namespace API.Data.Repositories
public class VolumeRepository : IVolumeRepository public class VolumeRepository : IVolumeRepository
{ {
private readonly DataContext _context; private readonly DataContext _context;
private readonly IMapper _mapper;
public VolumeRepository(DataContext context, IMapper mapper) public VolumeRepository(DataContext context)
{ {
_context = context; _context = context;
_mapper = mapper;
} }
public void Update(Volume volume) public void Update(Volume volume)
@ -26,83 +25,6 @@ namespace API.Data.Repositories
_context.Entry(volume).State = EntityState.Modified; _context.Entry(volume).State = EntityState.Modified;
} }
/// <summary>
/// Returns a Chapter for an Id. Includes linked <see cref="MangaFile"/>s.
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<Chapter> GetChapterAsync(int chapterId)
{
return await _context.Chapter
.Include(c => c.Files)
.SingleOrDefaultAsync(c => c.Id == chapterId);
}
/// <summary>
/// Returns Chapters for a volume id.
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
public async Task<IList<Chapter>> GetChaptersAsync(int volumeId)
{
return await _context.Chapter
.Where(c => c.VolumeId == volumeId)
.ToListAsync();
}
/// <summary>
/// Returns the cover image for a chapter id.
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<byte[]> GetChapterCoverImageAsync(int chapterId)
{
return await _context.Chapter
.Where(c => c.Id == chapterId)
.Select(c => c.CoverImage)
.AsNoTracking()
.SingleOrDefaultAsync();
}
public async Task<ChapterDto> GetChapterDtoAsync(int chapterId)
{
var chapter = await _context.Chapter
.Include(c => c.Files)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Id == chapterId);
return chapter;
}
/// <summary>
/// Returns non-tracked files for a given chapterId
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
{
return await _context.MangaFile
.Where(c => chapterId == c.ChapterId)
.AsNoTracking()
.ToListAsync();
}
/// <summary>
/// Returns non-tracked files for a set of chapterIds
/// </summary>
/// <param name="chapterIds"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
{
return await _context.MangaFile
.Where(c => chapterIds.Contains(c.ChapterId))
.AsNoTracking()
.ToListAsync();
}
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId) public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
{ {
return await _context.Chapter return await _context.Chapter
@ -112,5 +34,14 @@ namespace API.Data.Repositories
.AsNoTracking() .AsNoTracking()
.ToListAsync(); .ToListAsync();
} }
public async Task<byte[]> GetVolumeCoverImageAsync(int volumeId)
{
return await _context.Volume
.Where(v => v.Id == volumeId)
.Select(v => v.CoverImage)
.AsNoTracking()
.SingleOrDefaultAsync();
}
} }
} }

View File

@ -25,14 +25,14 @@ namespace API.Data
public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper); public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _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 ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper);
public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context);
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
public IFileRepository FileRepository => new FileRepository(_context); 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); public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper);
/// <summary> /// <summary>

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using API.Entities.Interfaces; using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace API.Entities namespace API.Entities
{ {

View File

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using API.DTOs; using API.DTOs;
using API.DTOs.Reader;
using API.DTOs.ReadingLists; using API.DTOs.ReadingLists;
using API.Entities; using API.Entities;
using API.Helpers.Converters; using API.Helpers.Converters;

View File

@ -1,11 +1,14 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
namespace API.Interfaces.Repositories namespace API.Interfaces.Repositories
{ {
public interface IAppUserProgressRepository public interface IAppUserProgressRepository
{ {
void Update(AppUserProgress userProgress);
Task<int> CleanupAbandonedChapters(); Task<int> CleanupAbandonedChapters();
Task<bool> UserHasProgress(LibraryType libraryType, int userId); Task<bool> UserHasProgress(LibraryType libraryType, int userId);
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
} }
} }

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Reader; using API.DTOs.Reader;
using API.Entities; using API.Entities;
@ -10,5 +11,12 @@ namespace API.Interfaces.Repositories
void Update(Chapter chapter); void Update(Chapter chapter);
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds); Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds);
Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId); Task<IChapterInfoDto> GetChapterInfoDtoAsync(int chapterId);
Task<int> GetChapterTotalPagesAsync(int chapterId);
Task<Chapter> GetChapterAsync(int chapterId);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
} }
} }

View File

@ -56,7 +56,7 @@ namespace API.Interfaces.Repositories
/// <returns></returns> /// <returns></returns>
Task AddSeriesModifiers(int userId, List<SeriesDto> series); Task AddSeriesModifiers(int userId, List<SeriesDto> series);
Task<byte[]> GetVolumeCoverImageAsync(int volumeId);
Task<byte[]> GetSeriesCoverImageAsync(int seriesId); Task<byte[]> GetSeriesCoverImageAsync(int seriesId);
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter); Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);

View File

@ -1,6 +1,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.Reader;
using API.Entities; using API.Entities;
namespace API.Interfaces.Repositories namespace API.Interfaces.Repositories
@ -9,20 +11,21 @@ namespace API.Interfaces.Repositories
{ {
void Update(AppUser user); void Update(AppUser user);
void Update(AppUserPreferences preferences); void Update(AppUserPreferences preferences);
void Update(AppUserBookmark bookmark);
public void Delete(AppUser user); public void Delete(AppUser user);
Task<AppUser> GetUserByUsernameAsync(string username);
Task<int> GetUserIdByUsernameAsync(string username);
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
Task<AppUser> GetUserByIdAsync(int id);
Task<IEnumerable<MemberDto>> GetMembersAsync(); Task<IEnumerable<MemberDto>> GetMembersAsync();
Task<IEnumerable<AppUser>> GetAdminUsersAsync(); Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<AppUserRating> GetUserRating(int seriesId, int userId); Task<AppUserRating> GetUserRating(int seriesId, int userId);
void AddRatingTracking(AppUserRating userRating);
Task<AppUserPreferences> GetPreferencesAsync(string username); Task<AppUserPreferences> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId); Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
Task<AppUser> GetUserByApiKeyAsync(string apiKey); Task<AppUserBookmark> GetBookmarkForPage(int page, int chapterId, int userId);
Task<int> GetUserIdByApiKeyAsync(string apiKey);
Task<AppUser> GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None);
Task<AppUser> GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None);
Task<int> GetUserIdByUsernameAsync(string username);
Task<AppUser> GetUserWithReadingListsByUsernameAsync(string username);
} }
} }

View File

@ -8,12 +8,7 @@ namespace API.Interfaces.Repositories
public interface IVolumeRepository public interface IVolumeRepository
{ {
void Update(Volume volume); void Update(Volume volume);
Task<Chapter> GetChapterAsync(int chapterId);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForVolume(int volumeId); Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
Task<byte[]> GetVolumeCoverImageAsync(int volumeId);
} }
} }

View File

@ -1,11 +1,13 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using API.DTOs; using API.DTOs;
using API.Entities;
namespace API.Interfaces.Services namespace API.Interfaces.Services
{ {
public interface IReaderService public interface IReaderService
{ {
Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user); Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
Task<int> CapPageToChapter(int chapterId, int page);
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
} }
} }

View File

@ -3,49 +3,50 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Comparators;
using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using Microsoft.Extensions.Logging;
namespace API.Interfaces.Services namespace API.Interfaces.Services
{ {
public class ReaderService : IReaderService public class ReaderService : IReaderService
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderService> _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<ReaderService> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger;
} }
/// <summary> /// <summary>
/// Saves progress to DB /// Saves progress to DB
/// </summary> /// </summary>
/// <param name="progressDto"></param> /// <param name="progressDto"></param>
/// <param name="user"></param> /// <param name="userId"></param>
/// <returns></returns> /// <returns></returns>
public async Task<bool> SaveReadingProgress(ProgressDto progressDto, AppUser user) public async Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId)
{ {
// Don't let user save past total pages. // Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId); progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum);
if (progressDto.PageNum > chapter.Pages)
{
progressDto.PageNum = chapter.Pages;
}
if (progressDto.PageNum < 0)
{
progressDto.PageNum = 0;
}
try try
{ {
user.Progresses ??= new List<AppUserProgress>();
var userProgress = var userProgress =
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id); await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId);
if (userProgress == null) 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<AppUserProgress>();
userWithProgress.Progresses.Add(new AppUserProgress
{ {
PagesRead = progressDto.PageNum, PagesRead = progressDto.PageNum,
VolumeId = progressDto.VolumeId, VolumeId = progressDto.VolumeId,
@ -54,6 +55,7 @@ namespace API.Interfaces.Services
BookScrollId = progressDto.BookScrollId, BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now LastModified = DateTime.Now
}); });
_unitOfWork.UserRepository.Update(userWithProgress);
} }
else else
{ {
@ -62,21 +64,149 @@ namespace API.Interfaces.Services
userProgress.VolumeId = progressDto.VolumeId; userProgress.VolumeId = progressDto.VolumeId;
userProgress.BookScrollId = progressDto.BookScrollId; userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now; userProgress.LastModified = DateTime.Now;
_unitOfWork.AppUserProgressRepository.Update(userProgress);
} }
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) if (await _unitOfWork.CommitAsync())
{ {
return true; 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(); await _unitOfWork.RollbackAsync();
} }
return false; return false;
} }
public async Task<int> CapPageToChapter(int chapterId, int page)
{
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
if (page > totalPages)
{
page = totalPages;
}
if (page < 0)
{
page = 0;
}
return page;
}
/// <summary>
/// Tries to find the next logical Chapter
/// </summary>
/// <example>
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02
/// </example>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="currentChapterId"></param>
/// <param name="userId"></param>
/// <returns>-1 if nothing can be found</returns>
public async Task<int> 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;
}
/// <summary>
/// Tries to find the prev logical Chapter
/// </summary>
/// <example>
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02
/// </example>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="currentChapterId"></param>
/// <param name="userId"></param>
/// <returns>-1 if nothing can be found</returns>
public async Task<int> 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<ChapterDto> 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;
}
} }
} }

View File

@ -67,7 +67,7 @@ namespace API.Services
public async Task<Chapter> Ensure(int chapterId) public async Task<Chapter> Ensure(int chapterId)
{ {
EnsureCacheDirectory(); EnsureCacheDirectory();
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
var extractPath = GetCachePath(chapterId); var extractPath = GetCachePath(chapterId);
if (!Directory.Exists(extractPath)) if (!Directory.Exists(extractPath))
@ -192,7 +192,7 @@ namespace API.Services
{ {
// Calculate what chapter the page belongs to // Calculate what chapter the page belongs to
var pagesSoFar = 0; 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) foreach (var mangaFile in chapterFiles)
{ {
if (page <= (mangaFile.Pages + pagesSoFar)) if (page <= (mangaFile.Pages + pagesSoFar))

126
UI/Web/package-lock.json generated
View File

@ -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": { "@schematics/angular": {
"version": "11.2.11", "version": "11.2.11",
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-11.2.11.tgz", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-11.2.11.tgz",
@ -5784,6 +5789,11 @@
"is-obj": "^2.0.0" "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": { "duplexify": {
"version": "3.7.1", "version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@ -6979,6 +6989,14 @@
"dev": true, "dev": true,
"optional": 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": { "handle-thing": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz",
@ -11188,6 +11206,11 @@
"is-wsl": "^2.1.1" "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": { "opn": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz", "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": { "sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@ -15088,6 +15128,11 @@
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
"dev": true "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": { "tough-cookie": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "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": { "webpack-dev-middleware": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz",

View File

@ -6,7 +6,7 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"prod": "ng build --prod", "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": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
@ -46,6 +46,7 @@
"rxjs": "~6.6.0", "rxjs": "~6.6.0",
"swiper": "^6.5.8", "swiper": "^6.5.8",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"webpack-bundle-analyzer": "^4.4.2",
"zone.js": "~0.10.2" "zone.js": "~0.10.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -8,7 +8,6 @@ export interface Series {
localizedName: string; localizedName: string;
sortName: string; sortName: string;
summary: string; summary: string;
coverImage: string; // This is not passed from backend any longer. TODO: Remove this field
coverImageLocked: boolean; coverImageLocked: boolean;
volumes: Volume[]; volumes: Volume[];
pages: number; // Total pages in series pages: number; // Total pages in series

View File

@ -9,7 +9,7 @@
<div class="form-group"> <div class="form-group">
<label for="filter">Filter</label> <label for="filter">Filter</label>
<div class="input-group"> <div class="input-group">
<input id="filter" autocomplete="false" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input"> <input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append"> <div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button> <button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div> </div>

View File

@ -12,10 +12,10 @@ import { DirectoryPickerComponent } from './_modals/directory-picker/directory-p
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component'; import { ResetPasswordModalComponent } from './_modals/reset-password-modal/reset-password-modal.component';
import { ManageSettingsComponent } from './manage-settings/manage-settings.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 { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.component';
import { ManageSystemComponent } from './manage-system/manage-system.component'; import { ManageSystemComponent } from './manage-system/manage-system.component';
import { ChangelogComponent } from './changelog/changelog.component'; import { ChangelogComponent } from './changelog/changelog.component';
import { PipeModule } from '../pipe/pipe.module';
@ -30,7 +30,6 @@ import { ChangelogComponent } from './changelog/changelog.component';
DirectoryPickerComponent, DirectoryPickerComponent,
ResetPasswordModalComponent, ResetPasswordModalComponent,
ManageSettingsComponent, ManageSettingsComponent,
FilterPipe,
EditRbsModalComponent, EditRbsModalComponent,
ManageSystemComponent, ManageSystemComponent,
ChangelogComponent, ChangelogComponent,
@ -44,6 +43,7 @@ import { ChangelogComponent } from './changelog/changelog.component';
NgbTooltipModule, NgbTooltipModule,
NgbDropdownModule, NgbDropdownModule,
SharedModule, SharedModule,
PipeModule
], ],
providers: [] providers: []
}) })

View File

@ -112,9 +112,9 @@
<div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)"> <div #readingSection class="reading-section" [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" [@isLoading]="isLoading ? true : false" (click)="handleReaderClick($event)">
<div #readingHtml [innerHtml]="page" *ngIf="page !== undefined"></div> <div #readingHtml [innerHtml]="page" *ngIf="page !== undefined"></div>
<div class="left {{clickOverlayClass('left')}}" (click)="prevPage()" *ngIf="clickToPaginate"> <div class="left {{clickOverlayClass('left')}} no-observe" (click)="prevPage()" *ngIf="clickToPaginate">
</div> </div>
<div class="right {{clickOverlayClass('right')}}" (click)="nextPage()" *ngIf="clickToPaginate"> <div class="right {{clickOverlayClass('right')}} no-observe" (click)="nextPage()" *ngIf="clickToPaginate">
</div> </div>
<div [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" *ngIf="page !== undefined && scrollbarNeeded"> <div [ngStyle]="{'padding-top': topOffset + 20 + 'px'}" *ngIf="page !== undefined && scrollbarNeeded">

View File

@ -288,14 +288,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
if (alreadyReached.length > 0) { if (alreadyReached.length > 0) {
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
if (!this.incognitoMode) {
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
}
return; return;
} else { } else {
this.currentPageAnchor = ''; 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 */}); 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[]) { 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) => { intersectingEntries.sort((a: Element, b: Element) => {
const aTop = a.getBoundingClientRect().top; const aTop = a.getBoundingClientRect().top;
const bTop = b.getBoundingClientRect().top; const bTop = b.getBoundingClientRect().top;
@ -457,6 +462,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return 0; return 0;
}); });
if (intersectingEntries.length > 0) { if (intersectingEntries.length > 0) {
let path = this.getXPathTo(intersectingEntries[0]); let path = this.getXPathTo(intersectingEntries[0]);
if (path === '') { return; } if (path === '') { return; }
@ -643,7 +649,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
setupPageAnchors() { setupPageAnchors() {
this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => { this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => {
if (!elem.classList.contains('no-observe')) {
this.intersectionObserver.observe(elem); this.intersectionObserver.observe(elem);
}
}); });
this.pageAnchors = {}; this.pageAnchors = {};

View File

@ -117,7 +117,6 @@
</div> </div>
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <div class="col">
<!-- Is Special: {{volume.isSpecial}} -->
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]"> <button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]">
View Files View Files
</button> </button>

View File

@ -6,10 +6,38 @@
<strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}} <strong>Prefetched</strong> {{minPageLoaded}}-{{maxPageLoaded}}
<strong>Current Page:</strong>{{pageNum}} <strong>Current Page:</strong>{{pageNum}}
<strong>Width:</strong> {{webtoonImageWidth}} <strong>Width:</strong> {{webtoonImageWidth}}
<strong>Pages:</strong> {{pageNum}} / {{totalPages}}
<strong>At Top:</strong> {{atTop}}
<strong>At Bottom:</strong> {{atBottom}}
</div> </div>
<div *ngIf="atTop" class="spacer top" role="alert" (click)="loadPrevChapter.emit()">
<div style="height: 200px"></div>
<div>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="mx-auto text">Previous Chapter</span>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-up animate" aria-hidden="true"></i>
</button>
<span class="sr-only">Scroll up to move to next chapter</span>
</div>
</div>
<ng-container *ngFor="let item of webtoonImages | async; let index = index;"> <ng-container *ngFor="let item of webtoonImages | async; let index = index;">
<img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && debug ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;"> <img src="{{item.src}}" style="display: block" class="mx-auto {{pageNum === item.page && debug ? 'active': ''}}" *ngIf="pageNum >= pageNum - bufferPages && pageNum <= pageNum + bufferPages" rel="nofollow" alt="image" (load)="onImageLoad($event)" id="page-{{item.page}}" [attr.page]="item.page" ondragstart="return false;" onselectstart="return false;">
</ng-container> </ng-container>
<div *ngIf="atBottom" class="spacer bottom" role="alert" (click)="loadPrevChapter.emit()">
<div>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
</button>
<span class="mx-auto text">Next Chapter</span>
<button class="btn btn-icon mx-auto">
<i class="fa fa-angle-double-down animate" aria-hidden="true"></i>
</button>
<span class="sr-only">Scroll down to move to next chapter</span>
</div>
<div style="height: 200px"></div>
</div>

View File

@ -5,3 +5,27 @@
.active { .active {
border: 2px solid red; 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);
}
}

View File

@ -1,10 +1,16 @@
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core'; 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 { 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 { ReaderService } from '../../_services/reader.service';
import { PAGING_DIRECTION } from '../_models/reader-enums'; import { PAGING_DIRECTION } from '../_models/reader-enums';
import { WebtoonImage } from '../_models/webtoon-image'; 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({ @Component({
selector: 'app-infinite-scroller', selector: 'app-infinite-scroller',
templateUrl: './infinite-scroller.component.html', templateUrl: './infinite-scroller.component.html',
@ -29,6 +35,8 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
*/ */
@Input() urlProvider!: (page: number) => string; @Input() urlProvider!: (page: number) => string;
@Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>(); @Output() pageNumberChange: EventEmitter<number> = new EventEmitter<number>();
@Output() loadNextChapter: EventEmitter<void> = new EventEmitter<void>();
@Output() loadPrevChapter: EventEmitter<void> = new EventEmitter<void>();
@Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>(); @Input() goToPage: ReplaySubject<number> = new ReplaySubject<number>();
@ -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. * Denotes each page that has been loaded or not. If pruning is implemented, the key will be deleted.
*/ */
imagesLoaded: {[key: number]: number} = {}; 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 * Debug mode. Will show extra information
*/ */
@ -87,7 +107,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
private readonly onDestroy = new Subject<void>(); private readonly onDestroy = new Subject<void>();
constructor(private readerService: ReaderService, private renderer: Renderer2) { } constructor(private readerService: ReaderService, private renderer: Renderer2, private toastr: ToastrService) {}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) { if (changes.hasOwnProperty('totalPages') && changes['totalPages'].previousValue != changes['totalPages'].currentValue) {
@ -145,6 +165,48 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy {
this.scrollingDirection = PAGING_DIRECTION.BACKWARDS; this.scrollingDirection = PAGING_DIRECTION.BACKWARDS;
} }
this.prevScrollPosition = verticalOffset; 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() { initWebtoonReader() {
this.imagesLoaded = {}; this.imagesLoaded = {};
this.webtoonImages.next([]); this.webtoonImages.next([]);
this.atBottom = false;
//this.atTop = document.documentElement.scrollTop === 0 && this.pageNum === 0;
this.checkIfShouldTriggerContinuousReader();
const [startingIndex, endingIndex] = this.calculatePrefetchIndecies(); 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 * @param scrollToPage Optional (default false) parameter to trigger scrolling to the newly set page
*/ */
setPageNum(pageNum: number, scrollToPage: boolean = false) { setPageNum(pageNum: number, scrollToPage: boolean = false) {
if (pageNum > this.totalPages) {
pageNum = this.totalPages;
} else if (pageNum < 0) {
pageNum = 0;
}
this.pageNum = pageNum; this.pageNum = pageNum;
this.pageNumberChange.emit(this.pageNum); this.pageNumberChange.emit(this.pageNum);

View File

@ -28,7 +28,7 @@
ondragstart="return false;" onselectstart="return false;"> ondragstart="return false;" onselectstart="return false;">
</canvas> </canvas>
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading"> <div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading">
<app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages" [urlProvider]="getPageUrl"></app-infinite-scroller> <app-infinite-scroller [pageNum]="pageNum" [bufferPages]="5" [goToPage]="goToPageEvent" (pageNumberChange)="handleWebtoonPageChange($event)" [totalPages]="maxPages - 1" [urlProvider]="getPageUrl" (loadNextChapter)="loadNextChapter()" (loadPrevChapter)="loadPrevChapter()"></app-infinite-scroller>
</div> </div>
<ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <!--; else webtoonClickArea; See if people want this mode WEBTOON_WITH_CLICKS--> <ng-container *ngIf="readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD"> <!--; else webtoonClickArea; See if people want this mode WEBTOON_WITH_CLICKS-->
<div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div> <div class="{{readerMode === READER_MODE.MANGA_LR ? 'right' : 'top'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, 'right')"></div>

View File

@ -365,6 +365,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const goToPageNum = this.promptForPage(); const goToPageNum = this.promptForPage();
if (goToPageNum === null) { return; } if (goToPageNum === null) { return; }
this.goToPage(parseInt(goToPageNum.trim(), 10)); 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.nextChapterDisabled = false;
this.prevChapterDisabled = false; this.prevChapterDisabled = false;
this.nextChapterPrefetched = false; this.nextChapterPrefetched = false;
this.pageNum = 0; // ?! Why was this 1 this.pageNum = 0;
forkJoin({ forkJoin({
progress: this.readerService.getProgress(this.chapterId), progress: this.readerService.getProgress(this.chapterId),
@ -391,11 +393,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.volumeId = results.chapterInfo.volumeId; this.volumeId = results.chapterInfo.volumeId;
this.maxPages = results.chapterInfo.pages; this.maxPages = results.chapterInfo.pages;
console.log('results: ', results);
let page = results.progress.pageNum; let page = results.progress.pageNum;
console.log('page: ', page); if (page > this.maxPages) {
console.log('this.pageNum: ', this.pageNum);
if (page >= this.maxPages) {
page = this.maxPages - 1; page = this.maxPages - 1;
} }
this.setPageNum(page); 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) { 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.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.nextChapterId = chapterId; this.nextChapterId = chapterId;
this.loadChapter(chapterId, 'next'); this.loadChapter(chapterId, 'Next');
}); });
} else { } 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) { 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.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => {
this.prevChapterId = chapterId; this.prevChapterId = chapterId;
this.loadChapter(chapterId, 'prev'); this.loadChapter(chapterId, 'Prev');
}); });
} else { } 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) { if (chapterId >= 0) {
this.chapterId = chapterId; this.chapterId = chapterId;
this.continuousChaptersStack.push(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); const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
window.history.replaceState({}, '', newRoute); window.history.replaceState({}, '', newRoute);
this.init(); this.init();
this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000});
} else { } else {
// This will only happen if no actual chapter can be found // 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; this.isLoading = false;
if (direction === 'prev') { if (direction === 'Prev') {
this.prevPageDisabled = true; this.prevPageDisabled = true;
} else { } else {
this.nextPageDisabled = true; this.nextPageDisabled = true;
@ -1010,7 +1010,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
bookmarkPage() { bookmarkPage() {
const pageNum = this.pageNum; const pageNum = this.pageNum;
if (this.pageBookmarked) { if (this.pageBookmarked) {
// Remove bookmark
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => { this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
delete this.bookmarks[pageNum]; delete this.bookmarks[pageNum];
}); });

View File

@ -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 { }

View File

@ -5,11 +5,19 @@
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<form style="width: 100%" [formGroup]="listForm">
<div class="modal-body"> <div class="modal-body">
<!-- TODO: Put filter here --> <div class="form-group" *ngIf="lists.length >= 5">
<label for="filter">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
</div>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let readingList of lists; let i = index" (click)="addToList(readingList)"> <li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let readingList of lists | filter: filterList; let i = index" (click)="addToList(readingList)">
<!-- Think about using radio buttons maybe for screen reader-->
{{readingList.title}} <i class="fa fa-angle-double-up" *ngIf="readingList.promoted" title="Promoted"></i> {{readingList.title}} <i class="fa fa-angle-double-up" *ngIf="readingList.promoted" title="Promoted"></i>
</li> </li>
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No lists created yet</li> <li class="list-group-item" *ngIf="lists.length === 0 && !loading">No lists created yet</li>
@ -21,17 +29,18 @@
</ul> </ul>
</div> </div>
<div class="modal-footer" style="justify-content: normal"> <div class="modal-footer" style="justify-content: normal">
<form style="width: 100%" [formGroup]="listForm"> <div style="width: 100%;">
<div class="form-row"> <div class="form-row">
<div class="col-md-10"> <div class="col-9 col-lg-10">
<label class="sr-only" for="add-rlist">Reading List</label> <label class="sr-only" for="add-rlist">Reading List</label>
<input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title"> <input width="100%" #title ngbAutofocus type="text" class="form-control mb-2" id="add-rlist" formControlName="title">
</div> </div>
<div class="col-md-2"> <div class="col-2">
<button type="submit" class="btn btn-primary" (click)="create()">Create</button> <button type="submit" class="btn btn-primary" (click)="create()">Create</button>
</div> </div>
</div> </div>
</form> </div>
</div> </div>
</form>

View File

@ -41,6 +41,7 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
ngOnInit(): void { ngOnInit(): void {
this.listForm.addControl('title', new FormControl(this.title, [])); this.listForm.addControl('title', new FormControl(this.title, []));
this.listForm.addControl('filterQuery', new FormControl('', []));
this.loading = true; this.loading = true;
this.readingListService.getReadingLists(false).subscribe(lists => { 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;
}
} }

View File

@ -29,7 +29,9 @@
</button> </button>
</div> </div>
</div> </div>
<p class="mt-2" *ngIf="readingList.summary.length > 0">{{readingList.summary}}</p> <div class="row no-gutters mt-2">
<app-read-more [text]="readingList.summary" [maxLength]="250"></app-read-more>
</div>
</div> </div>
</div> </div>
@ -44,14 +46,21 @@
<img width="74px" style="width: 74px;" class="img-top lazyload mr-3" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getChapterCoverImage(item.chapterId)" <img width="74px" style="width: 74px;" class="img-top lazyload mr-3" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getChapterCoverImage(item.chapterId)"
(error)="imageService.updateErroredImage($event)"> (error)="imageService.updateErroredImage($event)">
<div class="media-body"> <div class="media-body">
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}</h5> <h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}&nbsp;
<i class="fa {{utilityService.mangaFormatIcon(item.seriesFormat)}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(item.seriesFormat)}}"></i><span class="sr-only">{{utilityService.mangaFormat(item.seriesFormat)}}</span>&nbsp; <span class="badge badge-primary badge-pill">
<span *ngIf="item.pagesRead > 0 && item.pagesRead < item.pagesTotal">{{item.pagesRead}} / {{item.pagesTotal}}</span>
<span *ngIf="item.pagesRead === 0">UNREAD</span>
<span *ngIf="item.pagesRead === item.pagesTotal">READ</span>
</span>
</h5>
<i class="fa {{utilityService.mangaFormatIcon(item.seriesFormat)}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(item.seriesFormat)}}"></i>
<span class="sr-only">{{utilityService.mangaFormat(item.seriesFormat)}}</span>&nbsp;
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a> <a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
<span *ngIf="item.promoted"> <span *ngIf="item.promoted">
<i class="fa fa-angle-double-up" aria-hidden="true"></i> <i class="fa fa-angle-double-up" aria-hidden="true"></i>
</span> </span>
</div> </div>
<div class="pull-right" *ngIf="item.pagesRead === item.pagesTotal"><i class="fa fa-check-square" aria-label="Read"></i></div>
</div> </div>
</ng-template> </ng-template>
</app-dragable-ordered-list> </app-dragable-ordered-list>

View File

@ -10,7 +10,6 @@ import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service'; import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.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 { ReadingListService } from 'src/app/_services/reading-list.service';
import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component'; import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component';
@ -28,6 +27,11 @@ export class ReadingListDetailComponent implements OnInit {
isAdmin: boolean = false; isAdmin: boolean = false;
isLoading: boolean = false; isLoading: boolean = false;
// Downloading
hasDownloadingRole: boolean = false;
downloadInProgress: boolean = false;
get MangaFormat(): typeof MangaFormat { get MangaFormat(): typeof MangaFormat {
return MangaFormat; return MangaFormat;
} }
@ -58,6 +62,7 @@ export class ReadingListDetailComponent implements OnInit {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) { if (user) {
this.isAdmin = this.accountService.hasAdminRole(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)); this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)).filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
} }

View File

@ -9,6 +9,8 @@ import { ReactiveFormsModule } from '@angular/forms';
import { CardsModule } from '../cards/cards.module'; import { CardsModule } from '../cards/cards.module';
import { ReadingListsComponent } from './reading-lists/reading-lists.component'; import { ReadingListsComponent } from './reading-lists/reading-lists.component';
import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.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, ReadingListRoutingModule,
ReactiveFormsModule, ReactiveFormsModule,
DragDropModule, DragDropModule,
CardsModule CardsModule,
PipeModule,
SharedModule
], ],
exports: [ exports: [
AddToListModalComponent, AddToListModalComponent,

View File

@ -7,11 +7,10 @@ const routes: Routes = [
{ {
path: '', path: '',
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
canActivate: [AuthGuard], // TODO: Add a guard if they have access to said :id canActivate: [AuthGuard],
children: [ children: [
{path: '', component: ReadingListDetailComponent, pathMatch: 'full'}, {path: '', component: ReadingListDetailComponent, pathMatch: 'full'},
{path: ':id', component: ReadingListDetailComponent, pathMatch: 'full'}, {path: ':id', component: ReadingListDetailComponent, pathMatch: 'full'},
// {path: ':id', component: CollectionDetailComponent},
] ]
} }
]; ];

View File

@ -6,6 +6,7 @@ import { PaginatedResult, Pagination } from 'src/app/_models/pagination';
import { ReadingList } from 'src/app/_models/reading-list'; import { ReadingList } from 'src/app/_models/reading-list';
import { AccountService } from 'src/app/_services/account.service'; import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.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 { ImageService } from 'src/app/_services/image.service';
import { ReadingListService } from 'src/app/_services/reading-list.service'; import { ReadingListService } from 'src/app/_services/reading-list.service';
@ -23,7 +24,7 @@ export class ReadingListsComponent implements OnInit {
isAdmin: boolean = false; isAdmin: boolean = false;
constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, 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 { ngOnInit(): void {
this.loadPage(); this.loadPage();
@ -53,6 +54,13 @@ export class ReadingListsComponent implements OnInit {
this.toastr.success('Reading list deleted'); this.toastr.success('Reading list deleted');
this.loadPage(); this.loadPage();
}); });
break;
case Action.Edit:
this.actionService.editReadingList(readingList, (updatedList: ReadingList) => {
// Reload information around list
readingList = updatedList;
});
break;
} }
} }

View File

@ -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?')); 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. * Format bytes as human-readable text.
* *

View File

@ -13,6 +13,7 @@ export enum KEY_CODES {
SPACE = ' ', SPACE = ' ',
ENTER = 'Enter', ENTER = 'Enter',
G = 'g', G = 'g',
B = 'b',
BACKSPACE = 'Backspace', BACKSPACE = 'Backspace',
DELETE = 'Delete' DELETE = 'Delete'
} }