Infinite Scroll + List View + Cover Upload Redesign (#1319)

* Started with the redesign of the cover image chooser redesign to be less click intensive for volume/chapter images.

Made some headings bold in card detail drawer.

* Tweaked the styles

* Moved where the info cards show

* Added an ability to open a page settings drawer

* Cleaned up some old code that isn't needed anymore.

* Started implementing a list view. Refactored some title code to a dedicated component

* List view implemented but way too many API calls. Either need caching or adjusting the SeriesDetail api.

* Fixed a bug where if the progress bar didn't render on a card item while a download was in progress, the download indicator would be removed.

* Large refactor to move a lot of the needed fields to the chapter and volume dtos for series detail. All fields are noted when only used in series detail.

* Implemented cards for other tabs (except related)

* Fixed the unit test which needed a mocked reader service call.

* More cleanup around age rating and removing old code from the refactor. Commented out sorting till i feel motivated to work on that.

* Some cleanup and restored cards as initial layout. Time to test this out and see if there is value add.

* Added ability for Chapters tab to show the volume chapters belong to (if applicable)

* Adding style fixes

* Cover image updates, don't allow the first image (which is what is currently set) to respond to cover changes.

Hide the ID field on list item for series detail.

* Refactored the title for list item to be injectable

* Cleaned up the selection code to make it less finicky on mobile when tap scrolling.

* Refactored chapter tab to show volume as well on list view.

* Ensure word count shows for Volumes

* Started adding virtual scrolling, pushing up so Robbie can mess around

* Started adding virtual scrolling, pushing up so Robbie can mess around

* Fixed a bug where all chapters would come under specials

* Show title data as accent if set.

* Style fixes for virtual scroller

* Restyling scroll

* Implemented a way to show storyline with virtual scrolling

* Show Word Count for chapters and cleaned up some logics.

* I might have card layout working with virtual scroll code.

* Some cleanup to hide more system like properties from info bar on series detail page. Fixed some missing time estimate info on storyline chapters.

* Fixed a regression on series service when I integrated VolumeTitle.

* Refactored read time to the backend. Added WordCount to the volume itself so we don't need to calculate on frontend. When asking to analyze files from a series, force the calculation.

* Fixed SeriesDetail api code

* Fixed up the code in the drawer to better update list/card mode

* Basic infinite scroll implemented, however due to how we are updating the list to render, we are re-rending cards that haven't been touched.

* Updated how we render and layout data for infinite scroll on library detail. It's almost there.

* Started laying foundation for loading pages backwards.

Removed lazy loading of images since we are now using virtual paging.

* Hooked in some basic code to allow user to load a prev page with infinite scroll.

* Fixed up series detail api and undid the non-lazy loaded images.

Changed the router to help with this infinite loading on Firefox issue.

* Fixed up some naming issues with Series Detail and added a new test.

* This is an infinite scroll without pagination implementation. It is not fully done, but off to a good start. Virtual scroller with jump bar is working pretty well, def needs more polishing and tweaking. There are hacks in this implementation that need to be revisited.

* Refactored code so that we don't use any pagination and load all results by default.

* Misc code cleanup from build warnings.

* Cleaned up some logic for how to display titles in list view.

* More title cleanup for specials

* Hooked up page layout to user preferences and renamed an existing user pref name to match the dto.

* Swapped out everything but storyline with virtual-scroller over CDK

* Removed CDK from series detail.

* Default value for migration on page layout

* Updating card layout for library detail page

* fixing height for mobile

* Moved scrollbar

* Tweaked some styling for layouts when there is no data

* Refactored the series cards into their own component to make it re-usable.

* More tweaks on series info cards layout and enhanced a few pages with trackby functions.

* Removed some dead code

* Added download on series detail to actionables to fit in with new scroll strategy.

* Fixed language not being updated and sent to the backend for series update.

* Fixed a bad migration (if you ran any prior migration in this branch, you need to undo before you use this commit)

* Adding sticky tabs

* fixed mobile gap on sticky tab

* Enhanced the card title for books to show number up front.

* Adjusted the gutters on admin dashboard

* Removed debug code

* Removing duplicate book title

* Cleaned up old references to cdk scroller

* Implemented a basic jump bar scaling algorithm. Not perfect, but works pretty well.

* Code smells

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-06-13 16:37:49 -05:00 committed by GitHub
parent f0f0e23e88
commit bbc48a5f5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 7863 additions and 2097 deletions

View file

@ -12,6 +12,7 @@ using API.DTOs.Account;
using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Errors;
using API.Extensions;
using API.Services;
@ -652,22 +653,13 @@ namespace API.Controllers
try
{
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
//if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
user.Email = dto.Email;
if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration");
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
//var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email);
// _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink);
// // Always send an email, even if the user can't click it just to get them conformable with the system
// await _emailService.SendMigrationEmail(new EmailMigrationDto()
// {
// EmailAddress = dto.Email,
// Username = user.UserName,
// ServerConfirmationLink = emailLink
// });
return Ok();
}
catch (Exception ex)

View file

@ -181,7 +181,7 @@ namespace API.Controllers
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId);
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
return Ok();
}

