diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index e18bdb03f..764c162e2 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -6,28 +6,22 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Reader; using API.Entities.Enums; -using API.Extensions; using API.Services; -using HtmlAgilityPack; +using Kavita.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; using VersOne.Epub; namespace API.Controllers { public class BookController : BaseApiController { - private readonly ILogger _logger; private readonly IBookService _bookService; private readonly IUnitOfWork _unitOfWork; private readonly ICacheService _cacheService; - private const string BookApiUrl = "book-resources?file="; - - public BookController(ILogger logger, IBookService bookService, + public BookController(IBookService bookService, IUnitOfWork unitOfWork, ICacheService cacheService) { - _logger = logger; _bookService = bookService; _unitOfWork = unitOfWork; _cacheService = cacheService; @@ -70,6 +64,8 @@ namespace API.Controllers break; case MangaFormat.Unknown: break; + default: + throw new ArgumentOutOfRangeException(); } return Ok(new BookInfoDto() @@ -97,6 +93,7 @@ namespace API.Controllers [ResponseCache(Duration = 60 * 1, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetBookPageResources(int chapterId, [FromQuery] string file) { + if (chapterId <= 0) return BadRequest("Chapter is not valid"); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); @@ -120,125 +117,20 @@ namespace API.Controllers [HttpGet("{chapterId}/chapters")] public async Task>> GetBookChapters(int chapterId) { + if (chapterId <= 0) return BadRequest("Chapter is not valid"); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); - var mappings = await _bookService.CreateKeyToPageMappingAsync(book); - - var navItems = await book.GetNavigationAsync(); - var chaptersList = new List(); - - foreach (var navigationItem in navItems) + try { - if (navigationItem.NestedItems.Count > 0) - { - var nestedChapters = new List(); - - foreach (var nestedChapter in navigationItem.NestedItems) - { - if (nestedChapter.Link == null) continue; - var key = BookService.CleanContentKeys(nestedChapter.Link.ContentFileName); - if (mappings.ContainsKey(key)) - { - nestedChapters.Add(new BookChapterItem() - { - Title = nestedChapter.Title, - Page = mappings[key], - Part = nestedChapter.Link.Anchor ?? string.Empty, - Children = new List() - }); - } - } - - CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings); - } - - if (navigationItem.NestedItems.Count == 0) - { - CreateToCChapter(navigationItem, Array.Empty(), chaptersList, mappings); - } + return Ok(await _bookService.GenerateTableOfContents(chapter)); } - - if (chaptersList.Count == 0) + catch (KavitaException ex) { - // Generate from TOC - var tocPage = book.Content.Html.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC")); - if (tocPage == null) return Ok(chaptersList); - - // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content - var doc = new HtmlDocument(); - var content = await book.Content.Html[tocPage].ReadContentAsync(); - doc.LoadHtml(content); - var anchors = doc.DocumentNode.SelectNodes("//a"); - if (anchors == null) return Ok(chaptersList); - - foreach (var anchor in anchors) - { - if (anchor.Attributes.Contains("href")) - { - var key = BookService.CleanContentKeys(anchor.Attributes["href"].Value).Split("#")[0]; - if (!mappings.ContainsKey(key)) - { - // Fallback to searching for key (bad epub metadata) - var correctedKey = book.Content.Html.Keys.SingleOrDefault(s => s.EndsWith(key)); - if (!string.IsNullOrEmpty(correctedKey)) - { - key = correctedKey; - } - } - if (!string.IsNullOrEmpty(key) && mappings.ContainsKey(key)) - { - var part = string.Empty; - if (anchor.Attributes["href"].Value.Contains('#')) - { - part = anchor.Attributes["href"].Value.Split("#")[1]; - } - chaptersList.Add(new BookChapterItem() - { - Title = anchor.InnerText, - Page = mappings[key], - Part = part, - Children = new List() - }); - } - } - } - - } - return Ok(chaptersList); - } - - private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList nestedChapters, IList chaptersList, - IReadOnlyDictionary mappings) - { - if (navigationItem.Link == null) - { - var item = new BookChapterItem() - { - Title = navigationItem.Title, - Children = nestedChapters - }; - if (nestedChapters.Count > 0) - { - item.Page = nestedChapters[0].Page; - } - - chaptersList.Add(item); - } - else - { - var groupKey = BookService.CleanContentKeys(navigationItem.Link.ContentFileName); - if (mappings.ContainsKey(groupKey)) - { - chaptersList.Add(new BookChapterItem() - { - Title = navigationItem.Title, - Page = mappings[groupKey], - Children = nestedChapters - }); - } + return BadRequest(ex.Message); } } + /// /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, /// all css is scoped, etc. @@ -252,57 +144,17 @@ namespace API.Controllers var chapter = await _cacheService.Ensure(chapterId); var path = _cacheService.GetCachedFile(chapter); - using var book = await EpubReader.OpenBookAsync(path, BookService.BookReaderOptions); - var mappings = await _bookService.CreateKeyToPageMappingAsync(book); - - var counter = 0; - var doc = new HtmlDocument {OptionFixNestedTags = true}; - var baseUrl = "//" + Request.Host + Request.PathBase + "/api/"; - var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; - var bookPages = await book.GetReadingOrderAsync(); - foreach (var contentFileRef in bookPages) + + try { - if (page != counter) - { - counter++; - continue; - } - - var content = await contentFileRef.ReadContentAsync(); - if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(content); - - // In more cases than not, due to this being XML not HTML, we need to escape the script tags. - content = BookService.EscapeTags(content); - - doc.LoadHtml(content); - var body = doc.DocumentNode.SelectSingleNode("//body"); - - if (body == null) - { - if (doc.ParseErrors.Any()) - { - LogBookErrors(book, contentFileRef, doc); - return BadRequest("The file is malformed! Cannot read."); - } - _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); - doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); - body = doc.DocumentNode.SelectSingleNode("/html/body"); - } - - return Ok(await _bookService.ScopePage(doc, book, apiBase, body, mappings, page)); + return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); } - - return BadRequest("Could not find the appropriate html for that page"); - } - - private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc) - { - _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); - foreach (var error in doc.ParseErrors) + catch (KavitaException ex) { - _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); + return BadRequest(ex.Message); } } + } } diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 7fbf14dd5..da44d5e18 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -59,6 +59,7 @@ public class CollectionTagRepository : ICollectionTagRepository var tagsToDelete = await _context.CollectionTag .Include(c => c.SeriesMetadatas) .Where(c => c.SeriesMetadatas.Count == 0) + .AsSplitQuery() .ToListAsync(); _context.RemoveRange(tagsToDelete); @@ -112,6 +113,7 @@ public class CollectionTagRepository : ICollectionTagRepository return await _context.CollectionTag .Where(c => c.Id == tagId) .Include(c => c.SeriesMetadatas) + .AsSplitQuery() .SingleOrDefaultAsync(); } diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index f1c9b84eb..c5b151ac7 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -55,6 +55,7 @@ public class GenreRepository : IGenreRepository .Include(p => p.SeriesMetadatas) .Include(p => p.Chapters) .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .AsSplitQuery() .ToListAsync(); _context.Genre.RemoveRange(genresWithNoConnections); @@ -67,6 +68,7 @@ public class GenreRepository : IGenreRepository return await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .SelectMany(s => s.Metadata.Genres) + .AsSplitQuery() .Distinct() .OrderBy(p => p.Title) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index bc7c37bf5..782247a1a 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -169,6 +169,7 @@ public class LibraryRepository : ILibraryRepository .Include(f => f.Folders) .OrderBy(l => l.Name) .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() .AsNoTracking() .ToListAsync(); } @@ -200,7 +201,7 @@ public class LibraryRepository : ILibraryRepository query = query.Include(l => l.AppUsers); } - return query; + return query.AsSplitQuery(); } @@ -259,6 +260,7 @@ public class LibraryRepository : ILibraryRepository .Where(library => library.AppUsers.Contains(user)) .Include(l => l.Folders) .AsNoTracking() + .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } @@ -283,6 +285,7 @@ public class LibraryRepository : ILibraryRepository var ret = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) + .AsSplitQuery() .AsNoTracking() .Distinct() .ToListAsync(); @@ -302,6 +305,7 @@ public class LibraryRepository : ILibraryRepository { return _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) + .AsSplitQuery() .Select(s => s.Metadata.PublicationStatus) .Distinct() .AsEnumerable() @@ -313,5 +317,4 @@ public class LibraryRepository : ILibraryRepository .OrderBy(s => s.Title); } - } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 98794670e..ff59fe596 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -54,6 +54,7 @@ public class PersonRepository : IPersonRepository .Include(p => p.SeriesMetadatas) .Include(p => p.ChapterMetadatas) .Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0) + .AsSplitQuery() .ToListAsync(); _context.Person.RemoveRange(peopleWithNoConnections); @@ -69,6 +70,7 @@ public class PersonRepository : IPersonRepository .Distinct() .OrderBy(p => p.Name) .AsNoTracking() + .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 0294d6224..c884973a7 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -96,6 +96,7 @@ public class ReadingListRepository : IReadingListRepository var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) .Where(l => l.Items.Any(i => i.SeriesId == seriesId)) + .AsSplitQuery() .OrderBy(l => l.LastModified) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking(); @@ -108,6 +109,7 @@ public class ReadingListRepository : IReadingListRepository return await _context.ReadingList .Where(r => r.Id == readingListId) .Include(r => r.Items.OrderBy(item => item.Order)) + .AsSplitQuery() .SingleOrDefaultAsync(); } @@ -116,6 +118,7 @@ public class ReadingListRepository : IReadingListRepository var userLibraries = _context.Library .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsSplitQuery() .AsNoTracking() .Select(library => library.Id) .ToList(); @@ -165,6 +168,7 @@ public class ReadingListRepository : IReadingListRepository }) .Where(o => userLibraries.Contains(o.LibraryId)) .OrderBy(rli => rli.Order) + .AsSplitQuery() .AsNoTracking() .ToListAsync(); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index b42bce75c..e6f9bbd85 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -279,6 +279,7 @@ public class SeriesRepository : ISeriesRepository .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(user => user.Id == userId)) .AsNoTracking() + .AsSplitQuery() .Select(library => library.Id) .ToListAsync(); } @@ -485,6 +486,7 @@ public class SeriesRepository : ISeriesRepository var volumes = await _context.Volume .Where(v => seriesIds.Contains(v.SeriesId)) .Include(v => v.Chapters) + .AsSplitQuery() .ToListAsync(); IList chapterIds = new List(); @@ -509,6 +511,7 @@ public class SeriesRepository : ISeriesRepository var volumes = await _context.Volume .Where(v => seriesIds.Contains(v.SeriesId)) .Include(v => v.Chapters) + .AsSplitQuery() .ToListAsync(); var seriesChapters = new Dictionary>(); @@ -532,10 +535,12 @@ public class SeriesRepository : ISeriesRepository { var userProgress = await _context.AppUserProgresses .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId)) + .AsSplitQuery() .ToListAsync(); var userRatings = await _context.AppUserRating .Where(r => r.AppUserId == userId && series.Select(s => s.Id).Contains(r.SeriesId)) + .AsSplitQuery() .ToListAsync(); foreach (var s in series) @@ -804,6 +809,7 @@ public class SeriesRepository : ISeriesRepository var userLibraries = _context.Library .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsSplitQuery() .AsNoTracking() .Select(library => library.Id) .ToList(); @@ -829,6 +835,7 @@ public class SeriesRepository : ISeriesRepository .Include(v => v.Chapters) .ThenInclude(c => c.Files) .SelectMany(v => v.Chapters.SelectMany(c => c.Files)) + .AsSplitQuery() .AsNoTracking() .ToListAsync(); } @@ -838,6 +845,7 @@ public class SeriesRepository : ISeriesRepository var allowedLibraries = _context.Library .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(x => x.Id == userId)) + .AsSplitQuery() .Select(l => l.Id); return await _context.Series @@ -920,6 +928,7 @@ public class SeriesRepository : ISeriesRepository return await _context.SeriesMetadata .Where(sm => seriesIds.Contains(sm.SeriesId)) .Include(sm => sm.CollectionTags) + .AsSplitQuery() .ToListAsync(); } @@ -993,6 +1002,7 @@ public class SeriesRepository : ISeriesRepository { return _context.AppUser .Where(u => u.Id == userId) + .AsSplitQuery() .SelectMany(l => l.Libraries.Select(lib => lib.Id)); } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index ef7f2ad43..8ddb52d67 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -54,6 +54,7 @@ public class TagRepository : ITagRepository .Include(p => p.SeriesMetadatas) .Include(p => p.Chapters) .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .AsSplitQuery() .ToListAsync(); _context.Tag.RemoveRange(tagsWithNoConnections); @@ -66,6 +67,7 @@ public class TagRepository : ITagRepository return await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .SelectMany(s => s.Metadata.Tags) + .AsSplitQuery() .Distinct() .OrderBy(t => t.Title) .AsNoTracking() diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index c63603dbc..587836eb9 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -205,6 +205,7 @@ public class UserRepository : IUserRepository return await _context.Users .Include(u => u.ReadingLists) .ThenInclude(l => l.Items) + .AsSplitQuery() .SingleOrDefaultAsync(x => x.UserName == username); } @@ -244,6 +245,7 @@ public class UserRepository : IUserRepository { return await _context.Library .Include(l => l.AppUsers) + .AsSplitQuery() .AnyAsync(library => library.AppUsers.Any(user => user.Id == userId)); } @@ -362,6 +364,7 @@ public class UserRepository : IUserRepository Folders = l.Folders.Select(x => x.Path).ToList() }).ToList() }) + .AsSplitQuery() .AsNoTracking() .ToListAsync(); } @@ -390,6 +393,7 @@ public class UserRepository : IUserRepository Folders = l.Folders.Select(x => x.Path).ToList() }).ToList() }) + .AsSplitQuery() .AsNoTracking() .ToListAsync(); } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 6ab2ca113..04a91c95c 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -62,6 +62,7 @@ public class VolumeRepository : IVolumeRepository .Where(c => volumeId == c.VolumeId) .Include(c => c.Files) .SelectMany(c => c.Files) + .AsSplitQuery() .AsNoTracking() .ToListAsync(); } @@ -106,7 +107,7 @@ public class VolumeRepository : IVolumeRepository if (includeChapters) { - query = query.Include(v => v.Chapters); + query = query.Include(v => v.Chapters).AsSplitQuery(); } return await query.ToListAsync(); } @@ -123,6 +124,7 @@ public class VolumeRepository : IVolumeRepository .Where(vol => vol.Id == volumeId) .Include(vol => vol.Chapters) .ThenInclude(c => c.Files) + .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider) .SingleAsync(vol => vol.Id == volumeId); @@ -143,6 +145,7 @@ public class VolumeRepository : IVolumeRepository .Where(vol => vol.SeriesId == seriesId) .Include(vol => vol.Chapters) .ThenInclude(c => c.Files) + .AsSplitQuery() .OrderBy(vol => vol.Number) .ToListAsync(); } @@ -157,6 +160,7 @@ public class VolumeRepository : IVolumeRepository return await _context.Volume .Include(vol => vol.Chapters) .ThenInclude(c => c.Files) + .AsSplitQuery() .SingleOrDefaultAsync(vol => vol.Id == volumeId); } @@ -220,6 +224,4 @@ public class VolumeRepository : IVolumeRepository v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); } } - - } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 2ff50ade4..5ddf65be2 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -7,6 +7,8 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Web; using API.Data.Metadata; +using API.DTOs.Reader; +using API.Entities; using API.Entities.Enums; using API.Parser; using Docnet.Core; @@ -15,6 +17,7 @@ using Docnet.Core.Models; using Docnet.Core.Readers; using ExCSS; using HtmlAgilityPack; +using Kavita.Common; using Microsoft.Extensions.Logging; using Microsoft.IO; using SixLabors.ImageSharp; @@ -51,6 +54,9 @@ namespace API.Services void ExtractPdfImages(string fileFilePath, string targetDirectory); Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page); + Task> GenerateTableOfContents(Chapter chapter); + + Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); } public class BookService : IBookService @@ -61,6 +67,7 @@ namespace API.Services private readonly StylesheetParser _cssParser = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; + private const string BookApiUrl = "book-resources?file="; public static readonly EpubReaderOptions BookReaderOptions = new() { PackageReaderOptions = new PackageReaderOptions() @@ -681,6 +688,182 @@ namespace API.Services return PrepareFinalHtml(doc, body); } + /// + /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order + /// this is used to rewrite anchors in the book text so that we always load properly in our reader. + /// + /// Chapter with at least one file + /// + public async Task> GenerateTableOfContents(Chapter chapter) + { + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); + var mappings = await CreateKeyToPageMappingAsync(book); + + var navItems = await book.GetNavigationAsync(); + var chaptersList = new List(); + + foreach (var navigationItem in navItems) + { + if (navigationItem.NestedItems.Count == 0) + { + CreateToCChapter(navigationItem, Array.Empty(), chaptersList, mappings); + continue; + } + + var nestedChapters = new List(); + + foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null)) + { + var key = BookService.CleanContentKeys(nestedChapter.Link.ContentFileName); + if (mappings.ContainsKey(key)) + { + nestedChapters.Add(new BookChapterItem() + { + Title = nestedChapter.Title, + Page = mappings[key], + Part = nestedChapter.Link.Anchor ?? string.Empty, + Children = new List() + }); + } + } + + CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings); + } + + if (chaptersList.Count != 0) return chaptersList; + // Generate from TOC + var tocPage = book.Content.Html.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC")); + if (tocPage == null) return chaptersList; + + // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content + var doc = new HtmlDocument(); + var content = await book.Content.Html[tocPage].ReadContentAsync(); + doc.LoadHtml(content); + var anchors = doc.DocumentNode.SelectNodes("//a"); + if (anchors == null) return chaptersList; + + foreach (var anchor in anchors) + { + if (!anchor.Attributes.Contains("href")) continue; + + var key = BookService.CleanContentKeys(anchor.Attributes["href"].Value).Split("#")[0]; + if (!mappings.ContainsKey(key)) + { + // Fallback to searching for key (bad epub metadata) + var correctedKey = book.Content.Html.Keys.SingleOrDefault(s => s.EndsWith(key)); + if (!string.IsNullOrEmpty(correctedKey)) + { + key = correctedKey; + } + } + + if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue; + var part = string.Empty; + if (anchor.Attributes["href"].Value.Contains('#')) + { + part = anchor.Attributes["href"].Value.Split("#")[1]; + } + chaptersList.Add(new BookChapterItem() + { + Title = anchor.InnerText, + Page = mappings[key], + Part = part, + Children = new List() + }); + } + + return chaptersList; + } + + /// + /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, + /// all css is scoped, etc. + /// + /// The requested page + /// The chapterId + /// The path to the cached epub file + /// The API base for Kavita, to rewrite urls to so we load though our endpoint + /// Full epub HTML Page, scoped to Kavita's reader + /// All exceptions throw this + public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) + { + using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); + var mappings = await CreateKeyToPageMappingAsync(book); + var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; + + var counter = 0; + var doc = new HtmlDocument {OptionFixNestedTags = true}; + + + var bookPages = await book.GetReadingOrderAsync(); + foreach (var contentFileRef in bookPages) + { + if (page != counter) + { + counter++; + continue; + } + + var content = await contentFileRef.ReadContentAsync(); + if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return content; + + // In more cases than not, due to this being XML not HTML, we need to escape the script tags. + content = BookService.EscapeTags(content); + + doc.LoadHtml(content); + var body = doc.DocumentNode.SelectSingleNode("//body"); + + if (body == null) + { + if (doc.ParseErrors.Any()) + { + LogBookErrors(book, contentFileRef, doc); + throw new KavitaException("The file is malformed! Cannot read."); + } + _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); + doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); + body = doc.DocumentNode.SelectSingleNode("/html/body"); + } + + return await ScopePage(doc, book, apiBase, body, mappings, page); + } + + throw new KavitaException("Could not find the appropriate html for that page"); + } + + private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList nestedChapters, IList chaptersList, + IReadOnlyDictionary mappings) + { + if (navigationItem.Link == null) + { + var item = new BookChapterItem() + { + Title = navigationItem.Title, + Children = nestedChapters + }; + if (nestedChapters.Count > 0) + { + item.Page = nestedChapters[0].Page; + } + + chaptersList.Add(item); + } + else + { + var groupKey = CleanContentKeys(navigationItem.Link.ContentFileName); + if (mappings.ContainsKey(groupKey)) + { + chaptersList.Add(new BookChapterItem() + { + Title = navigationItem.Title, + Page = mappings[groupKey], + Children = nestedChapters + }); + } + } + } + + /// /// Extracts the cover image to covers directory and returns file path back /// @@ -743,6 +926,12 @@ namespace API.Services return string.Empty; } + /// + /// Returns an image raster of a page within a PDF + /// + /// + /// + /// private static void GetPdfPage(IDocReader docReader, int pageNumber, Stream stream) { using var pageReader = docReader.GetPageReader(pageNumber); @@ -784,5 +973,14 @@ namespace API.Services return body; } + + private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc) + { + _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); + foreach (var error in doc.ParseErrors) + { + _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); + } + } } } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 41bbb4a92..9d2a85f95 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -165,6 +165,8 @@ public class StatsService : IStatsService LibraryId = s.LibraryId, Count = _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series).Count() }) + .AsNoTracking() + .AsSplitQuery() .MaxAsync(d => d.Count); } @@ -176,12 +178,16 @@ public class StatsService : IStatsService v.SeriesId, Count = _context.Series.Where(s => s.Id == v.SeriesId).SelectMany(s => s.Volumes).Count() }) + .AsNoTracking() + .AsSplitQuery() .MaxAsync(d => d.Count); } private Task MaxChaptersInASeries() { return _context.Series + .AsNoTracking() + .AsSplitQuery() .MaxAsync(s => s.Volumes .Where(v => v.Number == 0) .SelectMany(v => v.Chapters) diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index e86aff9c0..03b0666a6 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -1080,6 +1080,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else { this.canvasImage.src = this.getPageUrl(this.pageNum); } + this.canvasImage.onload = () => { + this.cdRef.markForCheck(); + }; + this.cdRef.markForCheck(); } @@ -1114,8 +1118,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - console.log('prevChapterId', this.prevChapterId); - if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) { this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.prevChapterId = chapterId; @@ -1127,7 +1129,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } loadChapter(chapterId: number, direction: 'Next' | 'Prev') { - console.log('chapterId: ', chapterId); if (chapterId > 0) { this.isLoading = true; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/nav/events-widget/events-widget.component.scss b/UI/Web/src/app/nav/events-widget/events-widget.component.scss index 95a7cc214..4f10fafe8 100644 --- a/UI/Web/src/app/nav/events-widget/events-widget.component.scss +++ b/UI/Web/src/app/nav/events-widget/events-widget.component.scss @@ -55,13 +55,6 @@ } -// .download { -// width: 80px; -// height: 80px; -// } - - - .btn-icon { color: white; } diff --git a/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.html b/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.html index 12467fa61..8c3686f83 100644 --- a/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.html +++ b/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.html @@ -1,4 +1,16 @@
+ + +
+ + Loading...PDFs may take longer than expected +
+
+
+
+
+
+
diff --git a/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.scss b/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.scss index a49ec4553..0cfec3f33 100644 --- a/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.scss +++ b/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.scss @@ -10,3 +10,11 @@ ::ng-deep #presentationMode { margin: 3px 0 4px !important; } + +.progress-container { + width: 100%; +} +.progress-bar { + // NOTE: We have to override due to theme variables not being available + background-color: #3B9E76; +} \ No newline at end of file diff --git a/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.ts b/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.ts index 349d70ee6..1646597de 100644 --- a/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.ts +++ b/UI/Web/src/app/pdf-reader/pdf-reader/pdf-reader.component.ts @@ -1,9 +1,10 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { PageViewModeType } from 'ngx-extended-pdf-viewer'; +import { NgxExtendedPdfViewerService, PageViewModeType, ProgressBarEvent } from 'ngx-extended-pdf-viewer'; import { ToastrService } from 'ngx-toastr'; import { Subject, take } from 'rxjs'; import { BookService } from 'src/app/book-reader/book.service'; +import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; @@ -62,7 +63,11 @@ export class PdfReaderComponent implements OnInit, OnDestroy { backgroundColor: string = this.themeMap[this.theme].background; fontColor: string = this.themeMap[this.theme].font; - isLoading: boolean = false; + isLoading: boolean = true; + /** + * How much of the current document is loaded + */ + loadPrecent: number = 0; /** * This can't be updated dynamically: @@ -76,12 +81,19 @@ export class PdfReaderComponent implements OnInit, OnDestroy { private seriesService: SeriesService, public readerService: ReaderService, private navService: NavService, private toastr: ToastrService, private bookService: BookService, private themeService: ThemeService, - private readonly cdRef: ChangeDetectorRef) { + private readonly cdRef: ChangeDetectorRef, private pdfViewerService: NgxExtendedPdfViewerService) { this.navService.hideNavBar(); this.themeService.clearThemes(); this.navService.hideSideNav(); } + @HostListener('window:keyup', ['$event']) + handleKeyPress(event: KeyboardEvent) { + if (event.key === KEY_CODES.ESC_KEY) { + this.closeReader(); + } + } + ngOnDestroy(): void { this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => { this.themeService.setTheme(theme.name); @@ -141,13 +153,12 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.seriesService.getChapter(this.chapterId).subscribe(chapter => { this.maxPages = chapter.pages; - this.cdRef.markForCheck(); if (this.currentPage >= this.maxPages) { this.currentPage = this.maxPages - 1; this.saveProgress(); - this.cdRef.markForCheck(); } + this.cdRef.markForCheck(); }); } @@ -193,4 +204,14 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.readerService.closeReader(this.readingListMode, this.readingListId); } + updateLoading(state: boolean) { + this.isLoading = state; + this.cdRef.markForCheck(); + } + + updateLoadProgress(event: ProgressBarEvent) { + this.loadPrecent = event.percent; + this.cdRef.markForCheck(); + } + } diff --git a/UI/Web/src/theme/themes/light.scss b/UI/Web/src/theme/themes/light.scss index 6d91d80cf..573994334 100644 --- a/UI/Web/src/theme/themes/light.scss +++ b/UI/Web/src/theme/themes/light.scss @@ -1,203 +1,4 @@ -/* Theme for the light mode of Kavita */ +/* Default styles for Kavita */ :root { - --color-scheme: light; - --primary-color: #4ac694; - --primary-color-dark-shade: #3B9E76; - --primary-color-darker-shade: #338A67; - --primary-color-darkest-shade: #25624A; - --error-color: #ff4136; - --bs-body-bg: #fff; - --body-text-color: #333; - --btn-icon-filter: none; - - /* Navbar */ - --navbar-bg-color: black; - --navbar-text-color: white; - --navbar-fa-icon-color: white; - --navbar-btn-hover-outline-color: rgba(255, 255, 255, 1); - - /* Inputs */ - --input-bg-color: #fff; - --input-focused-border-color: #ccc; - --input-bg-readonly-color: rgba(0,0,0,0.2); - --input-placeholder-color: #aeaeae; - --input-border-color: #ccc; - --input-range-color: var(--primary-color); - --input-range-active-color: var(--primary-color-darker-shade); - - /* Buttons */ - --btn-primary-text-color: black; - --btn-primary-bg-color: white; - --btn-primary-border-color: black; - --btn-primary-hover-text-color: white; - --btn-primary-hover-bg-color: black; - --btn-primary-hover-border-color: black; - --btn-alt-bg-color: #424c72; - --btn-alt-border-color: #444f75; - --btn-alt-hover-bg-color: #3b4466; - --btn-alt-focus-bg-color: #343c59; - --btn-alt-focus-boxshadow-color: rgb(68 79 117 / 50%); - --btn-fa-icon-color: black; - --btn-disabled-bg-color: #020202; - --btn-disabled-text-color: white; - --btn-disabled-border-color: #6c757d; - - /* Nav */ - --nav-link-active-text-color: white; - --nav-link-bg-color: rgba(74, 198, 148, 0.9); - --nav-tab-active-text-color: white; - --nav-tab-text-color: var(--body-text-color); - --nav-tab-bg-color: rgba(74, 198, 148, 0.9); - --nav-tab-hover-border-color: #4ac694; - --nav-link-text-color: black; - --nav-link-hover-text-color: var(--primary-color); - --nav-tab-border-hover-color: transparent; - - /* Side Nav */ - --side-nav-bg-color: rgba(255,255,255,0.6); - --side-nav-mobile-bg-color: rgb(255,255,255); - --side-nav-openclose-transition: 0.15s ease-in-out; - --side-nav-box-shadow: none; - --side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%); - --side-nav-hover-text-color: white; - --side-nav-hover-bg-color: black; - --side-nav-color: black; - --side-nav-border-radius: 5px; - --side-nav-border: 1px solid rgba(0,0,0,0.2); - --side-nav-border-closed: 1px solid transparent; - --side-nav-companion-bar-transistion: 0.5s linear; - --side-nav-border-transition: 0.5s ease-in-out; - --side-nav-bg-color-transition: 0.5s ease-in-out; - --side-nav-closed-bg-color: transparent; - --side-nav-item-active-color: var(--primary-color); - --side-nav-active-bg-color: rgba(0,0,0,0.5); - --side-nav-item-active-text-color: white; - --side-nav-overlay-color: rgba(0,0,0,0.5); - - - /* Checkboxes */ - --checkbox-checked-bg-color: var(--primary-color); - --checkbox-bg-color: white; - --checkbox-border-color: var(--primary-color); - --checkbox-focus-border-color: var(--input-border-color); - - /* Tagbadge */ - --tagbadge-bg-color: #c9c9c9; - - /* Toasts */ - --toast-success-bg-color: rgba(74, 198, 148, 0.9); - --toast-error-bg-color: #BD362F; - --toast-info-bg-color: #2F96B4; - --toast-warning-bg-color: #F89406; - - /* Rating star */ - --ratingstar-star-empty: #b0c4de; - --ratingstar-star-filled: var(--primary-color); - - /* Global */ - --accent-bg-color: rgba(206, 206, 206, 0.5); // Drawer had: var(--bs-body-bg) - --accent-text-color: grey; - --accent-text-size: 0.8rem; - --hr-color: rgba(239, 239, 239, 0.125); - --grid-breakpoints-xs: $grid-breakpoint-xs; - --grid-breakpoints-sm: $grid-breakpoint-sm; - --grid-breakpoints-md: $grid-breakpoint-md; - --grid-breakpoints-lg: $grid-breakpoint-lg; - --grid-breakpoints-xl: $grid-breakpoint-xl; - --body-font-family: "EBGaramond", "Helvetica Neue", sans-serif; - --brand-font-family: "Spartan", sans-serif; - --text-muted-color: #aaa; - - /* Breadcrumb */ - --breadcrumb-bg-color: #eaeaea; - --breadcrumb-item-text-color: var(--body-text-color); - - /* Card */ - --card-text-color: #000; - --card-border-width: 0 1px 1px 1px; - --card-border-style: solid; - --card-border-color: #ccc; - --card-progress-bar-color: var(--primary-color); - --card-overlay-bg-color: rgba(0, 0, 0, 0); - --card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2); - - /* List items */ - --list-group-item-text-color: var(--body-text-color); - --list-group-item-bg-color: white; - --list-group-hover-text-color: var(--body-text-color); - --list-group-hover-bg-color: #eaeaea; - --list-group-item-border-color: rgba(239, 239, 239, 0.125); - --list-group-active-border-color: none; - - /* Dropdown */ - --dropdown-item-hover-text-color: white; - --dropdown-item-hover-bg-color: var(--primary-color); - --dropdown-overlay-color: rgba(0,0,0,0.5); - --dropdown-item-bg-color: white; - - /* Manga Reader */ - --manga-reader-overlay-filter: blur(10px); - --manga-reader-overlay-bg-color: rgba(0,0,0,0.5); - --manga-reader-overlay-text-color: white; - --manga-reader-bg-color: black; - --manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5); - --manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5); - - /* Radios */ - --radio-accent-color: var(--primary-color); - --radio-hover-accent-color: var(--primary-color-dark-shade); - - /* Carousel */ - --carousel-header-text-color: black; - --carousel-header-text-decoration: none; - --carousel-hover-header-text-decoration: underline; - - /** Drawer */ - --drawer-background-color: white; // TODO: Use bg - --drawer-bg-color: white; - --drawer-text-color: black; - - /* Pagination */ - --pagination-active-link-border-color: var(--primary-color); - --pagination-active-link-bg-color: var(--primary-color); - --pagination-active-link-text-color: white; - --pagination-link-border-color: rgba(239, 239, 239, 1); - --pagination-link-text-color: black; - --pagination-link-bg-color: white; - --pagination-focus-border-color: var(--primary-color); - --pagination-link-hover-color: var(--primary-color); - - /** Event Widget */ - --event-widget-bg-color: white; - --event-widget-item-bg-color: lightgrey; - --event-widget-text-color: black; - --event-widget-item-border-color: lightgrey; - --event-widget-border-color: lightgrey; - - /* Popover */ - --popover-body-bg-color: var(--navbar-bg-color); - --popover-body-text-color: var(--navbar-text-color); - --popover-outerarrow-color: lightgrey; - --popover-arrow-color: lightgrey; - --popover-bg-color: lightgrey; - --popover-border-color: lightgrey; - - /* Accordion */ - --accordion-header-text-color: rgba(74, 198, 148, 0.9); - --accordion-header-bg-color: var(--bs-body-bg); - --accordion-body-bg-color: var(--bs-body-bg); - --accordion-active-body-bg-color: var(--bs-body-bg); - --accordion-body-border-color: rgba(239, 239, 239, 0.125); - --accordion-body-text-color: var(--body-text-color); - --accordion-header-collapsed-text-color: var(--body-text-color); - --accordion-header-collapsed-bg-color: var(--bs-body-bg); - --accordion-button-focus-border-color: rgba(74, 198, 148, 0.9); - --accordion-button-focus-box-shadow: unset; - - /* Search */ - --search-result-text-lite-color: rgba(0,0,0,1); - - /* Bulk Selection */ - --bulk-selection-text-color: var(--navbar-text-color); - --bulk-selection-highlight-text-color: var(--primary-color); - } + @import './dark.scss'; // Just re-import variables from dark since that's all we support +}