diff --git a/API.Tests/BasicTest.cs b/API.Tests/AbstractDbTest.cs similarity index 87% rename from API.Tests/BasicTest.cs rename to API.Tests/AbstractDbTest.cs index fb2f2bbf0..6f59b55e9 100644 --- a/API.Tests/BasicTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -17,9 +17,9 @@ using NSubstitute; namespace API.Tests; -public abstract class BasicTest +public abstract class AbstractDbTest { - private readonly DbConnection _connection; + protected readonly DbConnection _connection; protected readonly DataContext _context; protected readonly IUnitOfWork _unitOfWork; @@ -30,8 +30,9 @@ public abstract class BasicTest protected const string LogDirectory = "C:/kavita/config/logs/"; protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; protected const string TempDirectory = "C:/kavita/config/temp/"; + protected const string DataDirectory = "C:/data/"; - protected BasicTest() + protected AbstractDbTest() { var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) @@ -50,13 +51,12 @@ public abstract class BasicTest private static DbConnection CreateInMemoryDatabase() { var connection = new SqliteConnection("Filename=:memory:"); - connection.Open(); return connection; } - private async Task SeedDb() + protected async Task SeedDb() { await _context.Database.MigrateAsync(); var filesystem = CreateFileSystem(); @@ -91,14 +91,7 @@ public abstract class BasicTest return await _context.SaveChangesAsync() > 0; } - protected async Task ResetDb() - { - _context.Series.RemoveRange(_context.Series.ToList()); - _context.Users.RemoveRange(_context.Users.ToList()); - _context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList()); - - await _context.SaveChangesAsync(); - } + protected abstract Task ResetDb(); protected static MockFileSystem CreateFileSystem() { @@ -111,7 +104,7 @@ public abstract class BasicTest fileSystem.AddDirectory(BookmarkDirectory); fileSystem.AddDirectory(LogDirectory); fileSystem.AddDirectory(TempDirectory); - fileSystem.AddDirectory("C:/data/"); + fileSystem.AddDirectory(DataDirectory); return fileSystem; } diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 0ffb61bb8..77fa9ac1f 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; +using API.DTOs.Filtering; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -28,70 +29,14 @@ using Xunit; namespace API.Tests.Services; -public class CleanupServiceTests +public class CleanupServiceTests : AbstractDbTest { private readonly ILogger _logger = Substitute.For>(); - private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _messageHub = Substitute.For(); - private readonly DbConnection _connection; - private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string LogDirectory = "C:/kavita/config/logs/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - - - public CleanupServiceTests() + public CleanupServiceTests() : base() { - var contextOptions = new DbContextOptionsBuilder() - .UseSqlite(CreateInMemoryDatabase()) - .Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); - - var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); - - _unitOfWork = new UnitOfWork(_context, mapper, null); - } - - #region Setup - - private static DbConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - - connection.Open(); - - return connection; - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); - - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); - - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); - setting.Value = BookmarkDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); - setting.Value = "10"; - - _context.ServerSetting.Update(setting); - _context.Library.Add(new Library() { Name = "Manga", @@ -103,10 +48,12 @@ public class CleanupServiceTests } } }); - return await _context.SaveChangesAsync() > 0; } - private async Task ResetDB() + #region Setup + + + protected override async Task ResetDb() { _context.Series.RemoveRange(_context.Series.ToList()); _context.Users.RemoveRange(_context.Users.ToList()); @@ -115,20 +62,6 @@ public class CleanupServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion #region DeleteSeriesCoverImages @@ -142,7 +75,7 @@ public class CleanupServiceTests filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData("")); // Delete all Series to reset state - await ResetDB(); + await ResetDb(); var s = DbFactory.Series("Test 1"); s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; @@ -175,7 +108,7 @@ public class CleanupServiceTests filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData("")); // Delete all Series to reset state - await ResetDB(); + await ResetDb(); // Add 2 series with cover images var s = DbFactory.Series("Test 1"); @@ -209,7 +142,7 @@ public class CleanupServiceTests filesystem.AddFile($"{CoverImageDirectory}v01_c1000.jpg", new MockFileData("")); // Delete all Series to reset state - await ResetDB(); + await ResetDb(); // Add 2 series with cover images var s = DbFactory.Series("Test 1"); @@ -259,7 +192,7 @@ public class CleanupServiceTests filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); // Delete all Series to reset state - await ResetDB(); + await ResetDb(); // Add 2 series with cover images var s = DbFactory.Series("Test 1"); @@ -307,7 +240,7 @@ public class CleanupServiceTests filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(3)}.jpg", new MockFileData("")); // Delete all Series to reset state - await ResetDB(); + await ResetDb(); _context.Users.Add(new AppUser() { @@ -569,6 +502,62 @@ public class CleanupServiceTests } #endregion + + #region CleanupWantToRead + + [Fact] + public async Task CleanupWantToRead_ShouldRemoveFullyReadSeries() + { + await ResetDb(); + + var s = new Series() + { + Name = "Test CleanupWantToRead_ShouldRemoveFullyReadSeries", + Library = new Library() + { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List(), + Metadata = new SeriesMetadata() + { + PublicationStatus = PublicationStatus.Completed + } + }; + _context.Series.Add(s); + + var user = new AppUser() + { + UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries", + WantToRead = new List() + { + s + } + }; + _context.AppUser.Add(user); + + await _unitOfWork.CommitAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For()); + + await readerService.MarkSeriesAsRead(user, s.Id); + await _unitOfWork.CommitAsync(); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + + await cleanupService.CleanupWantToRead(); + + var wantToRead = + await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto()); + + Assert.Equal(0, wantToRead.TotalCount); + } + #endregion + // #region CleanupBookmarks // // [Fact] @@ -579,7 +568,7 @@ public class CleanupServiceTests // filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData("")); // // // Delete all Series to reset state - // await ResetDB(); + // await ResetDb(); // // _context.Series.Add(new Series() // { @@ -651,7 +640,7 @@ public class CleanupServiceTests // filesystem.AddFile($"{BookmarkDirectory}1/1/2/0002.jpg", new MockFileData("")); // // // Delete all Series to reset state - // await ResetDB(); + // await ResetDb(); // // _context.Series.Add(new Series() // { diff --git a/API.Tests/Services/DeviceServiceTests.cs b/API.Tests/Services/DeviceServiceTests.cs index 717f3e98b..5e3b65522 100644 --- a/API.Tests/Services/DeviceServiceTests.cs +++ b/API.Tests/Services/DeviceServiceTests.cs @@ -12,20 +12,20 @@ using Xunit; namespace API.Tests.Services; -public class DeviceServiceTests : BasicTest +public class DeviceServiceDbTests : AbstractDbTest { private readonly ILogger _logger = Substitute.For>(); private readonly IDeviceService _deviceService; - public DeviceServiceTests() : base() + public DeviceServiceDbTests() : base() { _deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For()); } - protected new Task ResetDb() + protected override async Task ResetDb() { _context.Users.RemoveRange(_context.Users.ToList()); - return Task.CompletedTask; + await _unitOfWork.CommitAsync(); } diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 384d22a88..f7a7d2112 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1429,6 +1429,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1479,6 +1480,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1_WithProgress() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1524,6 +1526,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstNonSpecial() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1596,6 +1599,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1674,6 +1678,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstSpecial() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1743,6 +1748,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1785,6 +1791,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesAndAFewLooseChaptersRead() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1851,6 +1858,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1914,6 +1922,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllReadAndAllChapters() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -1959,6 +1968,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstSpecial_WhenAllReadAndAllChapters() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -2020,6 +2030,7 @@ public class ReaderServiceTests [Fact] public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress() { + await ResetDb(); var series = new Series() { Name = "Test", @@ -2079,6 +2090,7 @@ public class ReaderServiceTests [Fact] public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -2121,6 +2133,7 @@ public class ReaderServiceTests [Fact] public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", @@ -2165,6 +2178,7 @@ public class ReaderServiceTests [Fact] public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0() { + await ResetDb(); _context.Series.Add(new Series() { Name = "Test", diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 6472f8fb1..23c54c5a0 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -181,6 +181,96 @@ public class ReadingListServiceTests Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order); } + [Fact] + public async Task UpdateReadingListItemPosition_MoveLastToFirst_TwoItemsShouldShift_ThenDeleteSecond_OrderShouldBeCorrect() + { + await ResetDb(); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new Library() + { + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List() + { + new Series() + { + Name = "Test", + Metadata = DbFactory.SeriesMetadata(new List()), + Volumes = new List() + { + new Volume() + { + Name = "0", + Chapters = new List() + { + new Chapter() + { + Number = "1", + AgeRating = AgeRating.Everyone, + }, + new Chapter() + { + Number = "2", + AgeRating = AgeRating.X18Plus + }, + new Chapter() + { + Number = "3", + AgeRating = AgeRating.X18Plus + } + } + } + } + } + } + }, + } + }); + + await _context.SaveChangesAsync(); + + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists); + var readingList = new ReadingList(); + user.ReadingLists = new List() + { + readingList + }; + + // Existing (order, chapterId): (0, 1), (1, 2), (2, 3) + await _readingListService.AddChaptersToReadingList(1, new List() {1, 2, 3}, readingList); + await _unitOfWork.CommitAsync(); + Assert.Equal(3, readingList.Items.Count); + + // From 3 to 1 + // New (order, chapterId): (0, 3), (1, 2), (2, 1) + await _readingListService.UpdateReadingListItemPosition(new UpdateReadingListPosition() + { + FromPosition = 2, ToPosition = 0, ReadingListId = 1, ReadingListItemId = 3 + }); + + + + Assert.Equal(3, readingList.Items.Count); + Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order); + Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order); + Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order); + + // New (order, chapterId): (0, 3), (2, 1): Delete 2nd item + await _readingListService.DeleteReadingListItem(new UpdateReadingListPosition() + { + ReadingListId = 1, ReadingListItemId = readingList.Items.Single(i => i.ChapterId == 2).Id + }); + + Assert.Equal(2, readingList.Items.Count); + Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order); + Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order); + } + #endregion diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 33f3595fe..4d9bbbfdd 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -28,80 +28,18 @@ using Xunit; namespace API.Tests.Services; -public class SeriesServiceTests +public class SeriesServiceTests : AbstractDbTest { - private readonly IUnitOfWork _unitOfWork; - - private readonly DbConnection _connection; - private readonly DataContext _context; - private readonly ISeriesService _seriesService; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - - public SeriesServiceTests() + public SeriesServiceTests() : base() { - var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); - - var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); - _unitOfWork = new UnitOfWork(_context, mapper, null); - _seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>()); } #region Setup - private static DbConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - - connection.Open(); - - return connection; - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); - - await Seed.SeedSettings(_context, - new DirectoryService(Substitute.For>(), filesystem)); - - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; - - _context.ServerSetting.Update(setting); - - // var lib = new Library() - // { - // Name = "Manga", Folders = new List() {new FolderPath() {Path = "C:/data/"}} - // }; - // - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007", - // Libraries = new List() - // { - // lib - // } - // }); - - return await _context.SaveChangesAsync() > 0; - } - - private async Task ResetDb() + protected override async Task ResetDb() { _context.Series.RemoveRange(_context.Series.ToList()); _context.AppUserRating.RemoveRange(_context.AppUserRating.ToList()); @@ -113,19 +51,6 @@ public class SeriesServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - private static UpdateRelatedSeriesDto CreateRelationsDto(Series series) { return new UpdateRelatedSeriesDto() @@ -1465,7 +1390,7 @@ public class SeriesServiceTests public async Task SeriesRelation_ShouldAllowDeleteOnLibrary() { await ResetDb(); - _context.Library.Add(new Library() + var lib = new Library() { AppUsers = new List() { @@ -1481,20 +1406,21 @@ public class SeriesServiceTests new Series() { Name = "Test Series", - Volumes = new List(){} + Volumes = new List() { } }, new Series() { Name = "Test Series Prequels", - Volumes = new List(){} + Volumes = new List() { } }, new Series() { Name = "Test Series Sequels", - Volumes = new List(){} + Volumes = new List() { } } } - }); + }; + _context.Library.Add(lib); await _context.SaveChangesAsync(); @@ -1505,7 +1431,7 @@ public class SeriesServiceTests addRelationDto.Sequels.Add(3); await _seriesService.UpdateRelatedSeries(addRelationDto); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id); _unitOfWork.LibraryRepository.Delete(library); try @@ -1524,7 +1450,7 @@ public class SeriesServiceTests public async Task SeriesRelation_ShouldAllowDeleteOnLibrary_WhenSeriesCrossLibraries() { await ResetDb(); - _context.Library.Add(new Library() + var lib1 = new Library() { AppUsers = new List() { @@ -1564,17 +1490,17 @@ public class SeriesServiceTests new Series() { Name = "Test Series Prequels", - Volumes = new List(){} + Volumes = new List() { } }, new Series() { Name = "Test Series Sequels", - Volumes = new List(){} + Volumes = new List() { } } } - }); - - _context.Library.Add(new Library() + }; + _context.Library.Add(lib1); + var lib2 = new Library() { AppUsers = new List() { @@ -1590,20 +1516,21 @@ public class SeriesServiceTests new Series() { Name = "Test Series 2", - Volumes = new List(){} + Volumes = new List() { } }, new Series() { Name = "Test Series Prequels 2", - Volumes = new List(){} + Volumes = new List() { } }, new Series() { Name = "Test Series Sequels 2", - Volumes = new List(){} + Volumes = new List() { } } } - }); + }; + _context.Library.Add(lib2); await _context.SaveChangesAsync(); @@ -1613,7 +1540,7 @@ public class SeriesServiceTests addRelationDto.Adaptations.Add(4); // cross library link await _seriesService.UpdateRelatedSeries(addRelationDto); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Series); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib1.Id, LibraryIncludes.Series); _unitOfWork.LibraryRepository.Delete(library); try diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index a9a2efa97..03fbc335e 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -32,7 +32,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("chapter-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId"})] public async Task GetChapterCoverImage(int chapterId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); @@ -48,7 +48,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("library-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId"})] public async Task GetLibraryCoverImage(int libraryId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId)); @@ -64,7 +64,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("volume-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId"})] public async Task GetVolumeCoverImage(int volumeId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); @@ -79,7 +79,7 @@ public class ImageController : BaseApiController /// /// Id of Series /// - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId"})] [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId) { @@ -98,7 +98,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("collection-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId"})] public async Task GetCollectionCoverImage(int collectionTagId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); @@ -114,7 +114,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("readinglist-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId"})] public async Task GetReadingListCoverImage(int readingListId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); @@ -133,7 +133,7 @@ public class ImageController : BaseApiController /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})] public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -155,7 +155,7 @@ public class ImageController : BaseApiController /// [Authorize(Policy="RequireAdminRole")] [HttpGet("cover-upload")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"filename"})] public ActionResult GetCoverUploadImage(string filename) { if (filename.Contains("..")) return BadRequest("Invalid Filename"); diff --git a/API/DTOs/Statistics/ServerStatisticsDto.cs b/API/DTOs/Statistics/ServerStatisticsDto.cs index 3bdeef9f3..d727e3227 100644 --- a/API/DTOs/Statistics/ServerStatisticsDto.cs +++ b/API/DTOs/Statistics/ServerStatisticsDto.cs @@ -13,6 +13,7 @@ public class ServerStatisticsDto public long TotalGenres { get; set; } public long TotalTags { get; set; } public long TotalPeople { get; set; } + public long TotalReadingTime { get; set; } public IEnumerable> MostReadSeries { get; set; } /// /// Total users who have started/reading/read per series diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 58700a770..629b3da6d 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -145,4 +145,39 @@ public class ServerInfoDto /// /// Introduced in v0.6.0 public bool UsingRestrictedProfiles { get; set; } + /// + /// Number of users using the Emulate Comic Book setting + /// + /// Introduced in v0.7.0 + public int UsersWithEmulateComicBook { get; set; } + /// + /// Percent (0.0-1.0) of libraries with folder watching enabled + /// + /// Introduced in v0.7.0 + public float PercentOfLibrariesWithFolderWatchingEnabled { get; set; } + /// + /// Percent (0.0-1.0) of libraries included in Search + /// + /// Introduced in v0.7.0 + public float PercentOfLibrariesIncludedInSearch { get; set; } + /// + /// Percent (0.0-1.0) of libraries included in Recommended + /// + /// Introduced in v0.7.0 + public float PercentOfLibrariesIncludedInRecommended { get; set; } + /// + /// Percent (0.0-1.0) of libraries included in Dashboard + /// + /// Introduced in v0.7.0 + public float PercentOfLibrariesIncludedInDashboard { get; set; } + /// + /// Total reading hours of all users + /// + /// Introduced in v0.7.0 + public long TotalReadingHours { get; set; } + /// + /// Is the Server saving covers as WebP + /// + /// Added in v0.7.0 + public bool StoreCoversAsWebP { get; set; } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 0b3e924bf..e99449330 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -184,7 +184,8 @@ public class SeriesRepository : ISeriesRepository return await _context.Series .Where(s => s.LibraryId == libraryId) .Includes(includes) - .OrderBy(s => s.SortName).ToListAsync(); + .OrderBy(s => s.SortName.ToLower()) + .ToListAsync(); } /// @@ -223,7 +224,7 @@ public class SeriesRepository : ISeriesRepository .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Files) .AsSplitQuery() - .OrderBy(s => s.SortName); + .OrderBy(s => s.SortName.ToLower()); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } @@ -316,7 +317,7 @@ public class SeriesRepository : ISeriesRepository .Where(l => libraryIds.Contains(l.Id)) .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .IsRestricted(QueryContext.Search) - .OrderBy(l => l.Name) + .OrderBy(l => l.Name.ToLower()) .AsSplitQuery() .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) @@ -335,7 +336,7 @@ public class SeriesRepository : ISeriesRepository || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) .RestrictAgainstAgeRestriction(userRating) .Include(s => s.Library) - .OrderBy(s => s.SortName) + .OrderBy(s => s.SortName.ToLower()) .AsNoTracking() .AsSplitQuery() .Take(maxRecords) @@ -356,7 +357,7 @@ public class SeriesRepository : ISeriesRepository || EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")) .Where(c => c.Promoted || isAdmin) .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.Title) + .OrderBy(s => s.NormalizedTitle) .AsNoTracking() .AsSplitQuery() .Take(maxRecords) @@ -377,7 +378,7 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .AsSplitQuery() - .OrderBy(t => t.Title) + .OrderBy(t => t.NormalizedTitle) .Distinct() .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) @@ -387,7 +388,7 @@ public class SeriesRepository : ISeriesRepository .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .AsSplitQuery() - .OrderBy(t => t.Title) + .OrderBy(t => t.NormalizedTitle) .Distinct() .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) @@ -719,7 +720,8 @@ public class SeriesRepository : ISeriesRepository }) .Where(s => s.PagesRead > 0 && s.PagesRead < s.Series.Pages) - .Where(d => d.LatestReadDate >= cutoffProgressPoint || d.LastChapterAdded >= cutoffLastAddedPoint).OrderByDescending(s => s.LatestReadDate) + .Where(d => d.LatestReadDate >= cutoffProgressPoint || d.LastChapterAdded >= cutoffLastAddedPoint) + .OrderByDescending(s => s.LatestReadDate) .ThenByDescending(s => s.LastChapterAdded) .Select(s => s.Series) .ProjectTo(_mapper.ConfigurationProvider) @@ -777,7 +779,7 @@ public class SeriesRepository : ISeriesRepository { query = filter.SortOptions.SortField switch { - SortField.SortName => query.OrderBy(s => s.SortName), + SortField.SortName => query.OrderBy(s => s.SortName.ToLower()), SortField.CreatedDate => query.OrderBy(s => s.Created), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), @@ -790,7 +792,7 @@ public class SeriesRepository : ISeriesRepository { query = filter.SortOptions.SortField switch { - SortField.SortName => query.OrderByDescending(s => s.SortName), + SortField.SortName => query.OrderByDescending(s => s.SortName.ToLower()), SortField.CreatedDate => query.OrderByDescending(s => s.Created), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), @@ -844,7 +846,7 @@ public class SeriesRepository : ISeriesRepository { query = filter.SortOptions.SortField switch { - SortField.SortName => query.OrderBy(s => s.SortName), + SortField.SortName => query.OrderBy(s => s.SortName.ToLower()), SortField.CreatedDate => query.OrderBy(s => s.Created), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), @@ -856,7 +858,7 @@ public class SeriesRepository : ISeriesRepository { query = filter.SortOptions.SortField switch { - SortField.SortName => query.OrderByDescending(s => s.SortName), + SortField.SortName => query.OrderByDescending(s => s.SortName.ToLower()), SortField.CreatedDate => query.OrderByDescending(s => s.Created), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), @@ -887,7 +889,7 @@ public class SeriesRepository : ISeriesRepository .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() - .OrderBy(t => t.Title) + .OrderBy(t => t.Title.ToLower()) .AsSplitQuery() .ToListAsync(); } @@ -911,7 +913,7 @@ public class SeriesRepository : ISeriesRepository .ThenInclude(m => m.Series) .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) .OrderBy(s => s.LibraryId) - .ThenBy(s => s.SortName) + .ThenBy(s => s.SortName.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .AsNoTracking(); @@ -941,7 +943,7 @@ public class SeriesRepository : ISeriesRepository return await _context.Series .Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId)) - .OrderBy(s => s.SortName) + .OrderBy(s => s.SortName.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() .AsSplitQuery() diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 25c0a1db8..6a8d8676f 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -299,6 +299,8 @@ public class ArchiveService : IArchiveService try { ZipFile.CreateFromDirectory(tempLocation, zipPath); + // Remove the folder as we have the zip + _directoryService.ClearAndDeleteDirectory(tempLocation); } catch (AggregateException ex) { diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 8409e6a36..3eabe5b78 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -575,39 +575,20 @@ public class ReaderService : IReaderService { var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0); var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0); - if (maxHours < minHours) - { - return new HourEstimateRangeDto - { - MinHours = maxHours, - MaxHours = minHours, - AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)) - }; - } return new HourEstimateRangeDto { - MinHours = minHours, - MaxHours = maxHours, + MinHours = Math.Min(minHours, maxHours), + MaxHours = Math.Max(minHours, maxHours), AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)) }; } var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0); var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0); - if (maxHoursPages < minHoursPages) - { - return new HourEstimateRangeDto - { - MinHours = maxHoursPages, - MaxHours = minHoursPages, - AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)) - }; - } - return new HourEstimateRangeDto { - MinHours = minHoursPages, - MaxHours = maxHoursPages, + MinHours = Math.Min(minHoursPages, maxHoursPages), + MaxHours = Math.Max(minHoursPages, maxHoursPages), AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)) }; } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 81c512756..821bb1a69 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -107,7 +107,7 @@ public class ReadingListService : IReadingListService public async Task DeleteReadingListItem(UpdateReadingListPosition dto) { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); - readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).ToList(); + readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).OrderBy(r => r.Order).ToList(); var index = 0; foreach (var readingListItem in readingList.Items) diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index c770c4dce..e6ace9403 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -32,6 +32,7 @@ public interface IStatisticService IEnumerable> GetPagesReadCountByYear(int userId = 0); IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); + Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); } /// @@ -62,18 +63,20 @@ public class StatisticService : IStatisticService .Where(p => libraryIds.Contains(p.LibraryId)) .SumAsync(p => p.PagesRead); - var ids = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId) - .Where(p => libraryIds.Contains(p.LibraryId)) - .Where(p => p.PagesRead > 0) - .Select(p => new {p.ChapterId, p.SeriesId}) - .ToListAsync(); + // var ids = await _context.AppUserProgresses + // .Where(p => p.AppUserId == userId) + // .Where(p => libraryIds.Contains(p.LibraryId)) + // .Where(p => p.PagesRead > 0) + // .Select(p => new {p.ChapterId, p.SeriesId}) + // .ToListAsync(); - var chapterIds = ids.Select(id => id.ChapterId); + //var chapterIds = ids.Select(id => id.ChapterId); - var timeSpentReading = await _context.Chapter - .Where(c => chapterIds.Contains(c.Id)) - .SumAsync(c => c.AvgHoursToRead); + // var timeSpentReading = await _context.Chapter + // .Where(c => chapterIds.Contains(c.Id)) + // .SumAsync(c => c.AvgHoursToRead); + + var timeSpentReading = await TimeSpentReadingForUsersAsync(new List() {userId}, libraryIds); var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses .Where(p => p.AppUserId == userId) @@ -275,6 +278,8 @@ public class StatisticService : IStatisticService .Distinct() .Count(); + + return new ServerStatisticsDto() { ChapterCount = await _context.Chapter.CountAsync(), @@ -289,7 +294,8 @@ public class StatisticService : IStatisticService MostActiveLibraries = mostActiveLibrary, MostPopularSeries = mostPopularSeries, MostReadSeries = mostReadSeries, - RecentlyRead = recentlyRead + RecentlyRead = recentlyRead, + TotalReadingTime = await TimeSpentReadingForUsersAsync(ArraySegment.Empty, ArraySegment.Empty) }; } @@ -483,6 +489,30 @@ public class StatisticService : IStatisticService await _unitOfWork.CommitAsync(); } + public async Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds) + { + var query = _context.AppUserProgresses + .AsSplitQuery(); + + if (userIds.Any()) + { + query = query.Where(p => userIds.Contains(p.AppUserId)); + } + if (libraryIds.Any()) + { + query = query.Where(p => libraryIds.Contains(p.LibraryId)); + } + + return (long) Math.Round(await query + .Join(_context.Chapter, + p => p.ChapterId, + c => c.Id, + (progress, chapter) => new {chapter, progress}) + .Where(p => p.chapter.AvgHoursToRead > 0) + .SumAsync(p => + p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 2e02187d1..ada691b21 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -223,7 +223,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } - if (series.WordCount == 0 && series.WordCount != 0) series.WordCount = existingWordCount; // Restore original word count if the file hasn't changed + if (series.WordCount == 0 && existingWordCount != 0) series.WordCount = existingWordCount; // Restore original word count if the file hasn't changed var seriesEstimate = _readerService.GetTimeEstimate(series.WordCount, series.Pages, isEpub); series.MinHoursToRead = seriesEstimate.MinHours; series.MaxHoursToRead = seriesEstimate.MaxHours; diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 5cd6510d0..ba27508e9 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -25,18 +25,23 @@ public interface IStatsService Task GetServerInfo(); Task SendCancellation(); } +/// +/// This is for reporting to the stat server +/// public class StatsService : IStatsService { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly DataContext _context; + private readonly IStatisticService _statisticService; private const string ApiUrl = "https://stats.kavitareader.com"; - public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context) + public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) { _logger = logger; _unitOfWork = unitOfWork; _context = context; + _statisticService = statisticService; FlurlHttp.ConfigureClient(ApiUrl, cli => cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); @@ -116,6 +121,14 @@ public class StatsService : IStatsService DotnetVersion = Environment.Version.ToString(), IsDocker = new OsInfo().IsDocker, NumOfCores = Math.Max(Environment.ProcessorCount, 1), + UsersWithEmulateComicBook = await _context.AppUserPreferences.CountAsync(p => p.EmulateBook), + TotalReadingHours = await _statisticService.TimeSpentReadingForUsersAsync(ArraySegment.Empty, ArraySegment.Empty), + + PercentOfLibrariesWithFolderWatchingEnabled = await GetPercentageOfLibrariesWithFolderWatchingEnabled(), + PercentOfLibrariesIncludedInRecommended = await GetPercentageOfLibrariesIncludedInRecommended(), + PercentOfLibrariesIncludedInDashboard = await GetPercentageOfLibrariesIncludedInDashboard(), + PercentOfLibrariesIncludedInSearch = await GetPercentageOfLibrariesIncludedInSearch(), + HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), @@ -127,6 +140,7 @@ public class StatsService : IStatsService TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), UsingSeriesRelationships = await GetIfUsingSeriesRelationship(), StoreBookmarksAsWebP = serverSettings.ConvertBookmarkToWebP, + StoreCoversAsWebP = serverSettings.ConvertCoverToWebP, MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(), MaxVolumesInASeries = await MaxVolumesInASeries(), MaxChaptersInASeries = await MaxChaptersInASeries(), @@ -190,6 +204,30 @@ public class StatsService : IStatsService } } + private async Task GetPercentageOfLibrariesWithFolderWatchingEnabled() + { + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + return libraries.Count(l => l.FolderWatching) / (1.0f * libraries.Count); + } + + private async Task GetPercentageOfLibrariesIncludedInRecommended() + { + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + return libraries.Count(l => l.IncludeInRecommended) / (1.0f * libraries.Count); + } + + private async Task GetPercentageOfLibrariesIncludedInDashboard() + { + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + return libraries.Count(l => l.IncludeInDashboard) / (1.0f * libraries.Count); + } + + private async Task GetPercentageOfLibrariesIncludedInSearch() + { + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + return libraries.Count(l => l.IncludeInSearch) / (1.0f * libraries.Count); + } + private Task GetIfUsingSeriesRelationship() { return _context.SeriesRelation.AnyAsync(); @@ -242,6 +280,7 @@ public class StatsService : IStatsService return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync(); } + private async Task> AllMangaReaderLayoutModes() { return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync(); diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index a877a0df8..6609018e8 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -64,15 +64,23 @@ export class ThemeService implements OnDestroy { getColorScheme() { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } - - /** - * --theme-color from theme. Updates the meta tag - * @returns - */ - getThemeColor() { - return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); - } - + + /** + * --theme-color from theme. Updates the meta tag + * @returns + */ + getThemeColor() { + return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); + } + + /** + * --msapplication-TileColor from theme. Updates the meta tag + * @returns + */ + getTileColor() { + return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim(); + } + getCssVariable(variable: string) { return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); } @@ -155,6 +163,12 @@ export class ThemeService implements OnDestroy { const themeColor = this.getThemeColor(); if (themeColor) { this.document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor); + this.document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]')?.setAttribute('content', themeColor); + } + + const tileColor = this.getTileColor(); + if (themeColor) { + this.document.querySelector('meta[name="msapplication-TileColor"]')?.setAttribute('content', themeColor); } const colorScheme = this.getColorScheme(); diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 54f22e1dc..54f651a79 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -973,11 +973,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } triggerSwipePagination(direction: KeyDirection) { - - if (this.readingDirection === ReadingDirection.LeftToRight) { - if (direction === KeyDirection.Right) - this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage(); - } switch(direction) { case KeyDirection.Down: this.nextPage(); @@ -996,8 +991,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } onSwipeEnd(event: SwipeEvent) { - const threshold = .12; - // Positive number means swiping right/down, negative means left switch (this.readerMode) { case ReaderMode.Webtoon: break; @@ -1014,6 +1007,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { // We just came from a swipe where pagination was required and we are now at the end of the swipe, so make the user do it once more if (direction === KeyDirection.Right) { this.hasHitZeroScroll = false; + if (scrollLeft === 0 && this.ReadingAreaWidth === 0) { + this.triggerSwipePagination(direction); + return; + } if (!this.hasHitRightScroll && this.checkIfPaginationAllowed(direction)) { this.hasHitRightScroll = true; return; @@ -1036,7 +1033,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } - console.log('Next page triggered'); this.triggerSwipePagination(direction); break; } @@ -1072,32 +1068,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { return; } - console.log('Next page triggered'); this.triggerSwipePagination(direction); break; - - - - - - - const height = (this.readingArea?.nativeElement.scrollHeight === this.readingArea?.nativeElement.clientHeight) - ? this.readingArea?.nativeElement.clientHeight : this.ReadingAreaHeight; - - if (direction === KeyDirection.Down && this.readingArea?.nativeElement?.scrollTop === height && this.prevScrollTop != 0) { - this.prevScrollTop = 0; - return; - } - - if (direction === KeyDirection.Up && this.readingArea?.nativeElement?.scrollTop === 0 && this.prevScrollTop != 0) { - this.prevScrollTop = 0; - return; - } - - const thresholdMet = Math.abs(event.distance) >= height * threshold; - if (!thresholdMet) return; - - this.triggerSwipePagination(direction); } } } diff --git a/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts b/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts index a96c15b7e..49641b80c 100644 --- a/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts +++ b/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts @@ -3,7 +3,7 @@ import { PageSplitOption } from 'src/app/_models/preferences/page-split-option'; import { ScalingOption } from 'src/app/_models/preferences/scaling-option'; import { ReaderService } from 'src/app/_services/reader.service'; import { ChapterInfo } from '../_models/chapter-info'; -import { DimensionMap, FileDimension } from '../_models/file-dimension'; +import { DimensionMap } from '../_models/file-dimension'; import { FITTING_OPTION } from '../_models/reader-enums'; @Injectable({ diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html index b69718aec..ca28fc7a6 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html @@ -17,16 +17,7 @@
- - -
- - {{stats.chapterCount | compactNumber}} Chapters - -
-
-
- +
@@ -69,6 +60,15 @@ {{stats.totalPeople | compactNumber}} People
+
+
+ + +
+ + {{stats.totalReadingTime | compactNumber}} Hours + +
@@ -91,28 +91,33 @@ -
- -
- -
-
- + +
+
-
- -
-
-
-
- -
-
-
-
- +
+
+ +
+
+ +
-
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts index 8b261b043..a91abf2fa 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts @@ -1,8 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { map, Observable, shareReplay, Subject, takeUntil, tap } from 'rxjs'; +import { BehaviorSubject, map, Observable, of, shareReplay, Subject, takeUntil, tap } from 'rxjs'; import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service'; +import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { Series } from 'src/app/_models/series'; import { ImageService } from 'src/app/_services/image.service'; import { MetadataService } from 'src/app/_services/metadata.service'; @@ -32,13 +33,27 @@ export class ServerStatsComponent implements OnInit, OnDestroy { this.router.navigate(['library', series.libraryId, 'series', series.id]); } + breakpointSubject = new BehaviorSubject(1); + breakpoint$: Observable = this.breakpointSubject.asObservable(); + + @HostListener('window:resize', ['$event']) + @HostListener('window:orientationchange', ['$event']) + onResize() { + this.breakpointSubject.next(this.utilityService.getActiveBreakpoint()); + } + + + get Breakpoint() { return Breakpoint; } + constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService, - private metadataService: MetadataService, private modalService: NgbModal) { + private metadataService: MetadataService, private modalService: NgbModal, private utilityService: UtilityService) { this.seriesImage = (data: PieDataItem) => { if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id); return ''; } + this.breakpointSubject.next(this.utilityService.getActiveBreakpoint()); + this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay()); this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy)); this.mostActiveUsers$ = this.stats$.pipe( diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html index 13f79ccbf..36d9bc65b 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html @@ -20,7 +20,7 @@
- {{timeSpentReading}} hours + {{timeSpentReading | compactNumber}} hours
diff --git a/UI/Web/src/app/statistics/_models/server-statistics.ts b/UI/Web/src/app/statistics/_models/server-statistics.ts index 7d4612a1a..e46028102 100644 --- a/UI/Web/src/app/statistics/_models/server-statistics.ts +++ b/UI/Web/src/app/statistics/_models/server-statistics.ts @@ -12,6 +12,7 @@ export interface ServerStatistics { totalGenres: number; totalTags: number; totalPeople: number; + totalReadingTime: number; mostActiveUsers: Array>; mostActiveLibraries: Array>; mostReadSeries: Array>; diff --git a/UI/Web/src/index.html b/UI/Web/src/index.html index dfed2c574..0fbecc5a8 100644 --- a/UI/Web/src/index.html +++ b/UI/Web/src/index.html @@ -13,7 +13,7 @@ - + diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 413735eea..a3a4505fc 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -1,7 +1,6 @@ // :root, :root .default { - --theme-color: #000000; - --color-scheme: dark; + /* Base colors */ --primary-color: #4ac694; --primary-color-dark-shade: #3B9E76; --primary-color-darker-shade: #338A67; @@ -11,6 +10,11 @@ --body-text-color: #efefef; --btn-icon-filter: invert(1) grayscale(100%) brightness(200%); --primary-color-scrollbar: rgba(74,198,148,0.75); + + /* Meta and Globals */ + --theme-color: #000000; + --color-scheme: dark; + --tile-color: var(--primary-color); /* Navbar */ @@ -116,7 +120,7 @@ /* List items */ - --list-group-item-text-color: var(--body-text-color); /*rgba(74, 198, 148, 0.9)*/ + --list-group-item-text-color: var(--body-text-color); --list-group-item-bg-color: #343a40; --list-group-item-border-color: rgba(239, 239, 239, 0.125); --list-group-hover-text-color: white; @@ -176,6 +180,7 @@ --ratingstar-star-filled: var(--primary-color); /* Global */ + //--hr-color: transparent; --hr-color: rgba(239, 239, 239, 0.125); --accent-bg-color: rgba(1, 4, 9, 0.5); --accent-text-color: lightgrey; @@ -207,7 +212,6 @@ --manga-reader-overlay-filter: blur(10px); --manga-reader-overlay-bg-color: rgba(0,0,0,0.5); --manga-reader-overlay-text-color: white; - --manga-reader-bg-color: black; // TODO: Remove this --manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5); --manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5); @@ -242,7 +246,4 @@ /* List Card Item */ --card-list-item-bg-color: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%); - - /* Bootstrap overrides */ - --hr-color: transparent; } diff --git a/openapi.json b/openapi.json index 51847c981..6327af78c 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.6.1.26" + "version": "0.6.1.27" }, "servers": [ { @@ -13201,6 +13201,40 @@ "usingRestrictedProfiles": { "type": "boolean", "description": "If there is at least one user that is using an age restricted profile on the instance" + }, + "usersWithEmulateComicBook": { + "type": "integer", + "description": "Number of users using the Emulate Comic Book setting", + "format": "int32" + }, + "percentOfLibrariesWithFolderWatchingEnabled": { + "type": "number", + "description": "Percent (0.0-1.0) of libraries with folder watching enabled", + "format": "float" + }, + "percentOfLibrariesIncludedInSearch": { + "type": "number", + "description": "Percent (0.0-1.0) of libraries included in Search", + "format": "float" + }, + "percentOfLibrariesIncludedInRecommended": { + "type": "number", + "description": "Percent (0.0-1.0) of libraries included in Recommended", + "format": "float" + }, + "percentOfLibrariesIncludedInDashboard": { + "type": "number", + "description": "Percent (0.0-1.0) of libraries included in Dashboard", + "format": "float" + }, + "totalReadingHours": { + "type": "integer", + "description": "Total reading hours of all users", + "format": "int64" + }, + "storeCoversAsWebP": { + "type": "boolean", + "description": "Is the Server saving covers as WebP" } }, "additionalProperties": false, @@ -13323,6 +13357,10 @@ "type": "integer", "format": "int64" }, + "totalReadingTime": { + "type": "integer", + "format": "int64" + }, "mostReadSeries": { "type": "array", "items": {