View file

@ -83,7 +83,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
@ -104,7 +104,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(_unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues<PublicationStatus>().Select(t => new PublicationStatusDto()
@ -125,7 +125,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
var englishTag = CultureInfo.GetCultureInfo("en");

View file

@ -604,6 +604,7 @@ public class OpdsController : BaseApiController
/// <summary>
/// Downloads a file
/// </summary>
/// <param name="apiKey">User's API Key</param>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="chapterId"></param>

View file

@ -628,32 +628,6 @@ namespace API.Controllers
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
}
/// <summary>
/// Given word count, page count, and if the entity is an epub file, this will return the read time.
/// </summary>
/// <param name="wordCount"></param>
/// <param name="pageCount"></param>
/// <param name="isEpub"></param>
/// <returns>Will always assume no progress as it's not privy</returns>
[HttpGet("manual-read-time")]
public ActionResult<HourEstimateRangeDto> GetManualReadTime(int wordCount, int pageCount, bool isEpub)
{
return Ok(_readerService.GetTimeEstimate(wordCount, pageCount, isEpub));
}
[HttpGet("read-time")]
public async Task<ActionResult<HourEstimateRangeDto>> GetReadTime(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList();
return Ok(_readerService.GetTimeEstimate(series.WordCount, series.Pages, series.Format == MangaFormat.Epub,
progress.Any()));
}
/// <summary>
/// For the current user, returns an estimate on how long it would take to finish reading the series.
/// </summary>
@ -675,12 +649,12 @@ namespace API.Controllers
// Word count
var progressCount = chapters.Sum(c => c.WordCount);
var wordsLeft = series.WordCount - progressCount;
return _readerService.GetTimeEstimate(wordsLeft, 0, true, progressCount > 0);
return _readerService.GetTimeEstimate(wordsLeft, 0, true);
}
var progressPageCount = progress.Sum(p => p.PagesRead);
var pagesLeft = series.Pages - progressPageCount;
return _readerService.GetTimeEstimate(0, pagesLeft, false, progressPageCount > 0);
return _readerService.GetTimeEstimate(0, pagesLeft, false);
}
}

View file

@ -22,6 +22,7 @@ public class RecommendedController : BaseApiController
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("quick-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
@ -57,6 +58,7 @@ public class RecommendedController : BaseApiController
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("highly-rated")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
@ -74,6 +76,8 @@ public class RecommendedController : BaseApiController
/// Chooses a random genre and shows series that are in that without reading progress
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="genreId">Genre Id</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("more-in")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
@ -92,6 +96,7 @@ public class RecommendedController : BaseApiController
/// Series that are fully read by the user in no particular order
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("rediscover")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)

View file

@ -92,8 +92,9 @@ namespace API.Controllers
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
existingPreferences.PageLayoutMode = preferencesDto.BookReaderLayoutMode;
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
// TODO: Remove this code - this overrides layout mode to be single until the mode is released

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.DTOs
{
@ -9,7 +11,7 @@ namespace API.DTOs
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
/// file (abstracted from type).
/// </summary>
public class ChapterDto
public class ChapterDto : IHasReadTimeEstimate
{
public int Id { get; init; }
/// <summary>
@ -62,5 +64,30 @@ namespace API.DTOs
/// </summary>
/// <remarks>Metadata field</remarks>
public string TitleName { get; set; }
/// <summary>
/// Summary of the Chapter
/// </summary>
/// <remarks>This is not set normally, only for Series Detail</remarks>
public string Summary { get; init; }
/// <summary>
/// Age Rating for the issue/chapter
/// </summary>
public AgeRating AgeRating { get; init; }
/// <summary>
/// Total words in a Chapter (books only)
/// </summary>
public long WordCount { get; set; } = 0L;
/// <summary>
/// Formatted Volume title ie) Volume 2.
/// </summary>
/// <remarks>Only available when fetched from Series Detail API</remarks>
public string VolumeTitle { get; set; } = string.Empty;
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
}
}

View file

