diff --git a/API.Tests/Comparers/SortComparerZeroLastTests.cs b/API.Tests/Comparers/SortComparerZeroLastTests.cs index 37699d110..669ca6c37 100644 --- a/API.Tests/Comparers/SortComparerZeroLastTests.cs +++ b/API.Tests/Comparers/SortComparerZeroLastTests.cs @@ -12,6 +12,6 @@ public class SortComparerZeroLastTests [InlineData(new[] {0, 0, 1}, new[] {1, 0, 0})] public void SortComparerZeroLastTest(int[] input, int[] expected) { - Assert.Equal(expected, input.OrderBy(f => f, new SortComparerZeroLast()).ToArray()); + Assert.Equal(expected, input.OrderBy(f => f, SortComparerZeroLast.Default).ToArray()); } } diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index f7a7d2112..417a87e42 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -2079,10 +2079,84 @@ public class ReaderServiceTests _context.Series.Attach(series); await _context.SaveChangesAsync(); + // This tests that if you add a series later to a volume and a loose leaf chapter, we continue from that volume, rather than loose leaf var nextChapter = await readerService.GetContinuePoint(1, 1); Assert.Equal("14.9", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnUnreadSingleVolume_WhenThereAreSomeSingleVolumesBeforeLooseLeafChapters() + { + await ResetDb(); + var readChapter1 = EntityFactory.CreateChapter("0", false, new List(), 1); + var readChapter2 = EntityFactory.CreateChapter("0", false, new List(), 1); + + var volume = EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("51", false, new List(), 1), + EntityFactory.CreateChapter("52", false, new List(), 1), + EntityFactory.CreateChapter("53", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + readChapter1 + }), + EntityFactory.CreateVolume("2", new List() + { + readChapter2 + }), + volume, + // 3, 4, and all loose leafs are unread should be unread + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("0", false, new List(), 1), + }), + EntityFactory.CreateVolume("4", new List() + { + EntityFactory.CreateChapter("40", false, new List(), 1), + EntityFactory.CreateChapter("41", false, new List(), 1), + }), + } + }); + + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + + // Save progress on first volume chapters and 1st of second volume + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress); + await readerService.MarkChaptersAsRead(user, 1, + new List() + { + readChapter1, readChapter2 + }); + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal(4, nextChapter.VolumeId); + } + #endregion #region MarkChaptersUntilAsRead diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs index 599310514..55572aa7e 100644 --- a/API/Comparators/ChapterSortComparer.cs +++ b/API/Comparators/ChapterSortComparer.cs @@ -62,4 +62,5 @@ public class SortComparerZeroLast : IComparer return x.CompareTo(y); } + public static readonly SortComparerZeroLast Default = new SortComparerZeroLast(); } diff --git a/API/Constants/ResponseCacheProfiles.cs b/API/Constants/ResponseCacheProfiles.cs index 050a769c7..fd4127716 100644 --- a/API/Constants/ResponseCacheProfiles.cs +++ b/API/Constants/ResponseCacheProfiles.cs @@ -14,4 +14,5 @@ public static class ResponseCacheProfiles /// Instant is a very quick cache, because we can't bust based on the query params, but rather body /// public const string Instant = "Instant"; + public const string Month = "Month"; } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index c0059e0e5..6a04a129c 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -370,7 +370,7 @@ public class SeriesController : BaseApiController /// /// /// This is cached for an hour - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] {"ageRating"})] + [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] [HttpGet("age-rating")] public ActionResult GetAgeRating(int ageRating) { diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index b93bd124a..48d05ad2e 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; +using Microsoft.AspNetCore.Mvc.RazorPages; namespace API.DTOs; diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 7ca9523e4..6997e5b27 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -482,18 +482,17 @@ public class ReaderService : IReaderService var volumeChapters = volumes .Where(v => v.Number != 0) .SelectMany(v => v.Chapters) - //.OrderBy(c => float.Parse(c.Number)) .ToList(); // NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails // If there are any volumes that have progress, return those. If not, move on. var currentlyReadingChapter = volumeChapters .OrderBy(c => double.Parse(c.Range), _chapterSortComparer) - .FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages); + .FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0); if (currentlyReadingChapter != null) return currentlyReadingChapter; // Order with volume 0 last so we prefer the natural order - return FindNextReadingChapter(volumes.OrderBy(v => v.Number, new SortComparerZeroLast()).SelectMany(v => v.Chapters).ToList()); + return FindNextReadingChapter(volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default).SelectMany(v => v.Chapters).ToList()); } private static ChapterDto FindNextReadingChapter(IList volumeChapters) @@ -511,7 +510,14 @@ public class ReaderService : IReaderService var lastChapter = chaptersWithProgress.ElementAt(last); if (lastChapter.PagesRead < lastChapter.Pages) { - return chaptersWithProgress.ElementAt(last); + return lastChapter; + } + + // If the last chapter didn't fit, then we need the next chapter without any progress + var firstChapterWithoutProgress = volumeChapters.FirstOrDefault(c => c.PagesRead == 0); + if (firstChapterWithoutProgress != null) + { + return firstChapterWithoutProgress; } // chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 3939e580b..8b182e8c0 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -105,12 +105,6 @@ public class StatisticService : IStatisticService .ToListAsync(); - // var averageReadingTimePerWeek = _context.AppUserProgresses - // .Where(p => p.AppUserId == userId) - // .Join(_context.Chapter, p => p.ChapterId, c => c.Id, - // (p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead) - // .Average() / 7.0; - var averageReadingTimePerWeek = _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Join(_context.Chapter, p => p.ChapterId, c => c.Id, @@ -168,8 +162,6 @@ public class StatisticService : IStatisticService .ToListAsync(); } - - public async Task>> GetPublicationCount() { return await _context.SeriesMetadata @@ -196,7 +188,6 @@ public class StatisticService : IStatisticService .ToListAsync(); } - public async Task GetServerStatistics() { var mostActiveUsers = _context.AppUserProgresses diff --git a/API/Startup.cs b/API/Startup.cs index 785decd89..b20025815 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -60,10 +60,22 @@ public class Startup services.AddControllers(options => { - options.CacheProfiles.Add(ResponseCacheProfiles.Images, + options.CacheProfiles.Add(ResponseCacheProfiles.Instant, new CacheProfile() { - Duration = 60, + Duration = 30, + Location = ResponseCacheLocation.None, + }); + options.CacheProfiles.Add(ResponseCacheProfiles.FiveMinute, + new CacheProfile() + { + Duration = 60 * 5, + Location = ResponseCacheLocation.None, + }); + options.CacheProfiles.Add(ResponseCacheProfiles.TenMinute, + new CacheProfile() + { + Duration = 60 * 10, Location = ResponseCacheLocation.None, NoStore = false }); @@ -74,30 +86,25 @@ public class Startup Location = ResponseCacheLocation.None, NoStore = false }); - options.CacheProfiles.Add(ResponseCacheProfiles.TenMinute, - new CacheProfile() - { - Duration = 60 * 10, - Location = ResponseCacheLocation.None, - NoStore = false - }); - options.CacheProfiles.Add(ResponseCacheProfiles.FiveMinute, - new CacheProfile() - { - Duration = 60 * 5, - Location = ResponseCacheLocation.None, - }); options.CacheProfiles.Add(ResponseCacheProfiles.Statistics, new CacheProfile() { Duration = 60 * 60 * 6, Location = ResponseCacheLocation.None, }); - options.CacheProfiles.Add(ResponseCacheProfiles.Instant, + options.CacheProfiles.Add(ResponseCacheProfiles.Images, new CacheProfile() { - Duration = 30, + Duration = 60, Location = ResponseCacheLocation.None, + NoStore = false + }); + options.CacheProfiles.Add(ResponseCacheProfiles.Month, + new CacheProfile() + { + Duration = TimeSpan.FromDays(30).Seconds, + Location = ResponseCacheLocation.Client, + NoStore = false }); }); services.Configure(options => diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index 7c9bf8478..6ae2cb2e2 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -9,6 +9,8 @@ const keySize = 25; // Height of the JumpBar button export class JumpbarService { resumeKeys: {[key: string]: string} = {}; + // Used for custom filtered urls + resumeScroll: {[key: string]: number} = {}; constructor() { } @@ -18,10 +20,19 @@ export class JumpbarService { return ''; } + getResumePosition(key: string) { + if (this.resumeScroll.hasOwnProperty(key)) return this.resumeScroll[key]; + return 0; + } + saveResumeKey(key: string, value: string) { this.resumeKeys[key] = value; } + saveScrollOffset(key: string, value: number) { + this.resumeScroll[key] = value; + } + generateJumpBar(jumpBarKeys: Array, currentSize: number) { const fullSize = (jumpBarKeys.length * keySize); if (currentSize >= fullSize) { diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index 9e4ac5569..1ab66ce7a 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -14,6 +14,7 @@ import { Pagination } from 'src/app/_models/pagination'; import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/metadata/series-filter'; import { ActionItem } from 'src/app/_services/action-factory.service'; import { JumpbarService } from 'src/app/_services/jumpbar.service'; +import { ScrollService } from 'src/app/_services/scroll.service'; @Component({ selector: 'app-card-detail-layout', @@ -74,7 +75,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { constructor(private filterUtilitySerivce: FilterUtilitiesService, public utilityService: UtilityService, @Inject(DOCUMENT) private document: Document, private changeDetectionRef: ChangeDetectorRef, - private jumpbarService: JumpbarService, private router: Router) { + private jumpbarService: JumpbarService, private router: Router, private scrollService: ScrollService) { this.filter = this.filterUtilitySerivce.createSeriesFilter(); this.changeDetectionRef.markForCheck(); @@ -117,7 +118,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { this.resizeJumpBar(); // Don't resume jump key when there is a custom sort order, as it won't work - if (this.hasCustomSort()) { + if (!this.hasCustomSort()) { if (!this.hasResumedJumpKey && this.jumpBarKeysToRender.length > 0) { const resumeKey = this.jumpbarService.getResumeKey(this.router.url); if (resumeKey === '') return; @@ -127,6 +128,13 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { this.hasResumedJumpKey = true; setTimeout(() => this.scrollTo(keys[0]), 100); } + } else { + // I will come back and refactor this to work + // const scrollPosition = this.jumpbarService.getResumePosition(this.router.url); + // console.log('scroll position: ', scrollPosition); + // if (scrollPosition > 0) { + // setTimeout(() => this.virtualScroller.scrollToIndex(scrollPosition, true, 0, 1000), 100); + // } } } @@ -164,6 +172,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges { this.virtualScroller.scrollToIndex(targetIndex, true, 0, 1000); this.jumpbarService.saveResumeKey(this.router.url, jumpKey.key); + // TODO: This doesn't work, we need the offset from virtual scroller + this.jumpbarService.saveScrollOffset(this.router.url, this.scrollService.scrollPosition); this.changeDetectionRef.markForCheck(); }