Drawers, Estimated Reading Time, Korean Parsing Support (#1297)

* Started building out idea around detail drawer. Need code from word count to continue

* Fixed the logic for caluclating time to read on comics

* Adding styles

* more styling fixes

* Cleaned up the styles a bit more so it's at least functional. Not sure on the feature, might abandon.

* Pulled Robbie's changes in and partially migrated them to the drawer.

* Add offset overrides for offcanvas so it takes our header into account

* Implemented a basic time left to finish the series (or at least what's in Kavita). Rough around the edges.

* Cleaned up the drawer code.

* Added Quick Catch ups to recommended page. Updated the timeout for scan tasks to ensure we don't run 2 at the same time.

* Quick catchups implemented

* Added preliminary support for Korean filename parsing. Reduced an array alloc that is called many thousands of times per scan.

* Fixing drawer overflow

* Fixed a calculation bug with average reading time.

* Small spacing changes to drawer

* Don't show estimated reading time if the user hasn't read anything

* Bump eventsource from 1.1.1 to 2.0.2 in /UI/Web

Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.1.1 to 2.0.2.
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v1.1.1...v2.0.2)

---
updated-dependencies:
- dependency-name: eventsource
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Added image to series detail drawer

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Joseph Milazzo 2022-05-27 09:08:54 -05:00 committed by GitHub
parent d796bcdc0a
commit 63475722ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 883 additions and 144 deletions

View file

@ -149,4 +149,18 @@ public class MetadataController : BaseApiController
IsoCode = c.IetfLanguageTag
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
}
/// <summary>
/// Returns summary for the chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-summary")]
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
{
if (chapterId <= 0) return BadRequest("Chapter does not exist");
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest("Chapter does not exist");
return Ok(chapter.Summary);
}
}

View file

@ -8,6 +8,7 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
@ -627,5 +628,46 @@ namespace API.Controllers
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
}
/// <summary>
/// For the current user, returns an estimate on how long it would take to finish reading the series.
/// </summary>
/// <remarks>For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.</remarks>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("time-left")]
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
// Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId);
if (series.Format == MangaFormat.Epub)
{
var chapters =
await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(progress.Select(p => p.ChapterId).ToList());
// Word count
var progressCount = chapters.Sum(c => c.WordCount);
var wordsLeft = series.WordCount - progressCount;
return Ok(new HourEstimateRangeDto()
{
MinHours = (int) Math.Round((wordsLeft / ReaderService.MinWordsPerHour)),
MaxHours = (int) Math.Round((wordsLeft / ReaderService.MaxWordsPerHour)),
AvgHours = (int) Math.Round((wordsLeft / ReaderService.AvgWordsPerHour)),
HasProgress = progressCount > 0
});
}
var progressPageCount = progress.Sum(p => p.PagesRead);
var pagesLeft = series.Pages - progressPageCount;
return Ok(new HourEstimateRangeDto()
{
MinHours = (int) Math.Round((pagesLeft / ReaderService.MinPagesPerMinute / 60F)),
MaxHours = (int) Math.Round((pagesLeft / ReaderService.MaxPagesPerMinute / 60F)),
AvgHours = (int) Math.Round((pagesLeft / ReaderService.AvgPagesPerMinute / 60F)),
HasProgress = progressPageCount > 0
});
}
}
}

View file

@ -19,7 +19,7 @@ public class RecommendedController : BaseApiController
/// <summary>
/// Quick Reads are series that are less than 2K pages in total.
/// 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>
/// <returns></returns>
@ -35,6 +35,24 @@ public class RecommendedController : BaseApiController
return Ok(series);
}
/// <summary>
/// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpGet("quick-catchup-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(user.Id, libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
/// </summary>

View file

@ -394,6 +394,8 @@ namespace API.Controllers
return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId));
}
[Authorize(Policy="RequireAdminRole")]
[HttpPost("update-related")]
public async Task<ActionResult> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.Entities.Enums;
namespace API.DTOs
{
@ -61,9 +62,5 @@ namespace API.DTOs
/// </summary>
/// <remarks>Metadata field</remarks>
public string TitleName { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// </summary>
public long WordCount { get; set; }
}
}

View file

@ -47,6 +47,10 @@ namespace API.DTOs.Metadata
/// Total number of issues for the series
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// </summary>
public long WordCount { get; set; }
}
}

View file

@ -0,0 +1,24 @@
namespace API.DTOs.Reader;
/// <summary>
/// A range of time to read a selection (series, chapter, etc)
/// </summary>
public class HourEstimateRangeDto
{
/// <summary>
/// Min hours to read the selection
/// </summary>
public int MinHours { get; set; } = 1;
/// <summary>
/// Max hours to read the selection
/// </summary>
public int MaxHours { get; set; } = 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;
}

View file