@ -3,22 +3,18 @@
/// <summary>
/// A range of time to read a selection (series, chapter, etc)
/// </summary>
public class HourEstimateRangeDto
public record HourEstimateRangeDto
{
/// <summary>
/// Min hours to read the selection
/// </summary>
public int MinHours { get; set; } = 1;
public int MinHours { get; init; } = 1;
/// <summary>
/// Max hours to read the selection
/// </summary>
public int MaxHours { get; set; } = 1;
public int MaxHours { get; init; } = 1;
/// <summary>
/// Estimated average hours to read the selection
/// </summary>
public int AvgHours { get; set; } = 1;
/// <summary>
/// Does the user have progress on the range this represents
/// </summary>
public bool HasProgress { get; set; } = false;
public int AvgHours { get; init; } = 1;
}

View file

@ -1,9 +1,10 @@
using System;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.DTOs
{
public class SeriesDto
public class SeriesDto : IHasReadTimeEstimate
{
public int Id { get; init; }
public string Name { get; init; }
@ -47,5 +48,11 @@ namespace API.DTOs
public int LibraryId { get; set; }
public string LibraryName { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
}
}

View file

@ -7,7 +7,7 @@
/// </summary>
public int Id { get; set; }
/// <summary>
/// Url of the file to download from (can be null)
/// Base Url encoding of the file to upload from (can be null)
/// </summary>
public string Url { get; set; }
}

View file

@ -1,6 +1,7 @@
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
namespace API.DTOs
{
@ -82,5 +83,10 @@ namespace API.DTOs
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BookReaderImmersiveMode { get; set; } = false;
/// <summary>
/// Global Site Option: If the UI should layout items as Cards or List items
/// </summary>
/// <remarks>Defaults to Cards</remarks>
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
}
}

View file

@ -1,10 +1,11 @@

using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
namespace API.DTOs
{
public class VolumeDto
public class VolumeDto : IHasReadTimeEstimate
{
public int Id { get; set; }
public int Number { get; set; }
@ -15,5 +16,11 @@ namespace API.DTOs
public DateTime Created { get; set; }
public int SeriesId { get; set; }
public ICollection<ChapterDto> Chapters { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
}
}

View file

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Entities;
using API.Entities.Enums.UserPreferences;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using Microsoft.AspNetCore.Identity;
@ -78,10 +79,14 @@ namespace API.Data
builder.Entity<AppUserPreferences>()
.Property(b => b.BackgroundColor)
.HasDefaultValue("#000000");
builder.Entity<AppUserPreferences>()
.Property(b => b.GlobalPageLayoutMode)
.HasDefaultValue(PageLayoutMode.Cards);
}
static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
private static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
{
@ -91,7 +96,7 @@ namespace API.Data
}
static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
{
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
entity.LastModified = DateTime.Now;

View file

@ -19,6 +19,9 @@ public static class MigrateBookmarks
/// </summary>
/// <remarks>Bookmark directory is configurable. This will always use the default bookmark directory.</remarks>
/// <param name="directoryService"></param>
/// <param name="unitOfWork"></param>
/// <param name="logger"></param>
/// <param name="cacheService"></param>
/// <returns></returns>
public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork,
ILogger<Program> logger, ICacheService cacheService)

View file

@ -148,7 +148,7 @@ namespace API.Data
var volumes = await context.Volume.Include(v => v.Chapters).ToListAsync();
foreach (var volume in volumes)
{
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting).FirstOrDefault();
var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting);
if (firstChapter == null) continue;
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
$"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png")))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class TimeEstimateInDB : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AvgHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MinHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<long>(
name: "WordCount",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<int>(
name: "AvgHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MinHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "AvgHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MinHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AvgHoursToRead",
table: "Volume");
migrationBuilder.DropColumn(
name: "MaxHoursToRead",
table: "Volume");
migrationBuilder.DropColumn(
name: "MinHoursToRead",
table: "Volume");
migrationBuilder.DropColumn(
name: "WordCount",
table: "Volume");
migrationBuilder.DropColumn(
name: "AvgHoursToRead",
table: "Series");
migrationBuilder.DropColumn(
name: "MaxHoursToRead",
table: "Series");
migrationBuilder.DropColumn(
name: "MinHoursToRead",
table: "Series");
migrationBuilder.DropColumn(
name: "AvgHoursToRead",
table: "Chapter");
migrationBuilder.DropColumn(
name: "MaxHoursToRead",
table: "Chapter");
migrationBuilder.DropColumn(
name: "MinHoursToRead",
table: "Chapter");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class RenamedBookReaderLayoutMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "PageLayoutMode",
table: "AppUserPreferences",
newName: "BookReaderLayoutMode");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "BookReaderLayoutMode",
table: "AppUserPreferences",
newName: "PageLayoutMode");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class GlobalPageLayoutModeUserSetting : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "GlobalPageLayoutMode",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlobalPageLayoutMode",
table: "AppUserPreferences");
}
}
}

