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; 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 DataContext _context;
protected readonly IUnitOfWork _unitOfWork; protected readonly IUnitOfWork _unitOfWork;
@ -30,8 +30,9 @@ public abstract class BasicTest
protected const string LogDirectory = "C:/kavita/config/logs/"; protected const string LogDirectory = "C:/kavita/config/logs/";
protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
protected const string TempDirectory = "C:/kavita/config/temp/"; protected const string TempDirectory = "C:/kavita/config/temp/";
protected const string DataDirectory = "C:/data/";
protected BasicTest() protected AbstractDbTest()
{ {
var contextOptions = new DbContextOptionsBuilder() var contextOptions = new DbContextOptionsBuilder()
.UseSqlite(CreateInMemoryDatabase()) .UseSqlite(CreateInMemoryDatabase())
@ -50,13 +51,12 @@ public abstract class BasicTest
private static DbConnection CreateInMemoryDatabase() private static DbConnection CreateInMemoryDatabase()
{ {
var connection = new SqliteConnection("Filename=:memory:"); var connection = new SqliteConnection("Filename=:memory:");
connection.Open(); connection.Open();
return connection; return connection;
} }
private async Task<bool> SeedDb() protected async Task<bool> SeedDb()
{ {
await _context.Database.MigrateAsync(); await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem(); var filesystem = CreateFileSystem();
@ -91,14 +91,7 @@ public abstract class BasicTest
return await _context.SaveChangesAsync() > 0; return await _context.SaveChangesAsync() > 0;
} }
protected async Task ResetDb() protected abstract Task ResetDb();
{
_context.Series.RemoveRange(_context.Series.ToList());
_context.Users.RemoveRange(_context.Users.ToList());
_context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList());
await _context.SaveChangesAsync();
}
protected static MockFileSystem CreateFileSystem() protected static MockFileSystem CreateFileSystem()
{ {
@ -111,7 +104,7 @@ public abstract class BasicTest
fileSystem.AddDirectory(BookmarkDirectory); fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory(LogDirectory); fileSystem.AddDirectory(LogDirectory);
fileSystem.AddDirectory(TempDirectory); fileSystem.AddDirectory(TempDirectory);
fileSystem.AddDirectory("C:/data/"); fileSystem.AddDirectory(DataDirectory);
return fileSystem; return fileSystem;
} }

View File

@ -7,6 +7,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs.Filtering;
using API.DTOs.Settings; using API.DTOs.Settings;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -28,70 +29,14 @@ using Xunit;
namespace API.Tests.Services; namespace API.Tests.Services;
public class CleanupServiceTests public class CleanupServiceTests : AbstractDbTest
{ {
private readonly ILogger<CleanupService> _logger = Substitute.For<ILogger<CleanupService>>(); private readonly ILogger<CleanupService> _logger = Substitute.For<ILogger<CleanupService>>();
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _messageHub = Substitute.For<IEventHub>(); private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
private readonly DbConnection _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/"; public CleanupServiceTests() : base()
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()
{ {
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() _context.Library.Add(new Library()
{ {
Name = "Manga", 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.Series.RemoveRange(_context.Series.ToList());
_context.Users.RemoveRange(_context.Users.ToList()); _context.Users.RemoveRange(_context.Users.ToList());
@ -115,20 +62,6 @@ public class CleanupServiceTests
await _context.SaveChangesAsync(); 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 #endregion
#region DeleteSeriesCoverImages #region DeleteSeriesCoverImages
@ -142,7 +75,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData("")); filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData(""));
// Delete all Series to reset state // Delete all Series to reset state
await ResetDB(); await ResetDb();
var s = DbFactory.Series("Test 1"); var s = DbFactory.Series("Test 1");
s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg"; s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg";
@ -175,7 +108,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData("")); filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData(""));
// Delete all Series to reset state // Delete all Series to reset state
await ResetDB(); await ResetDb();
// Add 2 series with cover images // Add 2 series with cover images
var s = DbFactory.Series("Test 1"); var s = DbFactory.Series("Test 1");
@ -209,7 +142,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}v01_c1000.jpg", new MockFileData("")); filesystem.AddFile($"{CoverImageDirectory}v01_c1000.jpg", new MockFileData(""));
// Delete all Series to reset state // Delete all Series to reset state
await ResetDB(); await ResetDb();
// Add 2 series with cover images // Add 2 series with cover images
var s = DbFactory.Series("Test 1"); var s = DbFactory.Series("Test 1");
@ -259,7 +192,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
// Delete all Series to reset state // Delete all Series to reset state
await ResetDB(); await ResetDb();
// Add 2 series with cover images // Add 2 series with cover images
var s = DbFactory.Series("Test 1"); var s = DbFactory.Series("Test 1");
@ -307,7 +240,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(3)}.jpg", new MockFileData("")); filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(3)}.jpg", new MockFileData(""));
// Delete all Series to reset state // Delete all Series to reset state
await ResetDB(); await ResetDb();
_context.Users.Add(new AppUser() _context.Users.Add(new AppUser()
{ {
@ -569,6 +502,62 @@ public class CleanupServiceTests
} }
#endregion #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 // #region CleanupBookmarks
// //
// [Fact] // [Fact]
@ -579,7 +568,7 @@ public class CleanupServiceTests
// filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData("")); // filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData(""));
// //
// // Delete all Series to reset state // // Delete all Series to reset state
// await ResetDB(); // await ResetDb();
// //
// _context.Series.Add(new Series() // _context.Series.Add(new Series()
// { // {
@ -651,7 +640,7 @@ public class CleanupServiceTests
// filesystem.AddFile($"{BookmarkDirectory}1/1/2/0002.jpg", new MockFileData("")); // filesystem.AddFile($"{BookmarkDirectory}1/1/2/0002.jpg", new MockFileData(""));
// //
// // Delete all Series to reset state // // Delete all Series to reset state
// await ResetDB(); // await ResetDb();
// //
// _context.Series.Add(new Series() // _context.Series.Add(new Series()
// { // {

View File

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

View File

@ -1429,6 +1429,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress() public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1479,6 +1480,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1_WithProgress() public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1_WithProgress()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1524,6 +1526,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial() public async Task GetContinuePoint_ShouldReturnFirstNonSpecial()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1596,6 +1599,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2() public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1674,6 +1678,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstSpecial() public async Task GetContinuePoint_ShouldReturnFirstSpecial()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1743,6 +1748,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes() public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1785,6 +1791,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesAndAFewLooseChaptersRead() public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesAndAFewLooseChaptersRead()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1851,6 +1858,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead() public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1914,6 +1922,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllReadAndAllChapters() public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllReadAndAllChapters()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -1959,6 +1968,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstSpecial_WhenAllReadAndAllChapters() public async Task GetContinuePoint_ShouldReturnFirstSpecial_WhenAllReadAndAllChapters()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -2020,6 +2030,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress() public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress()
{ {
await ResetDb();
var series = new Series() var series = new Series()
{ {
Name = "Test", Name = "Test",
@ -2079,6 +2090,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead() public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -2121,6 +2133,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead() public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",
@ -2165,6 +2178,7 @@ public class ReaderServiceTests
[Fact] [Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0() public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0()
{ {
await ResetDb();
_context.Series.Add(new Series() _context.Series.Add(new Series()
{ {
Name = "Test", Name = "Test",

View File

@ -181,6 +181,96 @@ public class ReadingListServiceTests
Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order); 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 #endregion

View File

@ -28,80 +28,18 @@ using Xunit;
namespace API.Tests.Services; 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 readonly ISeriesService _seriesService;
private const string CacheDirectory = "C:/kavita/config/cache/"; public SeriesServiceTests() : base()
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string DataDirectory = "C:/data/";
public SeriesServiceTests()
{ {
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>(), _seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>()); Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>());
} }
#region Setup #region Setup
private static DbConnection CreateInMemoryDatabase() protected override async Task ResetDb()
{
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()
{ {
_context.Series.RemoveRange(_context.Series.ToList()); _context.Series.RemoveRange(_context.Series.ToList());
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList()); _context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
@ -113,19 +51,6 @@ public class SeriesServiceTests
await _context.SaveChangesAsync(); 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) private static UpdateRelatedSeriesDto CreateRelationsDto(Series series)
{ {
return new UpdateRelatedSeriesDto() return new UpdateRelatedSeriesDto()
@ -1465,7 +1390,7 @@ public class SeriesServiceTests
public async Task SeriesRelation_ShouldAllowDeleteOnLibrary() public async Task SeriesRelation_ShouldAllowDeleteOnLibrary()
{ {
await ResetDb(); await ResetDb();
_context.Library.Add(new Library() var lib = new Library()
{ {
AppUsers = new List<AppUser>() AppUsers = new List<AppUser>()
{ {
@ -1481,20 +1406,21 @@ public class SeriesServiceTests
new Series() new Series()
{ {
Name = "Test Series", Name = "Test Series",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
}, },
new Series() new Series()
{ {
Name = "Test Series Prequels", Name = "Test Series Prequels",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
}, },
new Series() new Series()
{ {
Name = "Test Series Sequels", Name = "Test Series Sequels",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
} }
} }
}); };
_context.Library.Add(lib);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@ -1505,7 +1431,7 @@ public class SeriesServiceTests
addRelationDto.Sequels.Add(3); addRelationDto.Sequels.Add(3);
await _seriesService.UpdateRelatedSeries(addRelationDto); await _seriesService.UpdateRelatedSeries(addRelationDto);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id);
_unitOfWork.LibraryRepository.Delete(library); _unitOfWork.LibraryRepository.Delete(library);
try try
@ -1524,7 +1450,7 @@ public class SeriesServiceTests
public async Task SeriesRelation_ShouldAllowDeleteOnLibrary_WhenSeriesCrossLibraries() public async Task SeriesRelation_ShouldAllowDeleteOnLibrary_WhenSeriesCrossLibraries()
{ {
await ResetDb(); await ResetDb();
_context.Library.Add(new Library() var lib1 = new Library()
{ {
AppUsers = new List<AppUser>() AppUsers = new List<AppUser>()
{ {
@ -1564,17 +1490,17 @@ public class SeriesServiceTests
new Series() new Series()
{ {
Name = "Test Series Prequels", Name = "Test Series Prequels",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
}, },
new Series() new Series()
{ {
Name = "Test Series Sequels", Name = "Test Series Sequels",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
} }
} }
}); };
_context.Library.Add(lib1);
_context.Library.Add(new Library() var lib2 = new Library()
{ {
AppUsers = new List<AppUser>() AppUsers = new List<AppUser>()
{ {
@ -1590,20 +1516,21 @@ public class SeriesServiceTests
new Series() new Series()
{ {
Name = "Test Series 2", Name = "Test Series 2",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
}, },
new Series() new Series()
{ {
Name = "Test Series Prequels 2", Name = "Test Series Prequels 2",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
}, },
new Series() new Series()
{ {
Name = "Test Series Sequels 2", Name = "Test Series Sequels 2",
Volumes = new List<Volume>(){} Volumes = new List<Volume>() { }
} }
} }
}); };
_context.Library.Add(lib2);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
@ -1613,7 +1540,7 @@ public class SeriesServiceTests
addRelationDto.Adaptations.Add(4); // cross library link addRelationDto.Adaptations.Add(4); // cross library link
await _seriesService.UpdateRelatedSeries(addRelationDto); 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); _unitOfWork.LibraryRepository.Delete(library);
try try

View File

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

View File

@ -13,6 +13,7 @@ public class ServerStatisticsDto
public long TotalGenres { get; set; } public long TotalGenres { get; set; }
public long TotalTags { get; set; } public long TotalTags { get; set; }
public long TotalPeople { get; set; } public long TotalPeople { get; set; }
public long TotalReadingTime { get; set; }
public IEnumerable<ICount<SeriesDto>> MostReadSeries { get; set; } public IEnumerable<ICount<SeriesDto>> MostReadSeries { get; set; }
/// <summary> /// <summary>
/// Total users who have started/reading/read per series /// Total users who have started/reading/read per series

View File

@ -145,4 +145,39 @@ public class ServerInfoDto
/// </summary> /// </summary>
/// <remarks>Introduced in v0.6.0</remarks> /// <remarks>Introduced in v0.6.0</remarks>
public bool UsingRestrictedProfiles { get; set; } 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 return await _context.Series
.Where(s => s.LibraryId == libraryId) .Where(s => s.LibraryId == libraryId)
.Includes(includes) .Includes(includes)
.OrderBy(s => s.SortName).ToListAsync(); .OrderBy(s => s.SortName.ToLower())
.ToListAsync();
} }
/// <summary> /// <summary>
@ -223,7 +224,7 @@ public class SeriesRepository : ISeriesRepository
.ThenInclude(v => v.Chapters) .ThenInclude(v => v.Chapters)
.ThenInclude(c => c.Files) .ThenInclude(c => c.Files)
.AsSplitQuery() .AsSplitQuery()
.OrderBy(s => s.SortName); .OrderBy(s => s.SortName.ToLower());
return await PagedList<Series>.CreateAsync(query, userParams.PageNumber, userParams.PageSize); 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 => libraryIds.Contains(l.Id))
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search) .IsRestricted(QueryContext.Search)
.OrderBy(l => l.Name) .OrderBy(l => l.Name.ToLower())
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider) .ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
@ -335,7 +336,7 @@ public class SeriesRepository : ISeriesRepository
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
.RestrictAgainstAgeRestriction(userRating) .RestrictAgainstAgeRestriction(userRating)
.Include(s => s.Library) .Include(s => s.Library)
.OrderBy(s => s.SortName) .OrderBy(s => s.SortName.ToLower())
.AsNoTracking() .AsNoTracking()
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords) .Take(maxRecords)
@ -356,7 +357,7 @@ public class SeriesRepository : ISeriesRepository
|| EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")) || EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%"))
.Where(c => c.Promoted || isAdmin) .Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating) .RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.Title) .OrderBy(s => s.NormalizedTitle)
.AsNoTracking() .AsNoTracking()
.AsSplitQuery() .AsSplitQuery()
.Take(maxRecords) .Take(maxRecords)
@ -377,7 +378,7 @@ public class SeriesRepository : ISeriesRepository
.Where(sm => seriesIds.Contains(sm.SeriesId)) .Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery() .AsSplitQuery()
.OrderBy(t => t.Title) .OrderBy(t => t.NormalizedTitle)
.Distinct() .Distinct()
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider) .ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
@ -387,7 +388,7 @@ public class SeriesRepository : ISeriesRepository
.Where(sm => seriesIds.Contains(sm.SeriesId)) .Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery() .AsSplitQuery()
.OrderBy(t => t.Title) .OrderBy(t => t.NormalizedTitle)
.Distinct() .Distinct()
.Take(maxRecords) .Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider) .ProjectTo<TagDto>(_mapper.ConfigurationProvider)
@ -719,7 +720,8 @@ public class SeriesRepository : ISeriesRepository
}) })
.Where(s => s.PagesRead > 0 .Where(s => s.PagesRead > 0
&& s.PagesRead < s.Series.Pages) && 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) .ThenByDescending(s => s.LastChapterAdded)
.Select(s => s.Series) .Select(s => s.Series)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -777,7 +779,7 @@ public class SeriesRepository : ISeriesRepository
{ {
query = filter.SortOptions.SortField switch 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.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
@ -790,7 +792,7 @@ public class SeriesRepository : ISeriesRepository
{ {
query = filter.SortOptions.SortField switch 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.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
@ -844,7 +846,7 @@ public class SeriesRepository : ISeriesRepository
{ {
query = filter.SortOptions.SortField switch 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.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
@ -856,7 +858,7 @@ public class SeriesRepository : ISeriesRepository
{ {
query = filter.SortOptions.SortField switch 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.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), 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)) .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider) .ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.AsNoTracking() .AsNoTracking()
.OrderBy(t => t.Title) .OrderBy(t => t.Title.ToLower())
.AsSplitQuery() .AsSplitQuery()
.ToListAsync(); .ToListAsync();
} }
@ -911,7 +913,7 @@ public class SeriesRepository : ISeriesRepository
.ThenInclude(m => m.Series) .ThenInclude(m => m.Series)
.SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId)))
.OrderBy(s => s.LibraryId) .OrderBy(s => s.LibraryId)
.ThenBy(s => s.SortName) .ThenBy(s => s.SortName.ToLower())
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery() .AsSplitQuery()
.AsNoTracking(); .AsNoTracking();
@ -941,7 +943,7 @@ public class SeriesRepository : ISeriesRepository
return await _context.Series return await _context.Series
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId)) .Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.OrderBy(s => s.SortName) .OrderBy(s => s.SortName.ToLower())
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking() .AsNoTracking()
.AsSplitQuery() .AsSplitQuery()

