From 63475722eae8181c502c67727e843926c9243a62 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Fri, 27 May 2022 09:08:54 -0500 Subject: [PATCH] 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] * Added image to series detail drawer Co-authored-by: Robbie Davis Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- API.Tests/Parser/MangaParserTests.cs | 4 +- API/Controllers/MetadataController.cs | 14 + API/Controllers/ReaderController.cs | 42 +++ API/Controllers/RecommendedController.cs | 20 +- API/Controllers/SeriesController.cs | 2 + API/DTOs/ChapterDto.cs | 5 +- API/DTOs/Metadata/ChapterMetadataDto.cs | 4 + API/DTOs/Reader/HourEstimateRangeDto.cs | 24 ++ API/Data/Repositories/SeriesRepository.cs | 33 ++- API/Parser/Parser.cs | 49 +++- API/Services/MetadataService.cs | 2 +- API/Services/ReaderService.cs | 8 + .../Metadata/WordCountAnalyzerService.cs | 2 +- API/Services/Tasks/ScannerService.cs | 7 +- UI/Web/package-lock.json | 25 +- UI/Web/package.json | 4 +- UI/Web/src/app/_models/chapter-metadata.ts | 2 + UI/Web/src/app/_models/hour-estimate-range.ts | 6 + UI/Web/src/app/_services/metadata.service.ts | 4 + UI/Web/src/app/_services/reader.service.ts | 11 +- .../app/_services/recommendation.service.ts | 7 + .../book-reader/book-reader.component.ts | 3 +- .../card-detail-drawer.component.html | 248 +++++++++++++++++ .../card-detail-drawer.component.scss | 16 ++ .../card-detail-drawer.component.ts | 263 ++++++++++++++++++ .../card-detail-layout.component.html | 13 - UI/Web/src/app/cards/cards.module.ts | 15 +- .../chapter-metadata-detail.component.ts | 18 +- .../library-recommended.component.html | 8 + .../library-recommended.component.ts | 6 +- .../grouped-typeahead.component.html | 4 +- UI/Web/src/app/pipe/language-name.pipe.ts | 4 +- .../series-detail.component.html | 22 -- .../series-detail/series-detail.component.ts | 18 +- .../series-metadata-detail.component.html | 67 +++-- .../series-metadata-detail.component.ts | 26 +- .../icon-and-title.component.html | 2 +- .../icon-and-title.component.scss | 6 + UI/Web/src/styles.scss | 1 + UI/Web/src/theme/components/_offcanvas.scss | 12 + 40 files changed, 883 insertions(+), 144 deletions(-) create mode 100644 API/DTOs/Reader/HourEstimateRangeDto.cs create mode 100644 UI/Web/src/app/_models/hour-estimate-range.ts create mode 100644 UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html create mode 100644 UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.scss create mode 100644 UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts create mode 100644 UI/Web/src/theme/components/_offcanvas.scss diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index bf7766a4e..e93161594 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -69,6 +69,8 @@ namespace API.Tests.Parser [InlineData("幽游白书完全版 第03卷 天下", "3")] [InlineData("阿衰online 第1册", "1")] [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")] + [InlineData("63권#200", "63")] + [InlineData("시즌34삽화2", "34")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename)); @@ -250,7 +252,7 @@ namespace API.Tests.Parser [InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")] [InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")] [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")] - [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "25")] + [InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename)); diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index ea87456c0..6187356a5 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -149,4 +149,18 @@ public class MetadataController : BaseApiController IsoCode = c.IetfLanguageTag }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); } + + /// + /// Returns summary for the chapter + /// + /// + /// + [HttpGet("chapter-summary")] + public async Task> 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); + } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 0385c3438..15e7497ce 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -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); } + /// + /// For the current user, returns an estimate on how long it would take to finish reading the series. + /// + /// For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases. + /// + /// + [HttpGet("time-left")] + public async Task> 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 + }); + } + } } diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs index acd200b97..0aa3ca41e 100644 --- a/API/Controllers/RecommendedController.cs +++ b/API/Controllers/RecommendedController.cs @@ -19,7 +19,7 @@ public class RecommendedController : BaseApiController /// - /// 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. /// /// Library to restrict series to /// @@ -35,6 +35,24 @@ public class RecommendedController : BaseApiController return Ok(series); } + /// + /// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release. + /// + /// Library to restrict series to + /// + /// + [HttpGet("quick-catchup-reads")] + public async Task>> 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); + } + /// /// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users. /// diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 613bc6698..ec31f7656 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -394,6 +394,8 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId)); } + + [Authorize(Policy="RequireAdminRole")] [HttpPost("update-related")] public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 63f0f6fd4..7c0c5384a 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -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 /// /// Metadata field public string TitleName { get; set; } - /// - /// Number of Words for this chapter. Only applies to Epub - /// - public long WordCount { get; set; } } } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index 7b81fb099..2c3add195 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -47,6 +47,10 @@ namespace API.DTOs.Metadata /// Total number of issues for the series /// public int TotalCount { get; set; } + /// + /// Number of Words for this chapter. Only applies to Epub + /// + public long WordCount { get; set; } } } diff --git a/API/DTOs/Reader/HourEstimateRangeDto.cs b/API/DTOs/Reader/HourEstimateRangeDto.cs new file mode 100644 index 000000000..efa70ef1c --- /dev/null +++ b/API/DTOs/Reader/HourEstimateRangeDto.cs @@ -0,0 +1,24 @@ +namespace API.DTOs.Reader; + +/// +/// A range of time to read a selection (series, chapter, etc) +/// +public class HourEstimateRangeDto +{ + /// + /// Min hours to read the selection + /// + public int MinHours { get; set; } = 1; + /// + /// Max hours to read the selection + /// + public int MaxHours { get; set; } = 1; + /// + /// Estimated average hours to read the selection + /// + public int AvgHours { get; set; } = 1; + /// + /// Does the user have progress on the range this represents + /// + public bool HasProgress { get; set; } = false; +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index c36bcc4cb..f6f721e3f 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -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 GetRelatedSeries(int userId, int seriesId); Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind); Task> GetQuickReads(int userId, int libraryId, UserParams userParams); + Task> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams); Task> GetHighlyRated(int userId, int libraryId, UserParams userParams); Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams); Task> 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(_mapper.ConfigurationProvider); @@ -1141,6 +1146,30 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } + public async Task> 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(_mapper.ConfigurationProvider); + + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + /// /// Returns all library ids for a user /// diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index f0cc2d1c5..f7e72ee8e 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -110,6 +110,22 @@ namespace API.Parser new Regex( @"(卷|册)(?\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( + @"제?(?\d+)권", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, + new Regex( + @"시즌(?\d+\-?\d+)", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"(?\d+(\-|~)?\d+?)시즌", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"시즌(?\d+(\-|~)?\d+?)", + MatchOptions, RegexTimeout), }; private static readonly Regex[] MangaSeriesRegex = new[] @@ -340,6 +356,18 @@ namespace API.Parser new Regex( @"^(?.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?\d+)", MatchOptions, RegexTimeout), + // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 + new Regex( + @"第(?\d+)(卷|册)", + MatchOptions, RegexTimeout), + // Chinese Volume: 卷n -> Volume n, 册n -> Volume n + new Regex( + @"(卷|册)(?\d+)", + MatchOptions, RegexTimeout), + // Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip + new Regex( + @"제?(?\d+)권", + MatchOptions, RegexTimeout), }; private static readonly Regex[] ComicChapterRegex = new[] @@ -398,11 +426,7 @@ namespace API.Parser new Regex( @"^(?.+?)-(chapter-)?(?\d+)", MatchOptions, RegexTimeout), - // Cyberpunk 2077 - Your Voice 01 - // new Regex( - // @"^(?.+?\s?-\s?(?:.+?))(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)$", - // MatchOptions, - // RegexTimeout), + }; private static readonly Regex[] ReleaseGroupRegex = new[] @@ -461,7 +485,10 @@ namespace API.Parser new Regex( @"第(?\d+)话", MatchOptions, RegexTimeout), - + // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 + new Regex( + @"제?(?\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 FormatTagSpecialKeyowrds = ImmutableArray.Create( + private static readonly ImmutableArray 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 /// public static bool HasComicInfoSpecial(string comicInfoFormat) { - return FormatTagSpecialKeyowrds.Contains(comicInfoFormat); + return FormatTagSpecialKeywords.Contains(comicInfoFormat); } } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index da0560848..323e31bb2 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -196,7 +196,7 @@ public class MetadataService : IMetadataService /// This can be heavy on memory first run /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - [DisableConcurrentExecution(timeoutInSeconds: 360)] + [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) { diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 8e3f5c47d..d75e952cd 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -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 logger, IEventHub eventHub) { _unitOfWork = unitOfWork; diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index aa81f1613..5287be4f5 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -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) { diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 4c19fc0ec..4d6adaf23 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -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 /// /// - [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; diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index ebe1d3ee1..b3750f3e3 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2424,12 +2424,22 @@ "fetch-cookie": "^0.11.0", "node-fetch": "^2.6.1", "ws": "^7.4.5" + }, + "dependencies": { + "eventsource": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz", + "integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==", + "requires": { + "original": "^1.0.0" + } + } } }, "@ng-bootstrap/ng-bootstrap": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0.tgz", - "integrity": "sha512-XWf/CsP1gH0aev7Mtsldtj0DPPFdTrJpSiyjzLFS29gU1ZuDlJz6OKthgUDxZoua6uNPAzaGMc0A20T+reMfRw==", + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.1.2.tgz", + "integrity": "sha512-p27c+mYVdHiJMYrj5hwClVJxLdiZxafAqlbw1sdJh2xJ1rGOe+H/kCf5YDRbhlHqRN+34Gr0RQqIUeD1I2V8hg==", "requires": { "tslib": "^2.3.0" } @@ -5757,12 +5767,9 @@ "dev": true }, "eventsource": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.1.tgz", - "integrity": "sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA==", - "requires": { - "original": "^1.0.0" - } + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==" }, "execa": { "version": "5.1.1", diff --git a/UI/Web/package.json b/UI/Web/package.json index 65633c433..44d64765e 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -28,12 +28,12 @@ "@angular/router": "~13.2.2", "@fortawesome/fontawesome-free": "^6.0.0", "@microsoft/signalr": "^6.0.2", - "@ng-bootstrap/ng-bootstrap": "^12.0.0", + "@ng-bootstrap/ng-bootstrap": "^12.1.2", "@popperjs/core": "^2.11.2", "@types/file-saver": "^2.0.5", "bootstrap": "^5.1.2", "bowser": "^2.11.0", - "eventsource": "^1.1.1", + "eventsource": "^2.0.2", "file-saver": "^2.0.5", "lazysizes": "^5.3.2", "ng-circle-progress": "^1.6.0", diff --git a/UI/Web/src/app/_models/chapter-metadata.ts b/UI/Web/src/app/_models/chapter-metadata.ts index 4f877cd5b..edda5dec4 100644 --- a/UI/Web/src/app/_models/chapter-metadata.ts +++ b/UI/Web/src/app/_models/chapter-metadata.ts @@ -17,6 +17,8 @@ export interface ChapterMetadata { summary: string; count: number; totalCount: number; + wordCount: number; + genres: Array; diff --git a/UI/Web/src/app/_models/hour-estimate-range.ts b/UI/Web/src/app/_models/hour-estimate-range.ts new file mode 100644 index 000000000..8e76285c1 --- /dev/null +++ b/UI/Web/src/app/_models/hour-estimate-range.ts @@ -0,0 +1,6 @@ +export interface HourEstimateRange{ + minHours: number; + maxHours: number; + avgHours: number; + hasProgress: boolean; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index 591cbdfd4..ca1016f15 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -97,4 +97,8 @@ export class MetadataService { } return this.httpClient.get>(this.baseUrl + method); } + + getChapterSummary(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'}); + } } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index b41efcb93..eff93aaca 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -4,10 +4,15 @@ import { environment } from 'src/environments/environment'; import { ChapterInfo } from '../manga-reader/_models/chapter-info'; import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; +import { HourEstimateRange } from '../_models/hour-estimate-range'; import { BookmarkInfo } from '../_models/manga-reader/bookmark-info'; import { PageBookmark } from '../_models/page-bookmark'; import { ProgressBookmark } from '../_models/progress-bookmark'; -import { Volume } from '../_models/volume'; + +export const MAX_WORDS_PER_HOUR = 30_000; +export const MIN_WORDS_PER_HOUR = 10_260; +export const MAX_PAGES_PER_MINUTE = 2.75; +export const MIN_PAGES_PER_MINUTE = 3.33; @Injectable({ providedIn: 'root' @@ -124,6 +129,10 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId); } + getTimeLeft(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'reader/time-left?seriesId=' + seriesId); + } + /** * Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes */ diff --git a/UI/Web/src/app/_services/recommendation.service.ts b/UI/Web/src/app/_services/recommendation.service.ts index ae3360ec3..b6795416f 100644 --- a/UI/Web/src/app/_services/recommendation.service.ts +++ b/UI/Web/src/app/_services/recommendation.service.ts @@ -22,6 +22,13 @@ export class RecommendationService { .pipe(map(response => this.utilityService.createPaginatedResult(response))); } + getQuickCatchupReads(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/quick-catchup-reads?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + getHighlyRated(libraryId: number, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index 0607ea408..ca438e477 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -3,7 +3,7 @@ import {DOCUMENT, Location} from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { forkJoin, fromEvent, of, Subject } from 'rxjs'; -import { catchError, debounceTime, take, takeUntil, tap } from 'rxjs/operators'; +import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators'; import { Chapter } from 'src/app/_models/chapter'; import { AccountService } from 'src/app/_services/account.service'; import { NavService } from 'src/app/_services/nav.service'; @@ -27,7 +27,6 @@ import { User } from 'src/app/_models/user'; import { ThemeService } from 'src/app/_services/theme.service'; import { ScrollService } from 'src/app/_services/scroll.service'; import { PAGING_DIRECTION } from 'src/app/manga-reader/_models/reader-enums'; -import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; enum TabID { diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html new file mode 100644 index 000000000..039211436 --- /dev/null +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html @@ -0,0 +1,248 @@ +
+
+ + + + + {{chapter.titleName}} + + + {{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}} + + + + + + + {{chapter.titleName}} + + + {{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}} + + + + + + {{chapter.titleName}} + + + +
+ +
+ +
+
+ +
+
+
\ No newline at end of file diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.scss b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.scss new file mode 100644 index 000000000..1dcb9aeac --- /dev/null +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.scss @@ -0,0 +1,16 @@ +.hide-if-empty:empty { + display: none !important; +} + +.offcanvas-body { + overflow: auto; +} + +.offcanvas-header { + padding: 1rem 1rem 0; +} + +.tab-content { + overflow: auto; + height: calc(40vh - 62px); // drawer height - offcanvas heading height +} diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts new file mode 100644 index 000000000..f5a7cbc8c --- /dev/null +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -0,0 +1,263 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { NgbActiveOffcanvas } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrService } from 'ngx-toastr'; +import { Observable, of, take } from 'rxjs'; +import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; +import { Chapter } from 'src/app/_models/chapter'; +import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; +import { LibraryType } from 'src/app/_models/library'; +import { MangaFile } from 'src/app/_models/manga-file'; +import { MangaFormat } from 'src/app/_models/manga-format'; +import { PersonRole } from 'src/app/_models/person'; +import { Volume } from 'src/app/_models/volume'; +import { AccountService } from 'src/app/_services/account.service'; +import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service'; +import { ActionService } from 'src/app/_services/action.service'; +import { ImageService } from 'src/app/_services/image.service'; +import { LibraryService } from 'src/app/_services/library.service'; +import { MetadataService } from 'src/app/_services/metadata.service'; +import { MAX_PAGES_PER_MINUTE, MAX_WORDS_PER_HOUR, MIN_PAGES_PER_MINUTE, MIN_WORDS_PER_HOUR, ReaderService } from 'src/app/_services/reader.service'; +import { SeriesService } from 'src/app/_services/series.service'; +import { UploadService } from 'src/app/_services/upload.service'; + +enum TabID { + General = 0, + Metadata = 1, + Cover = 2, + Files = 3 +} + +@Component({ + selector: 'app-card-detail-drawer', + templateUrl: './card-detail-drawer.component.html', + styleUrls: ['./card-detail-drawer.component.scss'] +}) +export class CardDetailDrawerComponent implements OnInit { + + @Input() parentName = ''; + @Input() seriesId: number = 0; + @Input() libraryId: number = 0; + @Input() data!: Volume | Chapter; + + /** + * If this is a volume, this will be first chapter for said volume. + */ + chapter!: Chapter; + isChapter = false; + chapters: Chapter[] = []; + + + /** + * If a cover image update occured. + */ + coverImageUpdate: boolean = false; + coverImageIndex: number = 0; + /** + * Url of the selected cover + */ + selectedCover: string = ''; + coverImageLocked: boolean = false; + /** + * When the API is doing work + */ + coverImageSaveLoading: boolean = false; + imageUrls: Array = []; + + + actions: ActionItem[] = []; + chapterActions: ActionItem[] = []; + libraryType: LibraryType = LibraryType.Manga; + + + tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}]; + active = this.tabs[0]; + + chapterMetadata!: ChapterMetadata; + ageRating!: string; + + summary$: Observable = of(''); + minHoursToRead: number = 1; + maxHoursToRead: number = 1; + + + get MangaFormat() { + return MangaFormat; + } + + get Breakpoint() { + return Breakpoint; + } + + get PersonRole() { + return PersonRole; + } + + get LibraryType() { + return LibraryType; + } + + get TabID() { + return TabID; + } + + constructor(public utilityService: UtilityService, + public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, + private accountService: AccountService, private actionFactoryService: ActionFactoryService, + private actionService: ActionService, private router: Router, private libraryService: LibraryService, + private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService, + public activeOffcanvas: NgbActiveOffcanvas) { } + + ngOnInit(): void { + this.isChapter = this.utilityService.isChapter(this.data); + + this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0]; + + this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); + + this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => { + this.chapterMetadata = metadata; + + this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating); + + if (this.chapter.files[0].format === MangaFormat.EPUB && this.chapterMetadata.wordCount > 0) { + this.minHoursToRead = parseInt(Math.round(this.chapterMetadata.wordCount / MAX_WORDS_PER_HOUR) + '', 10) || 1; + this.maxHoursToRead = parseInt(Math.round(this.chapterMetadata.wordCount / MIN_WORDS_PER_HOUR) + '', 10) || 1; + } else if (this.chapter.files[0].format !== MangaFormat.EPUB) { + this.minHoursToRead = parseInt(Math.round((this.chapter.pages / MIN_PAGES_PER_MINUTE) / 60) + '', 10) || 1; + this.maxHoursToRead = parseInt(Math.round((this.chapter.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10) || 1; + } + }); + + + if (this.isChapter) { + this.summary$ = this.metadataService.getChapterSummary(this.data.id); + } else { + this.summary$ = this.metadataService.getChapterSummary(this.utilityService.asVolume(this.data).chapters[0].id); + } + + + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + if (!this.accountService.hasAdminRole(user)) { + this.tabs.find(s => s.title === 'Cover')!.disabled = true; + } + } + }); + + this.libraryService.getLibraryType(this.libraryId).subscribe(type => { + this.libraryType = type; + }); + + this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit); + + if (this.isChapter) { + this.chapters.push(this.data as Chapter); + } else if (!this.isChapter) { + this.chapters.push(...(this.data as Volume).chapters); + } + // TODO: Move this into the backend + this.chapters.sort(this.utilityService.sortChapters); + this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id)); + // Try to show an approximation of the reading order for files + var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); + this.chapters.forEach((c: Chapter) => { + c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath)); + }); + } + + close() { + this.activeOffcanvas.close({coverImageUpdate: this.coverImageUpdate}); + } + + formatChapterNumber(chapter: Chapter) { + if (chapter.number === '0') { + return '1'; + } + return chapter.number; + } + + performAction(action: ActionItem, chapter: Chapter) { + if (typeof action.callback === 'function') { + action.callback(action.action, chapter); + } + } + + updateSelectedIndex(index: number) { + this.coverImageIndex = index; + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + } + + handleReset() { + this.coverImageLocked = false; + } + + saveCoverImage() { + this.coverImageSaveLoading = true; + const selectedIndex = this.coverImageIndex || 0; + if (selectedIndex > 0) { + this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => { + if (this.coverImageIndex > 0) { + this.chapter.coverImageLocked = true; + this.coverImageUpdate = true; + } + this.coverImageSaveLoading = false; + }, err => this.coverImageSaveLoading = false); + } else if (this.coverImageLocked === false) { + this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => { + this.toastr.info('Cover image reset'); + this.coverImageSaveLoading = false; + this.coverImageUpdate = true; + }); + } + } + + markChapterAsRead(chapter: Chapter) { + if (this.seriesId === 0) { + return; + } + + this.actionService.markChapterAsRead(this.seriesId, chapter, () => { /* No Action */ }); + } + + markChapterAsUnread(chapter: Chapter) { + if (this.seriesId === 0) { + return; + } + + this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { /* No Action */ }); + } + + handleChapterActionCallback(action: Action, chapter: Chapter) { + switch (action) { + case(Action.MarkAsRead): + this.markChapterAsRead(chapter); + break; + case(Action.MarkAsUnread): + this.markChapterAsUnread(chapter); + break; + case(Action.AddToReadingList): + this.actionService.addChapterToReadingList(chapter, this.seriesId); + break; + default: + break; + } + } + + readChapter(chapter: Chapter) { + if (chapter.pages === 0) { + this.toastr.error('There are no pages. Kavita was not able to read this archive.'); + return; + } + + if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) { + this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]); + } else { + this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]); + } + } + +} diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html index f412c16aa..67b9812f0 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.html @@ -17,19 +17,6 @@ - - diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index f18f3a967..61a353e50 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -5,7 +5,7 @@ import { LibraryCardComponent } from './library-card/library-card.component'; import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component'; import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component'; import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component'; -import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule, NgbOffcanvasModule } from '@ng-bootstrap/ng-bootstrap'; import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NgxFileDropModule } from 'ngx-file-drop'; @@ -22,6 +22,7 @@ import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapte import { FileInfoComponent } from './file-info/file-info.component'; import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'; import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component'; +import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-drawer.component'; @@ -41,6 +42,7 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series- ChapterMetadataDetailComponent, FileInfoComponent, EditSeriesRelationComponent, + CardDetailDrawerComponent, ], imports: [ CommonModule, @@ -59,13 +61,16 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series- NgbRatingModule, - + NgbOffcanvasModule, // Series Detail, action of cards NgbNavModule, //Series Detail NgbPaginationModule, // CardDetailLayoutComponent NgbDropdownModule, NgbProgressbarModule, NgxFileDropModule, // Cover Chooser - PipeModule // filter for BulkAddToCollectionComponent + PipeModule, // filter for BulkAddToCollectionComponent + + + SharedModule, // IconAndTitleComponent ], exports: [ CardItemComponent, @@ -81,7 +86,9 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series- CardDetailsModalComponent, BulkOperationsComponent, ChapterMetadataDetailComponent, - EditSeriesRelationComponent + EditSeriesRelationComponent, + + NgbOffcanvasModule ] }) export class CardsModule { } diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts index 1a3db1cc2..1e0c08f98 100644 --- a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts @@ -1,5 +1,4 @@ import { Component, Input, OnInit } from '@angular/core'; -import { MetadataService } from 'src/app/_services/metadata.service'; import { Chapter } from 'src/app/_models/chapter'; import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; import { UtilityService } from 'src/app/shared/_services/utility.service'; @@ -23,7 +22,7 @@ export class ChapterMetadataDetailComponent implements OnInit { return LibraryType; } - constructor(private metadataService: MetadataService, public utilityService: UtilityService) { } + constructor(public utilityService: UtilityService) { } ngOnInit(): void { this.roles = Object.keys(PersonRole).filter(role => /[0-9]/.test(role) === false); @@ -41,19 +40,4 @@ export class ChapterMetadataDetailComponent implements OnInit { action.callback(action.action, chapter); } } - - readChapter(chapter: Chapter) { - // if (chapter.pages === 0) { - // this.toastr.error('There are no pages. Kavita was not able to read this archive.'); - // return; - // } - - // if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) { - // this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]); - // } else { - // this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]); - // } - } - - } diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html index 52249c75e..20e7606a2 100644 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.html @@ -20,6 +20,14 @@ + + + + + + + + diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts index d06493b37..ed6c39fd3 100644 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts @@ -16,6 +16,7 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy { @Input() libraryId: number = 0; quickReads$!: Observable; + quickCatchups$!: Observable; highlyRated$!: Observable; onDeck$!: Observable; rediscover$!: Observable; @@ -36,6 +37,9 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy { this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId) .pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay()); + this.quickCatchups$ = this.recommendationService.getQuickCatchupReads(this.libraryId) + .pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay()); + this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId) .pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay()); @@ -50,7 +54,7 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy { this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(takeUntil(this.onDestroy), map(p => p.result), shareReplay()); }); - this.all$ = merge(this.quickReads$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntil(this.onDestroy)); + this.all$ = merge(this.quickReads$, this.quickCatchups$, this.highlyRated$, this.rediscover$, this.onDeck$, this.genre$).pipe(takeUntil(this.onDestroy)); this.all$.subscribe(() => this.noData = false); } diff --git a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html index 5944a5ced..01224ddf0 100644 --- a/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html @@ -1,9 +1,9 @@
diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 172101da6..6cde05998 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -1,7 +1,7 @@ import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import { NgbModal, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { forkJoin, Subject } from 'rxjs'; import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators'; @@ -35,6 +35,7 @@ import { SeriesService } from '../_services/series.service'; import { NavService } from '../_services/nav.service'; import { RelatedSeries } from '../_models/series-detail/related-series'; import { RelationKind } from '../_models/series-detail/relation-kind'; +import { CardDetailDrawerComponent } from '../cards/card-detail-drawer/card-detail-drawer.component'; interface RelatedSeris { series: Series; @@ -196,7 +197,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { private confirmService: ConfirmService, private titleService: Title, private downloadService: DownloadService, private actionService: ActionService, public imageSerivce: ImageService, private messageHub: MessageHubService, - private readingListService: ReadingListService, public navService: NavService + private readingListService: ReadingListService, public navService: NavService, + private offcanvasService: NgbOffcanvas ) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -560,12 +562,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { } openViewInfo(data: Volume | Chapter) { - const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' }); - modalRef.componentInstance.data = data; - modalRef.componentInstance.parentName = this.series?.name; - modalRef.componentInstance.seriesId = this.series?.id; - modalRef.componentInstance.libraryId = this.series?.libraryId; - modalRef.closed.subscribe((result: {coverImageUpdate: boolean}) => { + const drawerRef = this.offcanvasService.open(CardDetailDrawerComponent, {position: 'bottom'}); + drawerRef.componentInstance.data = data; + drawerRef.componentInstance.parentName = this.series?.name; + drawerRef.componentInstance.seriesId = this.series?.id; + drawerRef.componentInstance.libraryId = this.series?.libraryId; + drawerRef.closed.subscribe((result: {coverImageUpdate: boolean}) => { if (result.coverImageUpdate) { this.coverImageOffset += 1; } diff --git a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html index 45a7bf487..86c4a7bd7 100644 --- a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html +++ b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.html @@ -5,83 +5,100 @@
-
+
{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}
-
+
-
+
{{seriesMetadata.releaseYear}}
-
+
-
+
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
-
+
-
+
{{seriesMetadata.publicationStatus | publicationStatus}}
-
+
-
+
{{utilityService.mangaFormat(series.format)}}
-
+
-
+
{{series.latestReadDate | date:'shortDate'}}
-
+
-
- - {{series.pages}} Pages - -
-
+ + +
+ + {{series.wordCount | compactNumber}} Words + +
+
+
+ +
+ + {{series.pages}} Pages + +
+
+ +
+ + -
+
{{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hour{{minHoursToRead > 1 ? 's' : ''}}
- -
-
- - {{series.wordCount | compactNumber}} Words + + +
+
+ + ~{{readingTimeLeft.avgHours}} Hour{{readingTimeLeft.avgHours > 1 ? 's' : ''}} Left -
+
+ +
diff --git a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts index f4f5a2fda..a7b7101d6 100644 --- a/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-metadata-detail/series-metadata-detail.component.ts @@ -1,5 +1,7 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { Router } from '@angular/router'; +import { HourEstimateRange } from 'src/app/_models/hour-estimate-range'; +import { MAX_WORDS_PER_HOUR, MIN_WORDS_PER_HOUR, MIN_PAGES_PER_MINUTE, MAX_PAGES_PER_MINUTE, ReaderService } from 'src/app/_services/reader.service'; import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component'; import { FilterQueryParam } from '../../shared/_services/filter-utilities.service'; import { UtilityService } from '../../shared/_services/utility.service'; @@ -9,11 +11,6 @@ import { Series } from '../../_models/series'; import { SeriesMetadata } from '../../_models/series-metadata'; import { MetadataService } from '../../_services/metadata.service'; -const MAX_WORDS_PER_HOUR = 30_000; -const MIN_WORDS_PER_HOUR = 10_260; -const MAX_PAGES_PER_MINUTE = 2.75; -const MIN_PAGES_PER_MINUTE = 3.33; - @Component({ selector: 'app-series-metadata-detail', @@ -34,6 +31,7 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { minHoursToRead: number = 1; maxHoursToRead: number = 1; + readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false}; /** * Html representation of Series Summary @@ -52,7 +50,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { return FilterQueryParam; } - constructor(public utilityService: UtilityService, public metadataService: MetadataService, private router: Router) { } + constructor(public utilityService: UtilityService, public metadataService: MetadataService, private router: Router, public readerService: ReaderService) { + + } ngOnChanges(changes: SimpleChanges): void { this.hasExtendedProperites = this.seriesMetadata.colorists.length > 0 || @@ -67,17 +67,17 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { if (this.seriesMetadata !== null) { this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '
'); - - - } + if (this.series !== null) { + this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft); + if (this.series.format === MangaFormat.EPUB && this.series.wordCount > 0) { - this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10); - this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10); + this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10) || 1; + this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10) || 1; } else if (this.series.format !== MangaFormat.EPUB) { - this.minHoursToRead = parseInt(Math.round((this.series.pages / MIN_PAGES_PER_MINUTE) / 60) + '', 10); - this.maxHoursToRead = parseInt(Math.round((this.series.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10); + this.minHoursToRead = parseInt(Math.round((this.series.pages / MIN_PAGES_PER_MINUTE) / 60) + '', 10) || 1; + this.maxHoursToRead = parseInt(Math.round((this.series.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10) || 1; } } } diff --git a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html index 183d5fb10..064f88f33 100644 --- a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html +++ b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.html @@ -2,7 +2,7 @@ (click)="handleClick($event)"> -
+
\ No newline at end of file diff --git a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss index 7f7d1ce16..44b8535c7 100644 --- a/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss +++ b/UI/Web/src/app/shared/icon-and-title/icon-and-title.component.scss @@ -6,4 +6,10 @@ .icon { width: 20px; height: 20px; + text-align: center; +} + +.text { + padding-top: 5px; + text-align: center; } \ No newline at end of file diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index 1af759d4a..3b6b99179 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -36,6 +36,7 @@ @import './theme/components/progress'; @import './theme/components/sidenav'; @import './theme/components/carousel'; +@import './theme/components/offcanvas'; @import './theme/utilities/utilities'; diff --git a/UI/Web/src/theme/components/_offcanvas.scss b/UI/Web/src/theme/components/_offcanvas.scss new file mode 100644 index 000000000..ef6a2e527 --- /dev/null +++ b/UI/Web/src/theme/components/_offcanvas.scss @@ -0,0 +1,12 @@ +.offcanvas { + color: var(--drawer-text-color); + background-color: var(--drawer-bg-color); +} + +.offcanvas-end, .offcanvas-start, .offcanvas-top { + top: 56px; +} + +.offcanvas-bottom { + height: 40vh; +} \ No newline at end of file