View file

@ -179,6 +179,9 @@ namespace API.Data.Migrations
b.Property<bool>("BookReaderImmersiveMode")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
@ -196,10 +199,12 @@ namespace API.Data.Migrations
.HasColumnType("TEXT")
.HasDefaultValue("Dark");
b.Property<int>("LayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("GlobalPageLayoutMode")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<int>("PageLayoutMode")
b.Property<int>("LayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
@ -320,6 +325,9 @@ namespace API.Data.Migrations
b.Property<int>("AgeRating")
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("Count")
.HasColumnType("INTEGER");
@ -341,6 +349,12 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("Number")
.HasColumnType("TEXT");
@ -732,6 +746,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
@ -759,6 +776,12 @@ namespace API.Data.Migrations
b.Property<bool>("LocalizedNameLocked")
.HasColumnType("INTEGER");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -868,6 +891,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
@ -877,6 +903,12 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -889,6 +921,9 @@ namespace API.Data.Migrations
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<long>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");

View file

@ -1,14 +1,17 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.JumpBar;
using API.DTOs.Metadata;
using API.Entities;
using API.Entities.Enums;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Kavita.Common.Extensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
@ -41,6 +44,9 @@ public interface ILibraryRepository
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
Task<int> GetTotalFiles();
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
}
public class LibraryRepository : ILibraryRepository
@ -258,4 +264,54 @@ public class LibraryRepository : ILibraryRepository
}
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
{
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.AgeRating)
.Distinct()
.Select(s => new AgeRatingDto()
{
Value = s,
Title = s.ToDescription()
})
.ToListAsync();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
{
var ret = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.Language)
.AsNoTracking()
.Distinct()
.ToListAsync();
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
{
Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s
})
.OrderBy(s => s.Title)
.ToList();
}
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
{
return _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.PublicationStatus)
.Distinct()
.AsEnumerable()
.Select(s => new PublicationStatusDto()
{
Value = s,
Title = s.ToDescription()
})
.OrderBy(s => s.Title);
}
}

View file

