Swipe Issues (#1745)

* Updated theme support to be able to customize the tile color dynamically from a theme via --tile-color. In addition, --theme-color will update apple-mobile-web-app-status-bar-style as well as the non-apple variants

* Removed --manga-reader-bg-color as it wasn't used anywhere. Fixed double pagination on swipe.

* Cleaned up some dead threshold code for swipe.

* Started refactoring tests to use an abstract test class. Stopping because I should do on the .net 7 branch to avoid large merge conflicts. Tests need to be re-designed so they can run in parallel.

* Fixed a bug in reading lists where when deleting an item, order could be miscalculated.

* Started adding new information for stat service. Refactored time spent reading to be more accurate by taking average time against how much of the chapter the user has read.

* Hooked up total time reading at server stat level. Don't show fancy graphs on mobile.

* Added new stats for v0.7

* Added a test for Clearing want to read

* Fixed a few tests that weren't resetting state between runs

* Fixed some broken unit tests

* Ensure all Series queries sort by a case invariant string.

* Added more aggressive caching of images. This will result in a min delay on pages after a cover is changed.

* Fixed a bug where if during new word count calculation, new word count is zero, restoring the old count wasn't working.

* Cleaned up some of the code for getting time estimates

* Fixed a bug where triggering swipe right wasn't working when there was no scroll

* Delete the temp folder for creating a download after a full zip is created.
This commit is contained in:
Joe Milazzo 2023-01-12 19:24:58 -06:00 committed by GitHub
parent 3d6de68089
commit 549e52b458
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 488 additions and 339 deletions

View File

@ -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<bool> SeedDb()
protected async Task<bool> 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;
}

View File

@ -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<CleanupService> _logger = Substitute.For<ILogger<CleanupService>>();
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
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<AutoMapperProfiles>());
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<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), 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<Volume>(),
Metadata = new SeriesMetadata()
{
PublicationStatus = PublicationStatus.Completed
}
};
_context.Series.Add(s);
var user = new AppUser()
{
UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries",
WantToRead = new List<Series>()
{
s
}
};
_context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>());
await readerService.MarkSeriesAsRead(user, s.Id);
await _unitOfWork.CommitAsync();
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), 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()
// {

View File

@ -12,20 +12,20 @@ using Xunit;
namespace API.Tests.Services;
public class DeviceServiceTests : BasicTest
public class DeviceServiceDbTests : AbstractDbTest
{
private readonly ILogger<DeviceService> _logger = Substitute.For<ILogger<DeviceService>>();
private readonly IDeviceService _deviceService;
public DeviceServiceTests() : base()
public DeviceServiceDbTests() : base()
{
_deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For<IEmailService>());
}
protected new Task ResetDb()
protected override async Task ResetDb()
{
_context.Users.RemoveRange(_context.Users.ToList());
return Task.CompletedTask;
await _unitOfWork.CommitAsync();
}

View File

@ -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",

View File

@ -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<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
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>()
{
readingList
};
// Existing (order, chapterId): (0, 1), (1, 2), (2, 3)
await _readingListService.AddChaptersToReadingList(1, new List<int>() {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

View File

@ -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<AutoMapperProfiles>());
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null);
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>());
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context,
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), 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<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
// };
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// Libraries = new List<Library>()
// {
// 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<AppUser>()
{
@ -1481,20 +1406,21 @@ public class SeriesServiceTests
new Series()
{
Name = "Test Series",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Prequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Sequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
}
}
});
};
_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<AppUser>()
{
@ -1564,17 +1490,17 @@ public class SeriesServiceTests
new Series()
{
Name = "Test Series Prequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Sequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
}
}
});
_context.Library.Add(new Library()
};
_context.Library.Add(lib1);
var lib2 = new Library()
{
AppUsers = new List<AppUser>()
{
@ -1590,20 +1516,21 @@ public class SeriesServiceTests
new Series()
{
Name = "Test Series 2",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Prequels 2",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Sequels 2",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
}
}
});
};
_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

View File

