diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 21029449a..0002b4e6a 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -514,6 +514,72 @@ public class CleanupServiceTests : AbstractDbTest } #endregion + + #region EnsureChapterProgressIsCapped + + [Fact] + public async Task EnsureChapterProgressIsCapped_ShouldNormalizeProgress() + { + await ResetDb(); + + var s = new SeriesBuilder("Test CleanupWantToRead_ShouldRemoveFullyReadSeries") + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Completed).Build()) + .Build(); + + s.Library = new LibraryBuilder("Test LIb").Build(); + var c = new ChapterBuilder("1").WithPages(2).Build(); + c.UserProgress = new List(); + s.Volumes = new List() + { + new VolumeBuilder("0").WithChapter(c).Build() + }; + _context.Series.Add(s); + + var user = new AppUser() + { + UserName = "EnsureChapterProgressIsCapped", + Progresses = new List() + }; + _context.AppUser.Add(user); + + await _unitOfWork.CommitAsync(); + + await _readerService.MarkChaptersAsRead(user, s.Id, new List() {c}); + await _unitOfWork.CommitAsync(); + + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + + Assert.NotNull(chapter); + Assert.Equal(2, chapter.PagesRead); + + // Update chapter to have 1 page + c.Pages = 1; + _unitOfWork.ChapterRepository.Update(c); + await _unitOfWork.CommitAsync(); + + chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + Assert.NotNull(chapter); + Assert.Equal(2, chapter.PagesRead); + Assert.Equal(1, chapter.Pages); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + await cleanupService.EnsureChapterProgressIsCapped(); + chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id); + await _unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter); + + Assert.NotNull(chapter); + Assert.Equal(1, chapter.PagesRead); + + _context.AppUser.Remove(user); + await _unitOfWork.CommitAsync(); + } + #endregion + // #region CleanupBookmarks // // [Fact] diff --git a/API.Tests/Services/ScrobblingServiceTests.cs b/API.Tests/Services/ScrobblingServiceTests.cs new file mode 100644 index 000000000..2d40c5211 --- /dev/null +++ b/API.Tests/Services/ScrobblingServiceTests.cs @@ -0,0 +1,14 @@ +using API.Services.Plus; +using Xunit; + +namespace API.Tests.Services; + +public class ScrobblingServiceTests +{ + [Theory] + [InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)] + public void CanParseWeblink(string link, long expectedId) + { + Assert.Equal(ScrobblingService.ExtractId(link, ScrobblingService.AniListWeblinkWebsite), expectedId); + } +} diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index e4bb5db6c..9d44f629e 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -425,7 +425,7 @@ public class LibraryController : BaseApiController public async Task UpdateLibrary(UpdateLibraryDto dto) { var userId = User.GetUserId(); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders | LibraryIncludes.FileTypes); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); if (library == null) return BadRequest(await _localizationService.Translate(userId, "library-doesnt-exist")); var newName = dto.Name.Trim(); @@ -453,8 +453,8 @@ public class LibraryController : BaseApiController .ToList(); library.LibraryExcludePatterns = dto.ExcludePatterns - .Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id}) .Distinct() + .Select(t => new LibraryExcludePattern() {Pattern = t, LibraryId = library.Id}) .ToList(); // Override Scrobbling for Comic libraries since there are no providers to scrobble to diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index a303dcc58..46c7dc9f2 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -270,6 +270,8 @@ public class ServerController : BaseApiController await provider.FlushAsync(); provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings); await provider.FlushAsync(); + provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries); + await provider.FlushAsync(); return Ok(); } diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index ec3af56a7..106a1dd65 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -34,6 +34,7 @@ public interface IAppUserProgressRepository Task GetHighestFullyReadVolumeForSeries(int seriesId, int userId); Task GetLatestProgressForSeries(int seriesId, int userId); Task GetFirstProgressForSeries(int seriesId, int userId); + Task UpdateAllProgressThatAreMoreThanChapterPages(); } #nullable disable public class AppUserProgressRepository : IAppUserProgressRepository @@ -198,6 +199,36 @@ public class AppUserProgressRepository : IAppUserProgressRepository return list.Count == 0 ? null : list.DefaultIfEmpty().Min(); } + public async Task UpdateAllProgressThatAreMoreThanChapterPages() + { + var updates = _context.AppUserProgresses + .Join(_context.Chapter, + progress => progress.ChapterId, + chapter => chapter.Id, + (progress, chapter) => new + { + Progress = progress, + Chapter = chapter + }) + .Where(joinResult => joinResult.Progress.PagesRead > joinResult.Chapter.Pages) + .Select(result => new + { + ProgressId = result.Progress.Id, + NewPagesRead = Math.Min(result.Progress.PagesRead, result.Chapter.Pages) + }) + .AsEnumerable(); + + foreach (var update in updates) + { + _context.AppUserProgresses + .Where(p => p.Id == update.ProgressId) + .ToList() // Execute the query to ensure exclusive lock + .ForEach(p => p.PagesRead = update.NewPagesRead); + } + + await _context.SaveChangesAsync(); + } + #nullable enable public async Task GetUserProgressAsync(int chapterId, int userId) { diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index ead68227b..4f679fb2b 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -175,13 +175,13 @@ public class ScrobblingService : IScrobblingService private async Task GetTokenForProvider(int userId, ScrobbleProvider provider) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user == null) return null; + if (user == null) return string.Empty; return provider switch { ScrobbleProvider.AniList => user.AniListAccessToken, _ => string.Empty - }; + } ?? string.Empty; } public async Task ScrobbleReviewUpdate(int userId, int seriesId, string reviewTitle, string reviewBody) @@ -192,11 +192,9 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); _logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name); - if (await CheckIfCanScrobble(userId, seriesId, series)) return; + if (await CheckIfCannotScrobble(userId, seriesId, series)) return; - if (string.IsNullOrEmpty(reviewTitle) || string.IsNullOrEmpty(reviewBody) || (reviewTitle.Length < 2200 || - reviewTitle.Length > 120 || - reviewTitle.Length < 20)) + if (IsAniListReviewValid(reviewTitle, reviewBody)) { _logger.LogDebug( "Rejecting Scrobble event for {Series}. Review is not long enough to meet requirements", series.Name); @@ -232,6 +230,13 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {UserId} ", series.Name, userId); } + private static bool IsAniListReviewValid(string reviewTitle, string reviewBody) + { + return string.IsNullOrEmpty(reviewTitle) || string.IsNullOrEmpty(reviewBody) || (reviewTitle.Length < 2200 || + reviewTitle.Length > 120 || + reviewTitle.Length < 20); + } + public async Task ScrobbleRatingUpdate(int userId, int seriesId, float rating) { if (!await _licenseService.HasActiveLicense()) return; @@ -240,7 +245,7 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); - if (await CheckIfCanScrobble(userId, seriesId, series)) return; + if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.ScoreUpdated); @@ -279,7 +284,7 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); - if (await CheckIfCanScrobble(userId, seriesId, series)) return; + if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id, ScrobbleEventType.ChapterRead); @@ -334,11 +339,11 @@ public class ScrobblingService : IScrobblingService if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); - if (await CheckIfCanScrobble(userId, seriesId, series)) return; + if (await CheckIfCannotScrobble(userId, seriesId, series)) return; var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); - if (existing) return; + if (existing) return; // BUG: If I take a series and add to remove from want to read, then add to want to read, Kavita rejects the second as a duplicate, when it's not var evt = new ScrobbleEvent() { @@ -355,7 +360,7 @@ public class ScrobblingService : IScrobblingService _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId); } - private async Task CheckIfCanScrobble(int userId, int seriesId, Series series) + private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) { if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) { @@ -616,7 +621,7 @@ public class ScrobblingService : IScrobblingService await SetAndCheckRateLimit(userRateLimits, user, license.Value); } - var totalProgress = readEvents.Count + addToWantToRead.Count + removeWantToRead.Count + ratingEvents.Count + decisions.Count + reviewEvents.Count; + var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count + reviewEvents.Count; _logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress); try @@ -693,6 +698,24 @@ public class ScrobblingService : IScrobblingService LocalizedSeriesName = evt.Series.LocalizedName, Year = evt.Series.Metadata.ReleaseYear })); + + // After decisions, we need to mark all the want to read and remove from want to read as completed + if (decisions.All(d => d.IsProcessed)) + { + foreach (var scrobbleEvent in addToWantToRead) + { + scrobbleEvent.IsProcessed = true; + scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); + } + foreach (var scrobbleEvent in removeWantToRead) + { + scrobbleEvent.IsProcessed = true; + scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); + } + await _unitOfWork.CommitAsync(); + } } catch (FlurlHttpException) { diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 9dfb9c1cf..c5b908f73 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -26,6 +26,7 @@ public interface ICleanupService Task CleanupBackups(); Task CleanupLogs(); void CleanupTemp(); + Task EnsureChapterProgressIsCapped(); /// /// Responsible to remove Series from Want To Read when user's have fully read the series and the series has Publication Status of Completed or Cancelled. /// @@ -89,6 +90,8 @@ public class CleanupService : ICleanupService await DeleteReadingListCoverImages(); await SendProgress(0.8F, "Cleaning old logs"); await CleanupLogs(); + await SendProgress(0.9F, "Cleaning progress events that exceed 100%"); + await EnsureChapterProgressIsCapped(); await SendProgress(1F, "Cleanup finished"); _logger.LogInformation("Cleanup finished"); } @@ -243,6 +246,17 @@ public class CleanupService : ICleanupService _logger.LogInformation("Temp directory purged"); } + /// + /// Ensures that each chapter's progress (pages read) is capped at the total pages. This can get out of sync when a chapter is replaced after being read with one with lower page count. + /// + /// + public async Task EnsureChapterProgressIsCapped() + { + _logger.LogInformation("Cleaning up any progress rows that exceed chapter page count"); + await _unitOfWork.AppUserProgressRepository.UpdateAllProgressThatAreMoreThanChapterPages(); + _logger.LogInformation("Cleaning up any progress rows that exceed chapter page count - complete"); + } + /// /// This does not cleanup any Series that are not Completed or Cancelled /// diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 772258cce..73b0fe1bc 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -84,9 +84,11 @@ export class BookLineOverlayComponent implements OnInit { const selection = window.getSelection(); if (!event.target) return; - if ((!selection || selection.toString().trim() === '' || selection.toString().trim() === this.selectedText)) { - event.preventDefault(); - event.stopPropagation(); + if ((selection === null || selection === undefined || selection.toString().trim() === '' || selection.toString().trim() === this.selectedText)) { + if (this.selectedText !== '') { + event.preventDefault(); + event.stopPropagation(); + } this.reset(); return; } diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index b18569e86..03a74f1fb 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -1643,8 +1643,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.clickToPaginate) { if (this.isCursorOverLeftPaginationArea(event)) { this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD); + return; } else if (this.isCursorOverRightPaginationArea(event)) { this.movePage(this.readingDirection === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS) + return; } } diff --git a/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts b/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts index b4b196f29..0b86a8a2a 100644 --- a/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts +++ b/UI/Web/src/app/registration/_components/confirm-email/confirm-email.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy} from '@angular/core'; import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; @@ -9,6 +9,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { NgIf, NgFor, NgTemplateOutlet } from '@angular/common'; import { SplashContainerComponent } from '../splash-container/splash-container.component'; import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {take} from "rxjs/operators"; @Component({ selector: 'app-confirm-email', @@ -18,7 +19,7 @@ import {translate, TranslocoDirective} from "@ngneat/transloco"; standalone: true, imports: [SplashContainerComponent, NgIf, NgFor, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoDirective] }) -export class ConfirmEmailComponent { +export class ConfirmEmailComponent implements OnDestroy { /** * Email token used for validating */ @@ -55,6 +56,14 @@ export class ConfirmEmailComponent { this.cdRef.markForCheck(); } + ngOnDestroy() { + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.navService.showSideNav(); + } + }); + } + isNullOrEmpty(v: string | null | undefined) { return v == undefined || v === '' || v === null; } diff --git a/UI/Web/src/app/shared/edit-list/edit-list.component.ts b/UI/Web/src/app/shared/edit-list/edit-list.component.ts index 09d10ffa4..04779cefe 100644 --- a/UI/Web/src/app/shared/edit-list/edit-list.component.ts +++ b/UI/Web/src/app/shared/edit-list/edit-list.component.ts @@ -68,7 +68,12 @@ export class EditListComponent implements OnInit { const tokenToRemove = tokens[index]; this.combinedItems = tokens.filter(t => t != tokenToRemove).join(','); - this.form.removeControl('link' + index, {emitEvent: true}); + for (const [index, [key, value]] of Object.entries(Object.entries(this.form.controls))) { + if (key.startsWith('link') && this.form.get(key)?.value === tokenToRemove) { + this.form.removeControl('link' + index, {emitEvent: true}); + } + } + this.emit(); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index ac5ddceaa..f1442cf91 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -115,9 +115,10 @@
- {{t('exclude-patterns-tooltip')}} - {{t('help')}} - + {{t('exclude-patterns-tooltip')}}{{t('help')}} +
+ +
diff --git a/UI/Web/src/theme/components/_card.scss b/UI/Web/src/theme/components/_card.scss index 0f9b0d46c..35786d643 100644 --- a/UI/Web/src/theme/components/_card.scss +++ b/UI/Web/src/theme/components/_card.scss @@ -32,7 +32,7 @@ $image-width: 160px; .card-title { font-size: 13px; - width: 130px; + width: 140px; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; diff --git a/openapi.json b/openapi.json index 84a1898ef..f15d95236 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.7.10.17" + "version": "0.7.10.20" }, "servers": [ {