@ -68,7 +68,8 @@ public interface ISeriesRepository
/// </summary>
/// <param name="libraryId"></param>
/// <param name="userId"></param>
/// <param name="userParams"></param>
/// <param name="userParams">Pagination info</param>
/// <param name="filter">Filtering/Sorting to apply</param>
/// <returns></returns>
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
/// <summary>
@ -107,9 +108,7 @@ public interface ISeriesRepository
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
Task<Chunk> GetChunkInfo(int libraryId = 0);
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
@ -922,54 +921,7 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync();
}
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
{
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.AgeRating)
.Distinct()
.Select(s => new AgeRatingDto()
{
Value = s,
Title = s.ToDescription()
})
.ToListAsync();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
{
var ret = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.Language)
.AsNoTracking()
.Distinct()
.ToListAsync();
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
{
Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s
})
.OrderBy(s => s.Title)
.ToList();
}
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
{
return _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.PublicationStatus)
.Distinct()
.AsEnumerable()
.Select(s => new PublicationStatusDto()
{
Value = s,
Title = s.ToDescription()
})
.OrderBy(s => s.Title);
}
/// <summary>
@ -978,6 +930,7 @@ public class SeriesRepository : ISeriesRepository
/// <remarks>This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping
/// in memory, we stop after 30 series. </remarks>
/// <param name="userId">Used to ensure user has access to libraries</param>
/// <param name="pageSize">How many entities to return</param>
/// <returns></returns>
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30)
{
@ -1234,7 +1187,7 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync();
}
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000)
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
{
var libraries = await _context.AppUser
.Where(u => u.Id == userId)

View file

@ -1,4 +1,5 @@
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
namespace API.Entities
{
@ -81,13 +82,17 @@ namespace API.Entities
/// 2 column is fit to height, 2 columns
/// </summary>
/// <remarks>Defaults to Default</remarks>
public BookPageLayoutMode PageLayoutMode { get; set; } = BookPageLayoutMode.Default;
public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default;
/// <summary>
/// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this.
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BookReaderImmersiveMode { get; set; } = false;
/// <summary>
/// Global Site Option: If the UI should layout items as Cards or List items
/// </summary>
/// <remarks>Defaults to Cards</remarks>
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
public AppUser AppUser { get; set; }
public int AppUserId { get; set; }

View file

@ -3,10 +3,11 @@ using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Parser;
using API.Services;
namespace API.Entities
{
public class Chapter : IEntityDate
public class Chapter : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
@ -24,7 +25,7 @@ namespace API.Entities
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
/// <summary>
/// Absolute path to the (managed) image file
/// Relative path to the (managed) image file representing the cover image
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string CoverImage { get; set; }
@ -73,9 +74,16 @@ namespace API.Entities
public int Count { get; set; } = 0;
/// <summary>
/// Total words in a Chapter (books only)
/// Total Word count of all chapters in this chapter.
/// </summary>
/// <remarks>Word Count is only available from EPUB files</remarks>
public long WordCount { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int AvgHoursToRead { get; set; }
/// <summary>

View file

@ -7,10 +7,6 @@
/// </summary>
Other = 1,
/// <summary>
/// Artist
/// </summary>
//Artist = 2,
/// <summary>
/// Author or Writer
/// </summary>
Writer = 3,

View file

@ -0,0 +1,11 @@
using System.ComponentModel;
namespace API.Entities.Enums.UserPreferences;
public enum PageLayoutMode
{
[Description("Cards")]
Cards = 0,
[Description("List")]
List = 1
}

View file

@ -0,0 +1,25 @@
using API.Services;
namespace API.Entities.Interfaces;
/// <summary>
/// Entity has read time estimate properties to estimate time to read
/// </summary>
public interface IHasReadTimeEstimate
{
/// <summary>
/// Min hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int MinHoursToRead { get; set; }
/// <summary>
/// Max hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int MaxHoursToRead { get; set; }
/// <summary>
/// Average hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int AvgHoursToRead { get; set; }
}

View file

@ -6,7 +6,7 @@ using API.Entities.Metadata;
namespace API.Entities;
public class Series : IEntityDate
public class Series : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
@ -66,10 +66,15 @@ public class Series : IEntityDate
public DateTime LastChapterAdded { get; set; }
/// <summary>
/// Total words in a Series (books only)
/// Total Word count of all chapters in this chapter.
/// </summary>
/// <remarks>Word Count is only available from EPUB files</remarks>
public long WordCount { get; set; }
public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; }
public SeriesMetadata Metadata { get; set; }
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
@ -87,5 +92,4 @@ public class Series : IEntityDate
public List<Volume> Volumes { get; set; }
public Library Library { get; set; }
public int LibraryId { get; set; }
}

View file

@ -1,11 +1,10 @@
using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace API.Entities
{
public class Volume : IEntityDate
public class Volume : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
@ -25,12 +24,23 @@ namespace API.Entities
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string CoverImage { get; set; }
/// <summary>
/// Total pages of all chapters in this volume
/// </summary>
public int Pages { get; set; }
/// <summary>
/// Total Word count of all chapters in this volume.
/// </summary>
/// <remarks>Word Count is only available from EPUB files</remarks>
public long WordCount { get; set; }
public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; }
// Relationships
public Series Series { get; set; }
public int SeriesId { get; set; }
}
}

View file

@ -113,7 +113,7 @@ namespace API.Helpers
opt.MapFrom(src => src.BookThemeName))
.ForMember(dest => dest.BookReaderLayoutMode,
opt =>
opt.MapFrom(src => src.PageLayoutMode));
opt.MapFrom(src => src.BookReaderLayoutMode));
CreateMap<AppUserBookmark, BookmarkDto>();

View file

@ -32,6 +32,7 @@ public class CacheHelper : ICacheHelper
/// <remarks>If a cover image is locked but the underlying file has been deleted, this will allow regenerating. </remarks>
/// <param name="coverPath">This should just be the filename, no path information</param>
/// <param name="firstFile"></param>
/// <param name="chapterCreated">When the chapter was created (Not Used)</param>
/// <param name="forceUpdate">If the user has told us to force the refresh</param>
/// <param name="isCoverLocked">If cover has been locked by user. This will force false</param>
/// <returns></returns>

View file

@ -2,14 +2,17 @@
{
public class UserParams
{
private const int MaxPageSize = 50;
public int PageNumber { get; set; } = 1;
private int _pageSize = 30;
private const int MaxPageSize = int.MaxValue;
public int PageNumber { get; init; } = 1;
private readonly int _pageSize = 30;
/// <summary>
/// If set to 0, will set as MaxInt
/// </summary>
public int PageSize
{
get => _pageSize;
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
init => _pageSize = (value == 0) ? MaxPageSize : value;
}
}
}
}

