diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 15e7497ce..63c920ba0 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -628,6 +628,66 @@ namespace API.Controllers return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId); } + + /// + /// Given word count, page count, and if the entity is an epub file, this will return the read time. + /// + /// + /// + /// + /// Will always assume no progress as it's not privy + [HttpGet("manual-read-time")] + public ActionResult GetManualReadTime(int wordCount, int pageCount, bool isEpub) + { + + if (isEpub) + { + return Ok(new HourEstimateRangeDto() + { + MinHours = (int) Math.Round((wordCount / ReaderService.MinWordsPerHour)), + MaxHours = (int) Math.Round((wordCount / ReaderService.MaxWordsPerHour)), + AvgHours = (int) Math.Round((wordCount / ReaderService.AvgWordsPerHour)), + HasProgress = false + }); + } + + return Ok(new HourEstimateRangeDto() + { + MinHours = (int) Math.Round((pageCount / ReaderService.MinPagesPerMinute / 60F)), + MaxHours = (int) Math.Round((pageCount / ReaderService.MaxPagesPerMinute / 60F)), + AvgHours = (int) Math.Round((pageCount / ReaderService.AvgPagesPerMinute / 60F)), + HasProgress = false + }); + } + + [HttpGet("read-time")] + public async Task> 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(); + if (series.Format == MangaFormat.Epub) + { + return Ok(new HourEstimateRangeDto() + { + MinHours = (int) Math.Round((series.WordCount / ReaderService.MinWordsPerHour)), + MaxHours = (int) Math.Round((series.WordCount / ReaderService.MaxWordsPerHour)), + AvgHours = (int) Math.Round((series.WordCount / ReaderService.AvgWordsPerHour)), + HasProgress = progress.Any() + }); + } + + return Ok(new HourEstimateRangeDto() + { + MinHours = (int) Math.Round((series.Pages / ReaderService.MinPagesPerMinute / 60F)), + MaxHours = (int) Math.Round((series.Pages / ReaderService.MaxPagesPerMinute / 60F)), + AvgHours = (int) Math.Round((series.Pages / ReaderService.AvgPagesPerMinute / 60F)), + HasProgress = progress.Any() + }); + } + + /// /// For the current user, returns an estimate on how long it would take to finish reading the series. /// diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index 15f943178..b1c0e02cc 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -19,6 +19,7 @@ public interface IBookmarkService Task BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark); Task RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto); Task> GetBookmarkFilesById(IEnumerable bookmarkIds); + [DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)] Task ConvertAllBookmarkToWebP(); } @@ -173,7 +174,6 @@ public class BookmarkService : IBookmarkService /// /// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire. /// - [DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)] public async Task ConvertAllBookmarkToWebP() { var bookmarkDirectory = diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 323e31bb2..3f32c4583 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -27,6 +27,8 @@ public interface IMetadataService /// /// /// + [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task RefreshMetadata(int libraryId, bool forceUpdate = false); /// /// Performs a forced refresh of metadata just for a series and it's nested entities @@ -196,8 +198,6 @@ 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: 60 * 60 * 60)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 5287be4f5..724c9afae 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -17,8 +17,10 @@ namespace API.Services.Tasks.Metadata; public interface IWordCountAnalyzerService { + [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanLibrary(int libraryId, bool forceUpdate = false); - Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); + Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = true); } /// @@ -40,8 +42,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService _cacheHelper = cacheHelper; } - [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibrary(int libraryId, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); @@ -113,7 +114,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } - public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) + public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = true) { var sw = Stopwatch.StartNew(); var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); @@ -126,7 +127,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name)); - await ProcessSeries(series); + await ProcessSeries(series, forceUpdate); if (_unitOfWork.HasChanges()) { diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 16dd6c932..fb830da03 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -164,27 +164,44 @@ namespace API.Services.Tasks.Scanner info.Series = MergeName(info); var normalizedSeries = Parser.Parser.Normalize(info.Series); + var normalizedSortSeries = Parser.Parser.Normalize(info.SeriesSort); var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries); - var existingKey = _scannedSeries.Keys.FirstOrDefault(ps => - ps.Format == info.Format && (ps.NormalizedName == normalizedSeries - || ps.NormalizedName == normalizedLocalizedSeries)); - existingKey ??= new ParsedSeries() - { - Format = info.Format, - Name = info.Series, - NormalizedName = normalizedSeries - }; - _scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => + try { - oldValue ??= new List(); - if (!oldValue.Contains(info)) + var existingKey = _scannedSeries.Keys.SingleOrDefault(ps => + ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) + || ps.NormalizedName.Equals(normalizedLocalizedSeries) + || ps.NormalizedName.Equals(normalizedSortSeries))); + existingKey ??= new ParsedSeries() { - oldValue.Add(info); - } + Format = info.Format, + Name = info.Series, + NormalizedName = normalizedSeries + }; - return oldValue; - }); + _scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => + { + oldValue ??= new List(); + if (!oldValue.Contains(info)) + { + oldValue.Add(info); + } + + return oldValue; + }); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "{SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); + foreach (var seriesKey in _scannedSeries.Keys.Where(ps => + ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) + || ps.NormalizedName.Equals(normalizedLocalizedSeries) + || ps.NormalizedName.Equals(normalizedSortSeries)))) + { + _logger.LogCritical("Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name); + } + } } /// @@ -198,14 +215,32 @@ namespace API.Services.Tasks.Scanner var normalizedSeries = Parser.Parser.Normalize(info.Series); var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries); // We use FirstOrDefault because this was introduced late in development and users might have 2 series with both names - var existingName = - _scannedSeries.FirstOrDefault(p => - (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || - Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format) - .Key; - if (existingName != null && !string.IsNullOrEmpty(existingName.Name)) + try { - return existingName.Name; + var existingName = + _scannedSeries.SingleOrDefault(p => + (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || + Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && + p.Key.Format == info.Format) + .Key; + + if (existingName != null && !string.IsNullOrEmpty(existingName.Name)) + { + return existingName.Name; + } + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); + var values = _scannedSeries.Where(p => + (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || + Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && + p.Key.Format == info.Format); + foreach (var pair in values) + { + _logger.LogCritical("Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name); + } + } return info.Series; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 4d6adaf23..165e11a55 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -28,8 +28,14 @@ public interface IScannerService /// cover images if forceUpdate is true. /// /// Library to scan against + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanLibrary(int libraryId); + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanLibraries(); + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanSeries(int libraryId, int seriesId, CancellationToken token); } @@ -63,8 +69,6 @@ public class ScannerService : IScannerService _wordCountAnalyzerService = wordCountAnalyzerService; } - [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) { var sw = new Stopwatch(); @@ -247,8 +251,6 @@ public class ScannerService : IScannerService } - [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60 * 4)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibraries() { _logger.LogInformation("Starting Scan of All Libraries"); @@ -267,8 +269,7 @@ public class ScannerService : IScannerService /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes /// /// - [DisableConcurrentExecution(60 * 60 * 60)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibrary(int libraryId) { Library library; diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 7401b734b..0d7c12bda 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -5,7 +5,7 @@ "TokenKey": "super secret unguessable key", "Logging": { "LogLevel": { - "Default": "Information", + "Default": "Debug", "Microsoft": "Information", "Microsoft.Hosting.Lifetime": "Error", "Hangfire": "Information", diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index eff93aaca..6a9ea183d 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -133,6 +133,14 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/time-left?seriesId=' + seriesId); } + getTimeToRead(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'reader/read-time?seriesId=' + seriesId); + } + + getManualTimeToRead(wordCount: number, pageCount: number, isEpub: boolean) { + return this.httpClient.get(this.baseUrl + 'reader/manual-read-time?wordCount=' + wordCount + '&pageCount=' + pageCount + '&isEpub=' + isEpub); + } + /** * 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/cards/card-detail-drawer/card-detail-drawer.component.html b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.html index 039211436..819a09a3e 100644 --- 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 @@ -52,7 +52,7 @@
- {{chapter.pages}} Pages + {{chapter.pages | number:''}} Pages
@@ -70,7 +70,7 @@
- {{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hour{{minHoursToRead > 1 ? 's' : ''}} + {{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
@@ -229,7 +229,7 @@ {{file.filePath}}
- Pages: {{file.pages}} + Pages: {{file.pages | number:''}}
Added: {{(data.created | date: 'short') || '-'}} 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 index f5a7cbc8c..ba43deba8 100644 --- 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 @@ -6,6 +6,7 @@ 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 { HourEstimateRange } from 'src/app/_models/hour-estimate-range'; import { LibraryType } from 'src/app/_models/library'; import { MangaFile } from 'src/app/_models/manga-file'; import { MangaFormat } from 'src/app/_models/manga-format'; @@ -77,9 +78,11 @@ export class CardDetailDrawerComponent implements OnInit { ageRating!: string; summary$: Observable = of(''); + readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false}; minHoursToRead: number = 1; maxHoursToRead: number = 1; + get MangaFormat() { return MangaFormat; @@ -120,13 +123,13 @@ export class CardDetailDrawerComponent implements OnInit { 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; + let totalPages = this.chapter.pages; + if (!this.isChapter) { + // Need to account for multiple chapters if this is a volume + totalPages = this.utilityService.asVolume(this.data).chapters.map(c => c.pages).reduce((sum, d) => sum + d); } + + this.readerService.getManualTimeToRead(this.chapterMetadata.wordCount, totalPages, this.chapter.files[0].format === MangaFormat.EPUB).subscribe((time) => this.readingTime = time); }); 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 86c4a7bd7..8f0035156 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 @@ -67,29 +67,28 @@
+
- {{series.pages}} Pages + {{series.pages | number:''}} Pages
-
-
- {{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hour{{minHoursToRead > 1 ? 's' : ''}} + {{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
- +
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 a7b7101d6..3f9b89126 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 @@ -29,8 +29,7 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { isCollapsed: boolean = true; hasExtendedProperites: boolean = false; - minHoursToRead: number = 1; - maxHoursToRead: number = 1; + readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false}; readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false}; /** @@ -71,14 +70,7 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges { 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) || 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) || 1; - this.maxHoursToRead = parseInt(Math.round((this.series.pages / MAX_PAGES_PER_MINUTE) / 60) + '', 10) || 1; - } + this.readerService.getTimeToRead(this.series.id).subscribe((time) => this.readingTime = time); } }