View File

@ -299,6 +299,8 @@ public class ArchiveService : IArchiveService
try try
{ {
ZipFile.CreateFromDirectory(tempLocation, zipPath); ZipFile.CreateFromDirectory(tempLocation, zipPath);
// Remove the folder as we have the zip
_directoryService.ClearAndDeleteDirectory(tempLocation);
} }
catch (AggregateException ex) catch (AggregateException ex)
{ {

View File

@ -575,39 +575,20 @@ public class ReaderService : IReaderService
{ {
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0); var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 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 return new HourEstimateRangeDto
{ {
MinHours = minHours, MinHours = Math.Min(minHours, maxHours),
MaxHours = maxHours, MaxHours = Math.Max(minHours, maxHours),
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)) AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
}; };
} }
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0); var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 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 return new HourEstimateRangeDto
{ {
MinHours = minHoursPages, MinHours = Math.Min(minHoursPages, maxHoursPages),
MaxHours = maxHoursPages, MaxHours = Math.Max(minHoursPages, maxHoursPages),
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)) AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
}; };
} }

View File

@ -107,7 +107,7 @@ public class ReadingListService : IReadingListService
public async Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto) public async Task<bool> DeleteReadingListItem(UpdateReadingListPosition dto)
{ {
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); 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; var index = 0;
foreach (var readingListItem in readingList.Items) 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>> GetPagesReadCountByYear(int userId = 0);
IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0); IEnumerable<StatCount<int>> GetWordsReadCountByYear(int userId = 0);
Task UpdateServerStatistics(); Task UpdateServerStatistics();
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
} }
/// <summary> /// <summary>
@ -62,18 +63,20 @@ public class StatisticService : IStatisticService
.Where(p => libraryIds.Contains(p.LibraryId)) .Where(p => libraryIds.Contains(p.LibraryId))
.SumAsync(p => p.PagesRead); .SumAsync(p => p.PagesRead);
var ids = await _context.AppUserProgresses // var ids = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId) // .Where(p => p.AppUserId == userId)
.Where(p => libraryIds.Contains(p.LibraryId)) // .Where(p => libraryIds.Contains(p.LibraryId))
.Where(p => p.PagesRead > 0) // .Where(p => p.PagesRead > 0)
.Select(p => new {p.ChapterId, p.SeriesId}) // .Select(p => new {p.ChapterId, p.SeriesId})
.ToListAsync(); // .ToListAsync();
var chapterIds = ids.Select(id => id.ChapterId); //var chapterIds = ids.Select(id => id.ChapterId);
var timeSpentReading = await _context.Chapter // var timeSpentReading = await _context.Chapter
.Where(c => chapterIds.Contains(c.Id)) // .Where(c => chapterIds.Contains(c.Id))
.SumAsync(c => c.AvgHoursToRead); // .SumAsync(c => c.AvgHoursToRead);
var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds);
var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses
.Where(p => p.AppUserId == userId) .Where(p => p.AppUserId == userId)
@ -275,6 +278,8 @@ public class StatisticService : IStatisticService
.Distinct() .Distinct()
.Count(); .Count();
return new ServerStatisticsDto() return new ServerStatisticsDto()
{ {
ChapterCount = await _context.Chapter.CountAsync(), ChapterCount = await _context.Chapter.CountAsync(),
@ -289,7 +294,8 @@ public class StatisticService : IStatisticService
MostActiveLibraries = mostActiveLibrary, MostActiveLibraries = mostActiveLibrary,
MostPopularSeries = mostPopularSeries, MostPopularSeries = mostPopularSeries,
MostReadSeries = mostReadSeries, 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(); 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) public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
{ {
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); 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); var seriesEstimate = _readerService.GetTimeEstimate(series.WordCount, series.Pages, isEpub);
series.MinHoursToRead = seriesEstimate.MinHours; series.MinHoursToRead = seriesEstimate.MinHours;
series.MaxHoursToRead = seriesEstimate.MaxHours; series.MaxHoursToRead = seriesEstimate.MaxHours;

View File

@ -25,18 +25,23 @@ public interface IStatsService
Task<ServerInfoDto> GetServerInfo(); Task<ServerInfoDto> GetServerInfo();
Task SendCancellation(); Task SendCancellation();
} }
/// <summary>
/// This is for reporting to the stat server
/// </summary>
public class StatsService : IStatsService public class StatsService : IStatsService
{ {
private readonly ILogger<StatsService> _logger; private readonly ILogger<StatsService> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly DataContext _context; private readonly DataContext _context;
private readonly IStatisticService _statisticService;
private const string ApiUrl = "https://stats.kavitareader.com"; 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; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_context = context; _context = context;
_statisticService = statisticService;
FlurlHttp.ConfigureClient(ApiUrl, cli => FlurlHttp.ConfigureClient(ApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
@ -116,6 +121,14 @@ public class StatsService : IStatsService
DotnetVersion = Environment.Version.ToString(), DotnetVersion = Environment.Version.ToString(),
IsDocker = new OsInfo().IsDocker, IsDocker = new OsInfo().IsDocker,
NumOfCores = Math.Max(Environment.ProcessorCount, 1), 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(), HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(),
NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(),
NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(),
@ -127,6 +140,7 @@ public class StatsService : IStatsService
TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(),
UsingSeriesRelationships = await GetIfUsingSeriesRelationship(), UsingSeriesRelationships = await GetIfUsingSeriesRelationship(),
StoreBookmarksAsWebP = serverSettings.ConvertBookmarkToWebP, StoreBookmarksAsWebP = serverSettings.ConvertBookmarkToWebP,
StoreCoversAsWebP = serverSettings.ConvertCoverToWebP,
MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(), MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(),
MaxVolumesInASeries = await MaxVolumesInASeries(), MaxVolumesInASeries = await MaxVolumesInASeries(),
MaxChaptersInASeries = await MaxChaptersInASeries(), 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() private Task<bool> GetIfUsingSeriesRelationship()
{ {
return _context.SeriesRelation.AnyAsync(); return _context.SeriesRelation.AnyAsync();
@ -242,6 +280,7 @@ public class StatsService : IStatsService
return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync(); return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync();
} }
private async Task<IEnumerable<LayoutMode>> AllMangaReaderLayoutModes() private async Task<IEnumerable<LayoutMode>> AllMangaReaderLayoutModes()
{ {
return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync(); return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync();

View File

@ -64,15 +64,23 @@ export class ThemeService implements OnDestroy {
getColorScheme() { getColorScheme() {
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim();
} }
/** /**
* --theme-color from theme. Updates the meta tag * --theme-color from theme. Updates the meta tag
* @returns * @returns
*/ */
getThemeColor() { getThemeColor() {
return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); 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) { getCssVariable(variable: string) {
return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); return getComputedStyle(this.document.body).getPropertyValue(variable).trim();
} }
@ -155,6 +163,12 @@ export class ThemeService implements OnDestroy {
const themeColor = this.getThemeColor(); const themeColor = this.getThemeColor();
if (themeColor) { if (themeColor) {
this.document.querySelector('meta[name="theme-color"]')?.setAttribute('content', 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(); const colorScheme = this.getColorScheme();

View File

@ -973,11 +973,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
triggerSwipePagination(direction: KeyDirection) { triggerSwipePagination(direction: KeyDirection) {
if (this.readingDirection === ReadingDirection.LeftToRight) {
if (direction === KeyDirection.Right)
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
}
switch(direction) { switch(direction) {
case KeyDirection.Down: case KeyDirection.Down:
this.nextPage(); this.nextPage();
@ -996,8 +991,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
onSwipeEnd(event: SwipeEvent) { onSwipeEnd(event: SwipeEvent) {
const threshold = .12;
// Positive number means swiping right/down, negative means left // Positive number means swiping right/down, negative means left
switch (this.readerMode) { switch (this.readerMode) {
case ReaderMode.Webtoon: break; 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 // 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) { if (direction === KeyDirection.Right) {
this.hasHitZeroScroll = false; this.hasHitZeroScroll = false;
if (scrollLeft === 0 && this.ReadingAreaWidth === 0) {
this.triggerSwipePagination(direction);
return;
}
if (!this.hasHitRightScroll && this.checkIfPaginationAllowed(direction)) { if (!this.hasHitRightScroll && this.checkIfPaginationAllowed(direction)) {
this.hasHitRightScroll = true; this.hasHitRightScroll = true;
return; return;
@ -1036,7 +1033,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
console.log('Next page triggered');
this.triggerSwipePagination(direction); this.triggerSwipePagination(direction);
break; break;
} }
@ -1072,32 +1068,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
console.log('Next page triggered');
this.triggerSwipePagination(direction); this.triggerSwipePagination(direction);
break; 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 { ScalingOption } from 'src/app/_models/preferences/scaling-option';
import { ReaderService } from 'src/app/_services/reader.service'; import { ReaderService } from 'src/app/_services/reader.service';
import { ChapterInfo } from '../_models/chapter-info'; 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'; import { FITTING_OPTION } from '../_models/reader-enums';
@Injectable({ @Injectable({

View File

@ -17,16 +17,7 @@
</div> </div>
<div class="vr d-none d-lg-block m-2"></div> <div class="vr d-none d-lg-block m-2"></div>
</ng-container> </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> <ng-container>
<div class="col-auto mb-2"> <div class="col-auto mb-2">
<app-icon-and-title label="Total Files" [clickable]="false" fontClasses="fa-regular fa-file" title="Total Files"> <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 {{stats.totalPeople | compactNumber}} People
</app-icon-and-title> </app-icon-and-title>
</div> </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> </ng-container>
</div> </div>
@ -91,28 +91,33 @@
</div> </div>
</div> </div>
<div class="row g-0 pt-2 pb-2 "> <ng-container *ngIf="breakpoint$ | async as bp">
<app-top-readers></app-top-readers> <div class="row g-0 pt-2 pb-2" *ngIf="bp > Breakpoint.Mobile">
</div> <app-top-readers></app-top-readers>
<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>
</div> </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="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"> <div class="col-md-6 col-sm-12">
<app-day-breakdown></app-day-breakdown> <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>
<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> </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 { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 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 { 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 { Series } from 'src/app/_models/series';
import { ImageService } from 'src/app/_services/image.service'; import { ImageService } from 'src/app/_services/image.service';
import { MetadataService } from 'src/app/_services/metadata.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]); 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, 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) => { this.seriesImage = (data: PieDataItem) => {
if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id); if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id);
return ''; return '';
} }
this.breakpointSubject.next(this.utilityService.getActiveBreakpoint());
this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay()); this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy)); this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
this.mostActiveUsers$ = this.stats$.pipe( this.mostActiveUsers$ = this.stats$.pipe(

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
// //
:root, :root .default { :root, :root .default {
--theme-color: #000000; /* Base colors */
--color-scheme: dark;
--primary-color: #4ac694; --primary-color: #4ac694;
--primary-color-dark-shade: #3B9E76; --primary-color-dark-shade: #3B9E76;
--primary-color-darker-shade: #338A67; --primary-color-darker-shade: #338A67;
@ -11,6 +10,11 @@
--body-text-color: #efefef; --body-text-color: #efefef;
--btn-icon-filter: invert(1) grayscale(100%) brightness(200%); --btn-icon-filter: invert(1) grayscale(100%) brightness(200%);
--primary-color-scrollbar: rgba(74,198,148,0.75); --primary-color-scrollbar: rgba(74,198,148,0.75);
/* Meta and Globals */
--theme-color: #000000;
--color-scheme: dark;
--tile-color: var(--primary-color);
/* Navbar */ /* Navbar */
@ -116,7 +120,7 @@
/* List items */ /* 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-bg-color: #343a40;
--list-group-item-border-color: rgba(239, 239, 239, 0.125); --list-group-item-border-color: rgba(239, 239, 239, 0.125);
--list-group-hover-text-color: white; --list-group-hover-text-color: white;
@ -176,6 +180,7 @@
--ratingstar-star-filled: var(--primary-color); --ratingstar-star-filled: var(--primary-color);
/* Global */ /* Global */
//--hr-color: transparent;
--hr-color: rgba(239, 239, 239, 0.125); --hr-color: rgba(239, 239, 239, 0.125);
--accent-bg-color: rgba(1, 4, 9, 0.5); --accent-bg-color: rgba(1, 4, 9, 0.5);
--accent-text-color: lightgrey; --accent-text-color: lightgrey;
@ -207,7 +212,6 @@
--manga-reader-overlay-filter: blur(10px); --manga-reader-overlay-filter: blur(10px);
--manga-reader-overlay-bg-color: rgba(0,0,0,0.5); --manga-reader-overlay-bg-color: rgba(0,0,0,0.5);
--manga-reader-overlay-text-color: white; --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-next-highlight-bg-color: rgba(65, 225, 100, 0.5);
--manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5); --manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5);
@ -242,7 +246,4 @@
/* List Card Item */ /* 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%); --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", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.6.1.26" "version": "0.6.1.27"
}, },
"servers": [ "servers": [
{ {
@ -13201,6 +13201,40 @@
"usingRestrictedProfiles": { "usingRestrictedProfiles": {
"type": "boolean", "type": "boolean",
"description": "If there is at least one user that is using an age restricted profile on the instance" "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, "additionalProperties": false,
@ -13323,6 +13357,10 @@
"type": "integer", "type": "integer",
"format": "int64" "format": "int64"
}, },
"totalReadingTime": {
"type": "integer",
"format": "int64"
},
"mostReadSeries": { "mostReadSeries": {
"type": "array", "type": "array",
"items": { "items": {