Misc Bugfixes (#1378)

* Fixed an issue where sometimes when loading the next page, the pagination area wouldn't be properly setup due to a missed rendering cycle

* Refactored BookController to thin it out and refactor some of the functions to apply IOC. Added some split query statements on a few queries.

* Added Split Query to many queries

* Added a visual indicator for loading state of PDF. Will spruce up css later.

* Added back in logic

* Fixed flash of white when refreshing browser

* Hooked in a loading progress bar for the pdf reader

* Close the pdf reader when pressing ESC
This commit is contained in:
Joseph Milazzo 2022-07-17 10:19:36 -04:00 committed by GitHub
parent 3a10b54422
commit c650436f57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 315 additions and 389 deletions

View File

@ -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<BookController> _logger;
private readonly IBookService _bookService;
private readonly IUnitOfWork _unitOfWork;
private readonly ICacheService _cacheService;
private const string BookApiUrl = "book-resources?file=";
public BookController(ILogger<BookController> 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<ActionResult> 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<ActionResult<ICollection<BookChapterItem>>> 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<BookChapterItem>();
foreach (var navigationItem in navItems)
try
{
if (navigationItem.NestedItems.Count > 0)
{
var nestedChapters = new List<BookChapterItem>();
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<BookChapterItem>()
});
}
}
CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings);
}
if (navigationItem.NestedItems.Count == 0)
{
CreateToCChapter(navigationItem, Array.Empty<BookChapterItem>(), 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<BookChapterItem>()
});
}
}
}
}
return Ok(chaptersList);
}
private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList<BookChapterItem> nestedChapters, IList<BookChapterItem> chaptersList,
IReadOnlyDictionary<string, int> 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);
}
}
/// <summary>
/// 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></body>"));
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);
}
}
}
}

View File

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

View File

@ -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<GenreTagDto>(_mapper.ConfigurationProvider)

View File

@ -169,6 +169,7 @@ public class LibraryRepository : ILibraryRepository
.Include(f => f.Folders)
.OrderBy(l => l.Name)
.ProjectTo<LibraryDto>(_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<LibraryDto>(_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);
}
}

View File

@ -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<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}

View File

@ -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<ReadingListDto>(_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();

View File

@ -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<int> chapterIds = new List<int>();
@ -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<int, IList<int>>();
@ -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));
}

View File

@ -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()

View File

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

View File

@ -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<VolumeDto>(_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);
}
}
}

View File

@ -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<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page);
Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter);
Task<string> 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);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="chapter">Chapter with at least one file</param>
/// <returns></returns>
public async Task<ICollection<BookChapterItem>> 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<BookChapterItem>();
foreach (var navigationItem in navItems)
{
if (navigationItem.NestedItems.Count == 0)
{
CreateToCChapter(navigationItem, Array.Empty<BookChapterItem>(), chaptersList, mappings);
continue;
}
var nestedChapters = new List<BookChapterItem>();
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<BookChapterItem>()
});
}
}
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<BookChapterItem>()
});
}
return chaptersList;
}
/// <summary>
/// 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.
/// </summary>
/// <param name="page">The requested page</param>
/// <param name="chapterId">The chapterId</param>
/// <param name="cachedEpubPath">The path to the cached epub file</param>
/// <param name="baseUrl">The API base for Kavita, to rewrite urls to so we load though our endpoint</param>
/// <returns>Full epub HTML Page, scoped to Kavita's reader</returns>
/// <exception cref="KavitaException">All exceptions throw this</exception>
public async Task<string> 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></body>"));
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<BookChapterItem> nestedChapters, IList<BookChapterItem> chaptersList,
IReadOnlyDictionary<string, int> 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
});
}
}
}
/// <summary>
/// Extracts the cover image to covers directory and returns file path back
/// </summary>
@ -743,6 +926,12 @@ namespace API.Services
return string.Empty;
}
/// <summary>
/// Returns an image raster of a page within a PDF
/// </summary>
/// <param name="docReader"></param>
/// <param name="pageNumber"></param>
/// <param name="stream"></param>
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);
}
}
}
}

View File

@ -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<int> MaxChaptersInASeries()
{
return _context.Series
.AsNoTracking()
.AsSplitQuery()
.MaxAsync(s => s.Volumes
.Where(v => v.Number == 0)
.SelectMany(v => v.Chapters)

View File

@ -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();

View File

@ -55,13 +55,6 @@
}
// .download {
// width: 80px;
// height: 80px;
// }
.btn-icon {
color: white;
}

View File

@ -1,4 +1,16 @@
<div class="{{theme}}">
<ng-container *ngIf="isLoading">
<div class="loading mx-auto" style="min-width: 200px; width: 600px;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...PDFs may take longer than expected
</div>
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': loadPrecent + '%'}" [attr.aria-valuenow]="loadPrecent" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</ng-container>
<ngx-extended-pdf-viewer
#pdfViewer
[src]="readerService.downloadPdf(this.chapterId)"
@ -23,6 +35,9 @@
[customToolbar]="multiToolbar"
(pageChange)="saveProgress()"
(pdfLoadingStarts)="updateLoading(true)"
(pdfLoaded)="updateLoading(false)"
(progress)="updateLoadProgress($event)"
>
</ngx-extended-pdf-viewer>

View File

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

View File

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

View File

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