View file

@ -573,15 +573,13 @@ namespace API.Parser
foreach (var regex in MangaEditionRegex)
{
var matches = regex.Matches(filePath);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Edition"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Edition"].Success && match.Groups["Edition"].Value != string.Empty)
{
var edition = match.Groups["Edition"].Value.Replace("{", "").Replace("}", "")
.Replace("[", "").Replace("]", "").Replace("(", "").Replace(")", "");
return edition;
}
return group.Value
.Replace("{", "").Replace("}", "")
.Replace("[", "").Replace("]", "")
.Replace("(", "").Replace(")", "");
}
}
@ -596,15 +594,8 @@ namespace API.Parser
public static bool HasSpecialMarker(string filePath)
{
var matches = SpecialMarkerRegex.Matches(filePath);
foreach (Match match in matches)
{
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
{
return true;
}
}
return false;
return matches.Select(match => match.Groups["Special"])
.Any(group => group.Success && group != Match.Empty);
}
public static string ParseMangaSpecial(string filePath)
@ -612,12 +603,10 @@ namespace API.Parser
foreach (var regex in MangaSpecialRegex)
{
var matches = regex.Matches(filePath);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Special"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
{
return match.Groups["Special"].Value;
}
return group.Value;
}
}
@ -629,12 +618,10 @@ namespace API.Parser
foreach (var regex in ComicSpecialRegex)
{
var matches = regex.Matches(filePath);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Special"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
{
return match.Groups["Special"].Value;
}
return group.Value;
}
}
@ -646,12 +633,10 @@ namespace API.Parser
foreach (var regex in MangaSeriesRegex)
{
var matches = regex.Matches(filename);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Series"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
{
return CleanTitle(match.Groups["Series"].Value);
}
return CleanTitle(group.Value);
}
}
@ -662,12 +647,10 @@ namespace API.Parser
foreach (var regex in ComicSeriesRegex)
{
var matches = regex.Matches(filename);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Series"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
{
return CleanTitle(match.Groups["Series"].Value, true);
}
return CleanTitle(group.Value, true);
}
}
@ -697,12 +680,12 @@ namespace API.Parser
foreach (var regex in ComicVolumeRegex)
{
var matches = regex.Matches(filename);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups))
{
if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue;
if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue;
var value = match.Groups["Volume"].Value;
var hasPart = match.Groups["Part"].Success;
var value = group["Volume"].Value;
var hasPart = group["Part"].Success;
return FormatValue(value, hasPart);
}
}
@ -808,12 +791,9 @@ namespace API.Parser
foreach (var regex in MangaSpecialRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
foreach (var match in matches.Where(m => m.Success))
{
if (match.Success)
{
title = title.Replace(match.Value, string.Empty).Trim();
}
title = title.Replace(match.Value, string.Empty).Trim();
}
}
@ -825,12 +805,9 @@ namespace API.Parser
foreach (var regex in EuropeanComicRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
foreach (var match in matches.Where(m => m.Success))
{
if (match.Success)
{
title = title.Replace(match.Value, string.Empty).Trim();
}
title = title.Replace(match.Value, string.Empty).Trim();
}
}
@ -842,12 +819,9 @@ namespace API.Parser
foreach (var regex in ComicSpecialRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
foreach (var match in matches.Where(m => m.Success))
{
if (match.Success)
{
title = title.Replace(match.Value, string.Empty).Trim();
}
title = title.Replace(match.Value, string.Empty).Trim();
}
}
@ -905,12 +879,9 @@ namespace API.Parser
foreach (var regex in ReleaseGroupRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
foreach (var match in matches.Where(m => m.Success))
{
if (match.Success)
{
title = title.Replace(match.Value, string.Empty);
}
title = title.Replace(match.Value, string.Empty);
}
}

View file

@ -249,7 +249,7 @@ namespace API.Services
/// <summary>
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly
/// under extract path and not nested in subfolders. See <see cref="DirectoryInfoExtensions"/> Flatten method.
/// under extract path and not nested in subfolders. See <see cref="DirectoryService"/> Flatten method.
/// </summary>
/// <param name="archive">An opened archive stream</param>
/// <returns></returns>

View file