@ -32,7 +32,7 @@ public class ImageController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId"})]
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId));
@ -48,7 +48,7 @@ public class ImageController : BaseApiController
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId"})]
public async Task<ActionResult> GetLibraryCoverImage(int libraryId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId));
@ -64,7 +64,7 @@ public class ImageController : BaseApiController
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId"})]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId));
@ -79,7 +79,7 @@ public class ImageController : BaseApiController
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId"})]
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId)
{
@ -98,7 +98,7 @@ public class ImageController : BaseApiController
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId"})]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
@ -114,7 +114,7 @@ public class ImageController : BaseApiController
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("readinglist-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId"})]
public async Task<ActionResult> GetReadingListCoverImage(int readingListId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
@ -133,7 +133,7 @@ public class ImageController : BaseApiController
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -155,7 +155,7 @@ public class ImageController : BaseApiController
/// <returns></returns>
[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");

View File

@ -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<ICount<SeriesDto>> MostReadSeries { get; set; }
/// <summary>
/// Total users who have started/reading/read per series

View File

@ -145,4 +145,39 @@ public class ServerInfoDto
/// </summary>
/// <remarks>Introduced in v0.6.0</remarks>
public bool UsingRestrictedProfiles { get; set; }
/// <summary>
/// Number of users using the Emulate Comic Book setting
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public int UsersWithEmulateComicBook { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries with folder watching enabled
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesWithFolderWatchingEnabled { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries included in Search
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesIncludedInSearch { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries included in Recommended
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesIncludedInRecommended { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries included in Dashboard
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesIncludedInDashboard { get; set; }
/// <summary>
/// Total reading hours of all users
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public long TotalReadingHours { get; set; }
/// <summary>
/// Is the Server saving covers as WebP
/// </summary>
/// <remarks>Added in v0.7.0</remarks>
public bool StoreCoversAsWebP { get; set; }
}

View File

@ -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();
}
/// <summary>
@ -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<Series>.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<LibraryDto>(_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<GenreTagDto>(_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<TagDto>(_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<SeriesDto>(_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<CollectionTagDto>(_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<SeriesDto>(_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<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.AsSplitQuery()

View File

@ -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)
{

View File

@ -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))
};
}

View File

@ -107,7 +107,7 @@ public class ReadingListService : IReadingListService
public async Task<bool> 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)

View File

@ -32,6 +32,7 @@ public interface IStatisticService
IEnumerable<StatCount<int>> GetPagesReadCountByYear(int userId = 0);
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics();
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
}
/// <summary>
@ -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<int>() {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<int>.Empty, ArraySegment<int>.Empty)
};
}
@ -483,6 +489,30 @@ public class StatisticService : IStatisticService
await _unitOfWork.CommitAsync();
}
public async Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> 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<IEnumerable<TopReadDto>> GetTopUsers(int days)
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();

View File

@ -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;

View File

@ -25,18 +25,23 @@ public interface IStatsService
Task<ServerInfoDto> GetServerInfo();
Task SendCancellation();
}
/// <summary>
/// This is for reporting to the stat server
/// </summary>
public class StatsService : IStatsService
{
private readonly ILogger<StatsService> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly DataContext _context;
private readonly IStatisticService _statisticService;
private const string ApiUrl = "https://stats.kavitareader.com";
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context)
public StatsService(ILogger<StatsService> 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<int>.Empty, ArraySegment<int>.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<float> GetPercentageOfLibrariesWithFolderWatchingEnabled()
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
return libraries.Count(l => l.FolderWatching) / (1.0f * libraries.Count);
}
private async Task<float> GetPercentageOfLibrariesIncludedInRecommended()
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
return libraries.Count(l => l.IncludeInRecommended) / (1.0f * libraries.Count);
}
private async Task<float> GetPercentageOfLibrariesIncludedInDashboard()
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
return libraries.Count(l => l.IncludeInDashboard) / (1.0f * libraries.Count);
}
private async Task<float> GetPercentageOfLibrariesIncludedInSearch()
{
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
return libraries.Count(l => l.IncludeInSearch) / (1.0f * libraries.Count);
}
private Task<bool> 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<IEnumerable<LayoutMode>> AllMangaReaderLayoutModes()
{
return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync();

View File

@ -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();

View File

@ -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);
}
}
}

View File

@ -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({

View File

@ -17,16 +17,7 @@
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Chapters" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Chapters">
{{stats.chapterCount | compactNumber}} Chapters
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Files" [clickable]="false" fontClasses="fa-regular fa-file" title="Total Files">
@ -69,6 +60,15 @@
{{stats.totalPeople | compactNumber}} People
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Read Time" [clickable]="false" fontClasses="fas fa-eye" title="Total Read Time">
{{stats.totalReadingTime | compactNumber}} Hours
</app-icon-and-title>
</div>
</ng-container>
</div>
@ -91,28 +91,33 @@
</div>
</div>
<div class="row g-0 pt-2 pb-2 ">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-4 pb-2" style="height: 242px">
<div class="col-md-6 col-sm-12">
<app-file-breakdown-stats></app-file-breakdown-stats>
<ng-container *ngIf="breakpoint$ | async as bp">
<div class="row g-0 pt-2 pb-2" *ngIf="bp > Breakpoint.Mobile">
<app-top-readers></app-top-readers>
</div>
<div class="col-md-6 col-sm-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-read-by-day-and [isAdmin]="true"></app-read-by-day-and>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown></app-day-breakdown>
<div class="row g-0 pt-4 pb-2" style="height: 242px" *ngIf="bp > Breakpoint.Mobile">
<div class="col-md-6 col-sm-12">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="col-md-6 col-sm-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px" *ngIf="bp > Breakpoint.Mobile">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-read-by-day-and [isAdmin]="true"></app-read-by-day-and>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px" *ngIf="bp > Breakpoint.Mobile">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown></app-day-breakdown>
</div>
</div>
</ng-container>
</div>

View File

@ -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<Breakpoint>(1);
breakpoint$: Observable<Breakpoint> = 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(

View File

@ -20,7 +20,7 @@
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Time Spent Reading" [clickable]="false" fontClasses="fas fa-eye" title="Time Spent Reading">
{{timeSpentReading}} hours
{{timeSpentReading | compactNumber}} hours
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>

View File

@ -12,6 +12,7 @@ export interface ServerStatistics {
totalGenres: number;
totalTags: number;
totalPeople: number;
totalReadingTime: number;
mostActiveUsers: Array<StatCount<User>>;
mostActiveLibraries: Array<StatCount<Library>>;
mostReadSeries: Array<StatCount<Series>>;

View File

@ -13,7 +13,7 @@
<meta name="msapplication-TileColor" content="#4ac694">
<meta name="msapplication-config" content="assets/icons/browserconfig.xml">
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-status-bar-style" content="#000000">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">

View File

@ -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;
}

View File

@ -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": {