@ -17,6 +17,7 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks;
using AutoMapper;
using AutoMapper.QueryableExtensions;
@ -113,6 +114,7 @@ public interface ISeriesRepository
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
@ -1131,8 +1133,11 @@ public class SeriesRepository : ISeriesRepository
var query = _context.Series
.Where(s => s.Pages < 2000 && !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => (
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing)
.AsSplitQuery()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
@ -1141,6 +1146,30 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
.Select(p => p.SeriesId)
.Distinct();
var query = _context.Series
.Where(s => (
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => s.Metadata.PublicationStatus == PublicationStatus.OnGoing)
.AsSplitQuery()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
/// <summary>
/// Returns all library ids for a user
/// </summary>

View file

@ -110,6 +110,22 @@ namespace API.Parser
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
new Regex(
@"제?(?<Volume>\d+)권",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n,
new Regex(
@"시즌(?<Volume>\d+\-?\d+)",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n, n시즌 -> season n
new Regex(
@"(?<Volume>\d+(\-|~)?\d+?)시즌",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n, n시즌 -> season n
new Regex(
@"시즌(?<Volume>\d+(\-|~)?\d+?)",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] MangaSeriesRegex = new[]
@ -340,6 +356,18 @@ namespace API.Parser
new Regex(
@"^(?<Series>.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册
new Regex(
@"第(?<Volume>\d+)(卷|册)",
MatchOptions, RegexTimeout),
// Chinese Volume: 卷n -> Volume n, 册n -> Volume n
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip
new Regex(
@"제?(?<Volume>\d+)권",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] ComicChapterRegex = new[]
@ -398,11 +426,7 @@ namespace API.Parser
new Regex(
@"^(?<Series>.+?)-(chapter-)?(?<Chapter>\d+)",
MatchOptions, RegexTimeout),
// Cyberpunk 2077 - Your Voice 01
// new Regex(
// @"^(?<Series>.+?\s?-\s?(?:.+?))(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)$",
// MatchOptions,
// RegexTimeout),
};
private static readonly Regex[] ReleaseGroupRegex = new[]
@ -461,7 +485,10 @@ namespace API.Parser
new Regex(
@"第(?<Chapter>\d+)话",
MatchOptions, RegexTimeout),
// Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44
new Regex(
@"제?(?<Chapter>\d+\.?\d+)(화|장)",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] MangaEditionRegex = {
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
@ -525,11 +552,13 @@ namespace API.Parser
MatchOptions, RegexTimeout
);
private static readonly ImmutableArray<string> FormatTagSpecialKeyowrds = ImmutableArray.Create(
private static readonly ImmutableArray<string> FormatTagSpecialKeywords = ImmutableArray.Create(
"Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue",
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
"GN", "FCBD");
private static readonly char[] LeadingZeroesTrimChars = new[] { '0' };
public static MangaFormat ParseFormat(string filePath)
{
if (IsArchive(filePath)) return MangaFormat.Archive;
@ -916,8 +945,8 @@ namespace API.Parser
public static string RemoveLeadingZeroes(string title)
{
var ret = title.TrimStart(new[] { '0' });
return ret == string.Empty ? "0" : ret;
var ret = title.TrimStart(LeadingZeroesTrimChars);
return string.IsNullOrEmpty(ret) ? "0" : ret;
}
public static bool IsArchive(string filePath)
@ -1060,7 +1089,7 @@ namespace API.Parser
/// <returns></returns>
public static bool HasComicInfoSpecial(string comicInfoFormat)
{
return FormatTagSpecialKeyowrds.Contains(comicInfoFormat);
return FormatTagSpecialKeywords.Contains(comicInfoFormat);
}
}
}

View file

@ -196,7 +196,7 @@ public class MetadataService : IMetadataService
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task RefreshMetadata(int libraryId, bool forceUpdate = false)
{

View file

@ -38,6 +38,14 @@ public class ReaderService : IReaderService
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public const float MinWordsPerHour = 10260F;
public const float MaxWordsPerHour = 30000F;
public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
public const float MinPagesPerMinute = 3.33F;
public const float MaxPagesPerMinute = 2.75F;
public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F;
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub)
{
_unitOfWork = unitOfWork;

View file

@ -40,7 +40,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
_cacheHelper = cacheHelper;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
{

View file

@ -63,7 +63,7 @@ public class ScannerService : IScannerService
_wordCountAnalyzerService = wordCountAnalyzerService;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token)
{
@ -247,7 +247,7 @@ public class ScannerService : IScannerService
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60 * 4)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries()
{
@ -267,7 +267,7 @@ public class ScannerService : IScannerService
/// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
/// </summary>
/// <param name="libraryId"></param>
[DisableConcurrentExecution(360)]
[DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId)
{
@ -470,6 +470,7 @@ public class ScannerService : IScannerService
foreach (var series in duplicateSeries)
{
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
}
continue;