@ -32,7 +32,7 @@ namespace API.Services
string GetCachedEpubFile(int chapterId, Chapter chapter);
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
void CleanupBookmarkCache(int bookmarkDtoSeriesId);
void CleanupBookmarkCache(int seriesId);
}
public class CacheService : ICacheService
{

View file

@ -724,7 +724,7 @@ namespace API.Services
FileSystem.Path.Join(directoryName, "test.txt"),
string.Empty);
}
catch (Exception ex)
catch (Exception)
{
ClearAndDeleteDirectory(directoryName);
return false;

View file

@ -50,7 +50,7 @@ public class ImageService : IImageService
_directoryService = directoryService;
}
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount)
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1)
{
_directoryService.ExistOrCreate(targetDirectory);
if (fileCount == 1)

View file

@ -35,7 +35,7 @@ public interface IMetadataService
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true);
}
public class MetadataService : IMetadataService

View file

@ -29,7 +29,7 @@ public interface IReaderService
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub, bool hasProgress = false);
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub);
}
public class ReaderService : IReaderService
@ -330,7 +330,7 @@ public class ReaderService : IReaderService
{
var chapterVolume = volumes.FirstOrDefault();
if (chapterVolume?.Number != 0) return -1;
var firstChapter = chapterVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
var firstChapter = chapterVolume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
if (firstChapter == null) return -1;
return firstChapter.Id;
}
@ -372,17 +372,16 @@ public class ReaderService : IReaderService
if (volume.Number == currentVolume.Number - 1)
{
if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
var lastChapter = volume.Chapters
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
if (lastChapter == null) return -1;
return lastChapter.Id;
}
}
var lastVolume = volumes.OrderBy(v => v.Number).LastOrDefault();
var lastVolume = volumes.MaxBy(v => v.Number);
if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1)
{
var lastChapter = lastVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
var lastChapter = lastVolume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
if (lastChapter == null) return -1;
return lastChapter.Id;
}
@ -406,7 +405,7 @@ public class ReaderService : IReaderService
if (progress.Count == 0)
{
// I think i need a way to sort volumes last
return volumes.OrderBy(v => double.Parse(v.Number + ""), _chapterSortComparer).First().Chapters
return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
.OrderBy(c => float.Parse(c.Number)).First();
}
@ -499,41 +498,38 @@ public class ReaderService : IReaderService
}
}
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub, bool hasProgress = false)
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub)
{
if (isEpub)
{
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 1);
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 1);
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
if (maxHours < minHours)
{
return new HourEstimateRangeDto
{
MinHours = maxHours,
MaxHours = minHours,
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)),
HasProgress = hasProgress
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
};
}
return new HourEstimateRangeDto
{
MinHours = minHours,
MaxHours = maxHours,
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)),
HasProgress = hasProgress
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
};
}
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 1);
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 1);
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
if (maxHoursPages < minHoursPages)
{
return new HourEstimateRangeDto
{
MinHours = maxHoursPages,
MaxHours = minHoursPages,
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)),
HasProgress = hasProgress
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
};
}
@ -541,8 +537,7 @@ public class ReaderService : IReaderService
{
MinHours = minHoursPages,
MaxHours = maxHoursPages,
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)),
HasProgress = hasProgress
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
};
}
}

View file

@ -8,6 +8,7 @@ using API.Data;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
@ -458,7 +459,6 @@ public class SeriesService : ISeriesService
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
.OrderBy(v => Parser.Parser.MinNumberFromRange(v.Name))
.ToList();
var chapters = volumes.SelectMany(v => v.Chapters).ToList();
// For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number.
var processedVolumes = new List<VolumeDto>();
@ -479,8 +479,15 @@ public class SeriesService : ISeriesService
processedVolumes.ForEach(v => v.Name = $"Volume {v.Name}");
}
var specials = new List<ChapterDto>();
var chapters = volumes.SelectMany(v => v.Chapters.Select(c =>
{
if (v.Number == 0) return c;
c.VolumeTitle = v.Name;
return c;
})).ToList();
foreach (var chapter in chapters)
{
chapter.Title = FormatChapterTitle(chapter, libraryType);
@ -490,7 +497,6 @@ public class SeriesService : ISeriesService
specials.Add(chapter);
}
// Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes)
IEnumerable<ChapterDto> retChapters;
if (libraryType == LibraryType.Book)
@ -503,18 +509,17 @@ public class SeriesService : ISeriesService
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
}
var storylineChapters = volumes
.Where(v => v.Number == 0)
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
return new SeriesDetailDto()
{
Specials = specials,
Chapters = retChapters,
Volumes = processedVolumes,
StorylineChapters = volumes
.Where(v => v.Number == 0)
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())
StorylineChapters = storylineChapters
};
}

View file

@ -180,7 +180,7 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
}
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
{
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));

View file

@ -45,11 +45,6 @@ public class BackupService : IBackupService
_config = config;
_eventHub = eventHub;
// var maxRollingFiles = config.GetMaxRollingFiles();
// var loggingSection = config.GetLoggingFileName();
// var files = GetLogFiles(maxRollingFiles, loggingSection);
_backupFiles = new List<string>()
{
"appsettings.json",
@ -59,11 +54,6 @@ public class BackupService : IBackupService
"kavita.db-shm", // This wont always be there
"kavita.db-wal" // This wont always be there
};
// foreach (var file in files.Select(f => (_directoryService.FileSystem.FileInfo.FromFileName(f)).Name))
// {
// _backupFiles.Add(file);
// }
}
public IEnumerable<string> GetLogFiles(int maxRollingFiles, string logFileName)

View file

@ -32,14 +32,16 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly ICacheHelper _cacheHelper;
private readonly IReaderService _readerService;
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
ICacheHelper cacheHelper)
ICacheHelper cacheHelper, IReaderService readerService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_cacheHelper = cacheHelper;
_readerService = readerService;
}
@ -142,58 +144,78 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true)
{
if (series.Format != MangaFormat.Epub) return;
var isEpub = series.Format == MangaFormat.Epub;
long totalSum = 0;
foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters))
foreach (var volume in series.Volumes)
{
// This compares if it's changed since a file scan only
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false,
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
continue;
long sum = 0;
var fileCounter = 1;
foreach (var file in chapter.Files.Select(file => file.FilePath))
foreach (var chapter in volume.Chapters)
{
var pageCounter = 1;
try
// This compares if it's changed since a file scan only
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate,
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
continue;
if (series.Format == MangaFormat.Epub)
{
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
var totalPages = book.Content.Html.Values;
foreach (var bookPage in totalPages)
long sum = 0;
var fileCounter = 1;
foreach (var file in chapter.Files.Select(file => file.FilePath))
{
var progress = Math.Max(0F,
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
var pageCounter = 1;
try
{
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
var totalPages = book.Content.Html.Values;
foreach (var bookPage in totalPages)
{
var progress = Math.Max(0F,
Math.Min(1F, (fileCounter * pageCounter) * 1F / (chapter.Files.Count * totalPages.Count)));
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
ProgressEventType.Updated, useFileName ? file : series.Name));
sum += await GetWordCountFromHtml(bookPage);
pageCounter++;
}
fileCounter++;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
await _eventHub.SendMessageAsync(MessageFactory.Error,
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
$"{series.Name} - {file}"));
return;
}
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
ProgressEventType.Updated, useFileName ? file : series.Name));
sum += await GetWordCountFromHtml(bookPage);
pageCounter++;
}
fileCounter++;
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an error reading an epub file for word count, series skipped");
await _eventHub.SendMessageAsync(MessageFactory.Error,
MessageFactory.ErrorEvent("There was an issue counting words on an epub",
$"{series.Name} - {file}"));
return;
chapter.WordCount = sum;
series.WordCount += sum;
volume.WordCount += sum;
}
var est = _readerService.GetTimeEstimate(chapter.WordCount, chapter.Pages, isEpub);
chapter.MinHoursToRead = est.MinHours;
chapter.MaxHoursToRead = est.MaxHours;
chapter.AvgHoursToRead = est.AvgHours;
_unitOfWork.ChapterRepository.Update(chapter);
}
chapter.WordCount = sum;
_unitOfWork.ChapterRepository.Update(chapter);
totalSum += sum;
var volumeEst = _readerService.GetTimeEstimate(volume.WordCount, volume.Pages, isEpub);
volume.MinHoursToRead = volumeEst.MinHours;
volume.MaxHoursToRead = volumeEst.MaxHours;
volume.AvgHoursToRead = volumeEst.AvgHours;
_unitOfWork.VolumeRepository.Update(volume);
}
series.WordCount = totalSum;
var seriesEstimate = _readerService.GetTimeEstimate(series.WordCount, series.Pages, isEpub);
series.MinHoursToRead = seriesEstimate.MinHours;
series.MaxHoursToRead = seriesEstimate.MaxHours;
series.AvgHoursToRead = seriesEstimate.AvgHours;
_unitOfWork.SeriesRepository.Update(series);
}
@ -207,8 +229,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
if (textNodes == null) return 0;
return textNodes
.Select(node => node.InnerText)
.Select(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)

View file

@ -772,7 +772,6 @@ public class ScannerService : IScannerService
case PersonRole.Translator:
if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person);
break;
case PersonRole.Other:
default:
series.Metadata.People.Remove(person);
break;