From c361e66b3539845a4c15efbfb96bfe64811511b7 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Wed, 7 Dec 2022 08:01:49 -0600 Subject: [PATCH] Basic Stats (#1673) * Refactored ResponseCache profiles into consts * Refactored code to use an extension method for getting user library ids. * Started server statistics, added a charting library, and added a table sort column (not finished) * Refactored code and have a fully working example of sortable headers. Still doesn't work with default sorting state, will work on that later. * Implemented file size, but it's too expensive, so commented out. * Added a migration to provide extension and length/size information in the DB to allow for faster stat apis. * Added the ability to force a library scan from library settings. * Refactored some apis to provide more of a file breakdown rather than just file size. * Working on visualization of file breakdown * Fixed the file breakdown visual * Fixed up 2 visualizations * Added back an api for member names, started work on top reads * Hooked up the other library types and username/days. * Preparing to remove top reads and refactor into Top users * Added LibraryId to AppUserProgress to help with complex lookups. * Added the new libraryId hook into some stats methods * Updated api methods to use libraryId for progress * More places where LibraryId is needed * Added some high level server stats * Got a ton done on server stats * Updated default theme (dark) to be the default root variables. This will allow user themes to override just what they want, rather than maintain their own css variables. * Implemented a monster query for top users by reading time. It's very slow and can be cleaned up likely. * Hooked up top reads. Code needs a big refactor. Handing off for Robbie treatment and I'll switch to User stats. * Implemented last 5 recently read series (broken) and added some basic css * Fixed recently read query * Cleanup the css a bit, Robbie we need you * More css love * Cleaned up DTOs that aren't needed anymore * Fixed top readers query * When calculating top readers, don't include read events where nothing is read (0 pages) * Hooked up the date into GetTopUsers * Hooked top readers up with days and refactored and cleaned up componets not used * Fixed up query * Started on a day by day breakdown, but going to take a break from stats. * Added a temp task to run some migration manually for stats to work * Ensure OPDS-PS uses new libraryId for progress reporting * Fixed a code smell * Adding some styling * adding more styles * Removed some debug stuff from user stats * Bump qs from 6.5.2 to 6.5.3 in /UI/Web Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3. - [Release notes](https://github.com/ljharb/qs/releases) - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3) --- updated-dependencies: - dependency-name: qs dependency-type: indirect ... Signed-off-by: dependabot[bot] * Tweaked some code for bad data cases * Refactored a chapter lookup to remove un-needed Volume join in 5 places across the code. * API push Signed-off-by: dependabot[bot] Co-authored-by: Robbie Davis Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- API/Constants/ResponseCacheProfiles.cs | 17 + API/Controllers/ImageController.cs | 17 +- API/Controllers/MetadataController.cs | 5 +- API/Controllers/OPDSController.cs | 11 +- API/Controllers/ReaderController.cs | 7 +- API/Controllers/SeriesController.cs | 3 +- API/Controllers/ServerController.cs | 24 +- API/Controllers/StatsController.cs | 112 ++ API/Controllers/UsersController.cs | 17 +- API/DTOs/ProgressDto.cs | 2 + API/DTOs/Statistics/Count.cs | 7 + .../Statistics/FileExtensionBreakdownDto.cs | 22 + API/DTOs/Statistics/ICount.cs | 7 + API/DTOs/Statistics/ReadHistoryEvent.cs | 18 + API/DTOs/Statistics/ServerStatistics.cs | 29 + API/DTOs/Statistics/TopReadsDto.cs | 19 + API/DTOs/Statistics/UserReadStatistics.cs | 24 + API/Data/MigrateChangePasswordRoles.cs | 2 +- API/Data/MigrateChangeRestrictionRoles.cs | 2 +- API/Data/MigrateUserProgressLibraryId.cs | 43 + ...6133824_FileLengthAndExtension.Designer.cs | 1699 ++++++++++++++++ .../20221126133824_FileLengthAndExtension.cs | 36 + ...28230726_UserProgressLibraryId.Designer.cs | 1702 +++++++++++++++++ .../20221128230726_UserProgressLibraryId.cs | 26 + .../Migrations/DataContextModelSnapshot.cs | 9 + .../Repositories/AppUserProgressRepository.cs | 16 + API/Data/Repositories/ChapterRepository.cs | 17 +- API/Data/Repositories/MangaFileRepository.cs | 19 +- API/Data/Repositories/SeriesRepository.cs | 88 +- API/Data/Repositories/UserRepository.cs | 10 +- API/Entities/AppUserProgress.cs | 5 +- API/Entities/Enums/MangaFormat.cs | 3 +- API/Entities/MangaFile.cs | 9 +- .../ApplicationServiceExtensions.cs | 1 + API/Extensions/DateTimeExtensions.cs | 6 + API/Extensions/QueryableExtensions.cs | 36 +- API/Services/ReaderService.cs | 6 +- API/Services/ReadingListService.cs | 2 +- API/Services/StatisticService.cs | 417 ++++ API/Services/Tasks/Scanner/ProcessSeries.cs | 24 +- API/Services/Tasks/ScannerService.cs | 32 +- API/Services/Tasks/StatsService.cs | 2 +- API/Startup.cs | 21 +- .../iharbeck-ngx-virtual-scroller-14.0.5.tgz | Bin 0 -> 107154 bytes UI/Web/package-lock.json | 413 +++- UI/Web/package.json | 2 + UI/Web/src/app/_services/action.service.ts | 8 +- UI/Web/src/app/_services/reader.service.ts | 4 +- UI/Web/src/app/_services/server.service.ts | 4 + .../src/app/_services/statistics.service.ts | 84 + .../_directives/sortable-header.directive.ts | 30 + .../app/_single-module/table/table.module.ts | 18 + UI/Web/src/app/admin/admin.module.ts | 5 +- .../admin/dashboard/dashboard.component.html | 3 + .../admin/dashboard/dashboard.component.ts | 5 +- .../manage-tasks-settings.component.ts | 6 + .../book-reader/book-reader.component.ts | 2 +- .../card-detail-drawer.component.ts | 4 +- .../manga-reader/manga-reader.component.ts | 4 +- .../pdf-reader/pdf-reader.component.ts | 2 +- UI/Web/src/app/pipe/bytes.pipe.ts | 42 + UI/Web/src/app/pipe/pipe.module.ts | 3 + .../series-detail/series-detail.component.ts | 4 +- .../app/shared/_services/download.service.ts | 41 +- .../library-settings-modal.component.html | 1 + .../library-settings-modal.component.ts | 4 + .../file-breakdown-stats.component.html | 71 + .../file-breakdown-stats.component.scss | 4 + .../file-breakdown-stats.component.ts | 95 + .../manga-format-stats.component.html | 58 + .../manga-format-stats.component.scss | 0 .../manga-format-stats.component.ts | 72 + .../publication-status-stats.component.html | 54 + .../publication-status-stats.component.scss | 3 + .../publication-status-stats.component.ts | 71 + .../server-stats/server-stats.component.html | 106 + .../server-stats/server-stats.component.scss | 10 + .../server-stats/server-stats.component.ts | 75 + .../stat-list/stat-list.component.html | 14 + .../stat-list/stat-list.component.scss | 3 + .../stat-list/stat-list.component.ts | 27 + .../top-readers/top-readers.component.html | 35 + .../top-readers/top-readers.component.scss | 14 + .../top-readers/top-readers.component.ts | 44 + .../user-stats-info-cards.component.html | 36 + .../user-stats-info-cards.component.scss | 0 .../user-stats-info-cards.component.ts | 22 + .../user-stats/user-stats.component.html | 20 + .../user-stats/user-stats.component.scss | 0 .../user-stats/user-stats.component.ts | 66 + .../app/statistics/_models/file-breakdown.ts | 13 + UI/Web/src/app/statistics/_models/mode.ts | 4 + .../app/statistics/_models/pie-data-item.ts | 5 + .../statistics/_models/read-history-event.ts | 10 + .../statistics/_models/server-statistics.ts | 19 + .../src/app/statistics/_models/stat-count.ts | 4 + .../src/app/statistics/_models/top-reads.ts | 7 + .../_models/user-read-statistics.ts | 8 + .../src/app/statistics/statistics.module.ts | 48 + .../user-preferences.component.html | 3 + .../user-preferences.component.ts | 2 + .../app/user-settings/user-settings.module.ts | 3 + UI/Web/src/styles.scss | 2 + UI/Web/src/theme/components/_table.scss | 23 + UI/Web/src/theme/themes/dark.scss | 3 - openapi.json | 751 +++++++- 106 files changed, 6898 insertions(+), 170 deletions(-) create mode 100644 API/Constants/ResponseCacheProfiles.cs create mode 100644 API/Controllers/StatsController.cs create mode 100644 API/DTOs/Statistics/Count.cs create mode 100644 API/DTOs/Statistics/FileExtensionBreakdownDto.cs create mode 100644 API/DTOs/Statistics/ICount.cs create mode 100644 API/DTOs/Statistics/ReadHistoryEvent.cs create mode 100644 API/DTOs/Statistics/ServerStatistics.cs create mode 100644 API/DTOs/Statistics/TopReadsDto.cs create mode 100644 API/DTOs/Statistics/UserReadStatistics.cs create mode 100644 API/Data/MigrateUserProgressLibraryId.cs create mode 100644 API/Data/Migrations/20221126133824_FileLengthAndExtension.Designer.cs create mode 100644 API/Data/Migrations/20221126133824_FileLengthAndExtension.cs create mode 100644 API/Data/Migrations/20221128230726_UserProgressLibraryId.Designer.cs create mode 100644 API/Data/Migrations/20221128230726_UserProgressLibraryId.cs create mode 100644 API/Services/StatisticService.cs create mode 100644 UI/Web/libs/iharbeck-ngx-virtual-scroller-14.0.5.tgz create mode 100644 UI/Web/src/app/_services/statistics.service.ts create mode 100644 UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts create mode 100644 UI/Web/src/app/_single-module/table/table.module.ts create mode 100644 UI/Web/src/app/pipe/bytes.pipe.ts create mode 100644 UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.html create mode 100644 UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.scss create mode 100644 UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.ts create mode 100644 UI/Web/src/app/statistics/_components/manga-format-stats/manga-format-stats.component.html create mode 100644 UI/Web/src/app/statistics/_components/manga-format-stats/manga-format-stats.component.scss create mode 100644 UI/Web/src/app/statistics/_components/manga-format-stats/manga-format-stats.component.ts create mode 100644 UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.html create mode 100644 UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.scss create mode 100644 UI/Web/src/app/statistics/_components/publication-status-stats/publication-status-stats.component.ts create mode 100644 UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html create mode 100644 UI/Web/src/app/statistics/_components/server-stats/server-stats.component.scss create mode 100644 UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts create mode 100644 UI/Web/src/app/statistics/_components/stat-list/stat-list.component.html create mode 100644 UI/Web/src/app/statistics/_components/stat-list/stat-list.component.scss create mode 100644 UI/Web/src/app/statistics/_components/stat-list/stat-list.component.ts create mode 100644 UI/Web/src/app/statistics/_components/top-readers/top-readers.component.html create mode 100644 UI/Web/src/app/statistics/_components/top-readers/top-readers.component.scss create mode 100644 UI/Web/src/app/statistics/_components/top-readers/top-readers.component.ts create mode 100644 UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html create mode 100644 UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.scss create mode 100644 UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.ts create mode 100644 UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html create mode 100644 UI/Web/src/app/statistics/_components/user-stats/user-stats.component.scss create mode 100644 UI/Web/src/app/statistics/_components/user-stats/user-stats.component.ts create mode 100644 UI/Web/src/app/statistics/_models/file-breakdown.ts create mode 100644 UI/Web/src/app/statistics/_models/mode.ts create mode 100644 UI/Web/src/app/statistics/_models/pie-data-item.ts create mode 100644 UI/Web/src/app/statistics/_models/read-history-event.ts create mode 100644 UI/Web/src/app/statistics/_models/server-statistics.ts create mode 100644 UI/Web/src/app/statistics/_models/stat-count.ts create mode 100644 UI/Web/src/app/statistics/_models/top-reads.ts create mode 100644 UI/Web/src/app/statistics/_models/user-read-statistics.ts create mode 100644 UI/Web/src/app/statistics/statistics.module.ts create mode 100644 UI/Web/src/theme/components/_table.scss diff --git a/API/Constants/ResponseCacheProfiles.cs b/API/Constants/ResponseCacheProfiles.cs new file mode 100644 index 000000000..050a769c7 --- /dev/null +++ b/API/Constants/ResponseCacheProfiles.cs @@ -0,0 +1,17 @@ +namespace API.Constants; + +public static class ResponseCacheProfiles +{ + public const string Images = "Images"; + public const string Hour = "Hour"; + public const string TenMinute = "10Minute"; + public const string FiveMinute = "5Minute"; + /// + /// 6 hour long cache as underlying API is expensive + /// + public const string Statistics = "Statistics"; + /// + /// Instant is a very quick cache, because we can't bust based on the query params, but rather body + /// + public const string Instant = "Instant"; +} diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 12b116cb8..cdd13882a 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -1,5 +1,6 @@ using System.IO; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Entities.Enums; using API.Extensions; @@ -31,7 +32,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("chapter-cover")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public async Task GetChapterCoverImage(int chapterId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); @@ -47,7 +48,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("library-cover")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public async Task GetLibraryCoverImage(int libraryId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId)); @@ -63,7 +64,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("volume-cover")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public async Task GetVolumeCoverImage(int volumeId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); @@ -78,7 +79,7 @@ public class ImageController : BaseApiController /// /// Id of Series /// - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId) { @@ -97,7 +98,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("collection-cover")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public async Task GetCollectionCoverImage(int collectionTagId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); @@ -113,7 +114,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("readinglist-cover")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public async Task GetReadingListCoverImage(int readingListId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); @@ -132,7 +133,7 @@ public class ImageController : BaseApiController /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -154,7 +155,7 @@ public class ImageController : BaseApiController /// [Authorize(Policy="RequireAdminRole")] [HttpGet("cover-upload")] - [ResponseCache(CacheProfileName = "Images")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images)] public ActionResult GetCoverUploadImage(string filename) { if (filename.Contains("..")) return BadRequest("Invalid Filename"); diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index b0c9b62be..9b3c5876a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs; using API.DTOs.Filtering; @@ -84,7 +85,7 @@ public class MetadataController : BaseApiController /// String separated libraryIds or null for all ratings /// This API is cached for 1 hour, varying by libraryIds /// - [ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})] [HttpGet("age-ratings")] public async Task>> GetAllAgeRatings(string? libraryIds) { @@ -107,7 +108,7 @@ public class MetadataController : BaseApiController /// String separated libraryIds or null for all publication status /// This API is cached for 1 hour, varying by libraryIds /// - [ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})] [HttpGet("publication-status")] public ActionResult> GetAllPublicationStatus(string? libraryIds) { diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index c13a99079..ad7f61143 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -787,7 +787,7 @@ public class OpdsController : BaseApiController CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"), // We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly accLink, - CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey) + CreatePageStreamLink(series.LibraryId,seriesId, volumeId, chapterId, mangaFile, apiKey) }, Content = new FeedEntryContent() { @@ -800,7 +800,7 @@ public class OpdsController : BaseApiController } [HttpGet("{apiKey}/image")] - public async Task GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) + public async Task GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) { if (pageNumber < 0) return BadRequest("Page cannot be less than 0"); var chapter = await _cacheService.Ensure(chapterId); @@ -823,7 +823,8 @@ public class OpdsController : BaseApiController ChapterId = chapterId, PageNum = pageNumber, SeriesId = seriesId, - VolumeId = volumeId + VolumeId = volumeId, + LibraryId =libraryId }, await GetUser(apiKey)); return File(content, "image/" + format); @@ -866,9 +867,9 @@ public class OpdsController : BaseApiController throw new KavitaException("User does not exist"); } - private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) + private static FeedLink CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) { - var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); + var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); link.TotalPages = mangaFile.Pages; return link; } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index cd2001c08..cff8c24d4 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -56,7 +57,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("pdf")] - [ResponseCache(CacheProfileName = "Hour")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] public async Task GetPdf(int chapterId) { var chapter = await _cacheService.Ensure(chapterId); @@ -90,7 +91,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("image")] - [ResponseCache(CacheProfileName = "Hour")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] [AllowAnonymous] public async Task GetImage(int chapterId, int page) { @@ -122,7 +123,7 @@ public class ReaderController : BaseApiController /// We must use api key as bookmarks could be leaked to other users via the API /// [HttpGet("bookmark-image")] - [ResponseCache(CacheProfileName = "Hour")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] [AllowAnonymous] public async Task GetBookmarkImage(int seriesId, string apiKey, int page) { diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 4433ade21..c93e93fe9 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -383,7 +384,7 @@ public class SeriesController : BaseApiController /// /// /// Do not rely on this API externally. May change without hesitation. - [ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"seriesId"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"seriesId"})] [HttpGet("series-detail")] public async Task> GetSeriesDetailBreakdown(int seriesId) { diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 02727f686..7c921fe8f 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -35,10 +35,11 @@ public class ServerController : BaseApiController private readonly ICleanupService _cleanupService; private readonly IEmailService _emailService; private readonly IBookmarkService _bookmarkService; + private readonly IScannerService _scannerService; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, - ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService) + ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService) { _applicationLifetime = applicationLifetime; _logger = logger; @@ -49,6 +50,7 @@ public class ServerController : BaseApiController _cleanupService = cleanupService; _emailService = emailService; _bookmarkService = bookmarkService; + _scannerService = scannerService; } /// @@ -85,7 +87,7 @@ public class ServerController : BaseApiController public ActionResult CleanupWantToRead() { _logger.LogInformation("{UserName} is clearing running want to read cleanup from admin dashboard", User.GetUsername()); - RecurringJob.TriggerJob(API.Services.TaskScheduler.RemoveFromWantToReadTaskId); + RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId); return Ok(); } @@ -98,7 +100,23 @@ public class ServerController : BaseApiController public ActionResult BackupDatabase() { _logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername()); - RecurringJob.TriggerJob(API.Services.TaskScheduler.BackupTaskId); + RecurringJob.TriggerJob(TaskScheduler.BackupTaskId); + return Ok(); + } + + /// + /// This is a one time task that needs to be ran for v0.7 statistics to work + /// + /// + [HttpPost("analyze-files")] + public ActionResult AnalyzeFiles() + { + _logger.LogInformation("{UserName} is performing file analysis from admin dashboard", User.GetUsername()); + if (TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "AnalyzeFiles", + Array.Empty(), TaskScheduler.DefaultQueue, true)) + return Ok("Job already running"); + + BackgroundJob.Enqueue(() => _scannerService.AnalyzeFiles()); return Ok(); } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs new file mode 100644 index 000000000..4f0f1fcdf --- /dev/null +++ b/API/Controllers/StatsController.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.DTOs.Statistics; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class StatsController : BaseApiController +{ + private readonly IStatisticService _statService; + private readonly IUnitOfWork _unitOfWork; + private readonly UserManager _userManager; + + public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, UserManager userManager) + { + _statService = statService; + _unitOfWork = unitOfWork; + _userManager = userManager; + } + + [HttpGet("user/{userId}/read")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task> GetUserReadStatistics(int userId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user.Id != userId && !await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) + return Unauthorized("You are not authorized to view another user's statistics"); + + return Ok(await _statService.GetUserReadStatistics(userId, new List())); + } + + [Authorize("RequireAdminRole")] + [HttpGet("server/stats")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task> GetHighLevelStats() + { + return Ok(await _statService.GetServerStatistics()); + } + + [Authorize("RequireAdminRole")] + [HttpGet("server/count/year")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetYearStatistics() + { + return Ok(await _statService.GetYearCount()); + } + + [Authorize("RequireAdminRole")] + [HttpGet("server/count/publication-status")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetPublicationStatus() + { + return Ok(await _statService.GetPublicationCount()); + } + + [Authorize("RequireAdminRole")] + [HttpGet("server/count/manga-format")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetMangaFormat() + { + return Ok(await _statService.GetMangaFormatCount()); + } + + [Authorize("RequireAdminRole")] + [HttpGet("server/top/years")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetTopYears() + { + return Ok(await _statService.GetTopYears()); + } + + /// + /// Returns + /// + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("server/top/users")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>> GetTopReads(int days = 0) + { + return Ok(await _statService.GetTopUsers(days)); + } + + [Authorize("RequireAdminRole")] + [HttpGet("server/file-breakdown")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>> GetFileSize() + { + return Ok(await _statService.GetFileBreakdown()); + } + + + [HttpGet("user/reading-history")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>> GetReadingHistory(int userId) + { + // TODO: Put a check in if the calling user is said userId or has admin + + return Ok(await _statService.GetReadingHistory(userId)); + } + +} diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 72d99e13c..6636bf519 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -60,7 +60,7 @@ public class UsersController : BaseApiController public async Task> HasReadingProgress(int libraryId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId)); } @@ -115,6 +115,10 @@ public class UsersController : BaseApiController return BadRequest("There was an issue saving preferences."); } + /// + /// Returns the preferences of the user + /// + /// [HttpGet("get-preferences")] public async Task> GetPreferences() { @@ -122,4 +126,15 @@ public class UsersController : BaseApiController await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername())); } + + /// + /// Returns a list of the user names within the system + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("names")] + public async Task>> GetUserNames() + { + return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName)); + } } diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs index 1bab779cb..1f5142078 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/ProgressDto.cs @@ -12,6 +12,8 @@ public class ProgressDto public int PageNum { get; set; } [Required] public int SeriesId { get; set; } + [Required] + public int LibraryId { get; set; } /// /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position /// on pages that combine multiple "chapters". diff --git a/API/DTOs/Statistics/Count.cs b/API/DTOs/Statistics/Count.cs new file mode 100644 index 000000000..b9f797574 --- /dev/null +++ b/API/DTOs/Statistics/Count.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Statistics; + +public class StatCount : ICount +{ + public T Value { get; set; } + public int Count { get; set; } +} diff --git a/API/DTOs/Statistics/FileExtensionBreakdownDto.cs b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs new file mode 100644 index 000000000..66e5f821b --- /dev/null +++ b/API/DTOs/Statistics/FileExtensionBreakdownDto.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Statistics; + +public class FileExtensionDto +{ + public string Extension { get; set; } + public MangaFormat Format { get; set; } + public long TotalSize { get; set; } + public long TotalFiles { get; set; } +} + +public class FileExtensionBreakdownDto +{ + /// + /// Total bytes for all files + /// + public long TotalFileSize { get; set; } + public IList FileBreakdown { get; set; } + +} diff --git a/API/DTOs/Statistics/ICount.cs b/API/DTOs/Statistics/ICount.cs new file mode 100644 index 000000000..c38f8895e --- /dev/null +++ b/API/DTOs/Statistics/ICount.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Statistics; + +public interface ICount +{ + public T Value { get; set; } + public int Count { get; set; } +} diff --git a/API/DTOs/Statistics/ReadHistoryEvent.cs b/API/DTOs/Statistics/ReadHistoryEvent.cs new file mode 100644 index 000000000..72377c823 --- /dev/null +++ b/API/DTOs/Statistics/ReadHistoryEvent.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.DTOs.Statistics; + +/// +/// Represents a single User's reading event +/// +public class ReadHistoryEvent +{ + public int UserId { get; set; } + public string UserName { get; set; } + public int LibraryId { get; set; } + public int SeriesId { get; set; } + public string SeriesName { get; set; } + public DateTime ReadDate { get; set; } + public int ChapterId { get; set; } + public string ChapterNumber { get; set; } +} diff --git a/API/DTOs/Statistics/ServerStatistics.cs b/API/DTOs/Statistics/ServerStatistics.cs new file mode 100644 index 000000000..76dbd94e0 --- /dev/null +++ b/API/DTOs/Statistics/ServerStatistics.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace API.DTOs.Statistics; + +public class ServerStatistics +{ + public long ChapterCount { get; set; } + public long VolumeCount { get; set; } + public long SeriesCount { get; set; } + public long TotalFiles { get; set; } + public long TotalSize { get; set; } + public long TotalGenres { get; set; } + public long TotalTags { get; set; } + public long TotalPeople { get; set; } + public IEnumerable> MostReadSeries { get; set; } + /// + /// Total users who have started/reading/read per series + /// + public IEnumerable> MostPopularSeries { get; set; } + public IEnumerable> MostActiveUsers { get; set; } + public IEnumerable> MostActiveLibraries { get; set; } + /// + /// Last 5 Series read + /// + public IEnumerable RecentlyRead { get; set; } + + +} diff --git a/API/DTOs/Statistics/TopReadsDto.cs b/API/DTOs/Statistics/TopReadsDto.cs new file mode 100644 index 000000000..dbcb718dc --- /dev/null +++ b/API/DTOs/Statistics/TopReadsDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace API.DTOs.Statistics; + +public class TopReadDto +{ + public int UserId { get; set; } + public string Username { get; set; } + /// + /// Amount of time read on Comic libraries + /// + public long ComicsTime { get; set; } + /// + /// Amount of time read on + /// + public long BooksTime { get; set; } + public long MangaTime { get; set; } +} + diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs new file mode 100644 index 000000000..8ebf4226d --- /dev/null +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; + +namespace API.DTOs.Statistics; + +public class UserReadStatistics +{ + /// + /// Total number of pages read + /// + public long TotalPagesRead { get; set; } + /// + /// Total time spent reading based on estimates + /// + public long TimeSpentReading { get; set; } + /// + /// A list of genres mapped with genre and number of series that fall into said genre + /// + public ICollection> FavoriteGenres { get; set; } + + public long ChaptersRead { get; set; } + public DateTime LastActive { get; set; } + public long AvgHoursPerWeekSpentReading { get; set; } +} diff --git a/API/Data/MigrateChangePasswordRoles.cs b/API/Data/MigrateChangePasswordRoles.cs index d9a07ab24..722f92a7d 100644 --- a/API/Data/MigrateChangePasswordRoles.cs +++ b/API/Data/MigrateChangePasswordRoles.cs @@ -20,7 +20,7 @@ public static class MigrateChangePasswordRoles var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangePasswordRole); if (usersWithRole.Count != 0) return; - var allUsers = await unitOfWork.UserRepository.GetAllUsers(); + var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(); foreach (var user in allUsers) { await userManager.RemoveFromRoleAsync(user, "ChangePassword"); diff --git a/API/Data/MigrateChangeRestrictionRoles.cs b/API/Data/MigrateChangeRestrictionRoles.cs index 25385823b..7e64a7098 100644 --- a/API/Data/MigrateChangeRestrictionRoles.cs +++ b/API/Data/MigrateChangeRestrictionRoles.cs @@ -24,7 +24,7 @@ public static class MigrateChangeRestrictionRoles logger.LogCritical("Running MigrateChangeRestrictionRoles migration"); - var allUsers = await unitOfWork.UserRepository.GetAllUsers(); + var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(); foreach (var user in allUsers) { await userManager.RemoveFromRoleAsync(user, PolicyConstants.ChangeRestrictionRole); diff --git a/API/Data/MigrateUserProgressLibraryId.cs b/API/Data/MigrateUserProgressLibraryId.cs new file mode 100644 index 000000000..8b4d84f3f --- /dev/null +++ b/API/Data/MigrateUserProgressLibraryId.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using API.Entities.Enums; +using API.Entities.Metadata; +using CsvHelper; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data; + +/// +/// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress +/// +public static class MigrateUserProgressLibraryId +{ + public static async Task Migrate(IUnitOfWork unitOfWork, ILogger logger) + { + logger.LogCritical("Running MigrateUserProgressLibraryId migration - Please be patient, this may take some time. This is not an error"); + + var progress = await unitOfWork.AppUserProgressRepository.GetAnyProgress(); + if (progress == null || progress.LibraryId != 0) + { + logger.LogCritical("Running MigrateUserProgressLibraryId migration - complete. Nothing to do"); + return; + } + + var seriesIdsWithLibraryIds = await unitOfWork.SeriesRepository.GetLibraryIdsForSeriesAsync(); + foreach (var prog in await unitOfWork.AppUserProgressRepository.GetAllProgress()) + { + prog.LibraryId = seriesIdsWithLibraryIds[prog.SeriesId]; + unitOfWork.AppUserProgressRepository.Update(prog); + } + + + await unitOfWork.CommitAsync(); + + logger.LogCritical("Running MigrateSeriesRelationsImport migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Migrations/20221126133824_FileLengthAndExtension.Designer.cs b/API/Data/Migrations/20221126133824_FileLengthAndExtension.Designer.cs new file mode 100644 index 000000000..17cfe499d --- /dev/null +++ b/API/Data/Migrations/20221126133824_FileLengthAndExtension.Designer.cs @@ -0,0 +1,1699 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20221126133824_FileLengthAndExtension")] + partial class FileLengthAndExtension + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20221126133824_FileLengthAndExtension.cs b/API/Data/Migrations/20221126133824_FileLengthAndExtension.cs new file mode 100644 index 000000000..d07deaf89 --- /dev/null +++ b/API/Data/Migrations/20221126133824_FileLengthAndExtension.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class FileLengthAndExtension : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Bytes", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "Extension", + table: "MangaFile", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Bytes", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "Extension", + table: "MangaFile"); + } + } +} diff --git a/API/Data/Migrations/20221128230726_UserProgressLibraryId.Designer.cs b/API/Data/Migrations/20221128230726_UserProgressLibraryId.Designer.cs new file mode 100644 index 000000000..067f7d486 --- /dev/null +++ b/API/Data/Migrations/20221128230726_UserProgressLibraryId.Designer.cs @@ -0,0 +1,1702 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20221128230726_UserProgressLibraryId")] + partial class UserProgressLibraryId + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.10"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20221128230726_UserProgressLibraryId.cs b/API/Data/Migrations/20221128230726_UserProgressLibraryId.cs new file mode 100644 index 000000000..383507825 --- /dev/null +++ b/API/Data/Migrations/20221128230726_UserProgressLibraryId.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class UserProgressLibraryId : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LibraryId", + table: "AppUserProgresses", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LibraryId", + table: "AppUserProgresses"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 68065530b..a7b1cccb8 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -280,6 +280,9 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LibraryId") + .HasColumnType("INTEGER"); + b.Property("PagesRead") .HasColumnType("INTEGER"); @@ -588,12 +591,18 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Bytes") + .HasColumnType("INTEGER"); + b.Property("ChapterId") .HasColumnType("INTEGER"); b.Property("Created") .HasColumnType("TEXT"); + b.Property("Extension") + .HasColumnType("TEXT"); + b.Property("FilePath") .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index d2acb3573..172ba8648 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -14,7 +14,13 @@ public interface IAppUserProgressRepository Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); Task HasAnyProgressOnSeriesAsync(int seriesId, int userId); + /// + /// This is built exclusively for + /// + /// + Task GetAnyProgress(); Task> GetUserProgressForSeriesAsync(int seriesId, int userId); + Task> GetAllProgress(); } public class AppUserProgressRepository : IAppUserProgressRepository @@ -85,6 +91,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository .AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId); } + public async Task GetAnyProgress() + { + return await _context.AppUserProgresses.FirstOrDefaultAsync(); + } + /// /// This will return any user progress. This filters out progress rows that have no pages read. /// @@ -98,6 +109,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository .ToListAsync(); } + public async Task> GetAllProgress() + { + return await _context.AppUserProgresses.ToListAsync(); + } + public async Task GetUserProgressAsync(int chapterId, int userId) { return await _context.AppUserProgresses diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index ce65883cc..9bc127280 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -1,20 +1,29 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.DTOs.Metadata; using API.DTOs.Reader; using API.Entities; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +[Flags] +public enum ChapterIncludes +{ + None = 1, + Volumes = 2, +} + public interface IChapterRepository { void Update(Chapter chapter); - Task> GetChaptersByIdsAsync(IList chapterIds); + Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); Task GetChapterAsync(int chapterId); @@ -43,11 +52,11 @@ public class ChapterRepository : IChapterRepository _context.Entry(chapter).State = EntityState.Modified; } - public async Task> GetChaptersByIdsAsync(IList chapterIds) + public async Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes) { return await _context.Chapter .Where(c => chapterIds.Contains(c.Id)) - .Include(c => c.Volume) + .Includes(includes) .AsSplitQuery() .ToListAsync(); } diff --git a/API/Data/Repositories/MangaFileRepository.cs b/API/Data/Repositories/MangaFileRepository.cs index 64101324a..e45700b32 100644 --- a/API/Data/Repositories/MangaFileRepository.cs +++ b/API/Data/Repositories/MangaFileRepository.cs @@ -1,4 +1,7 @@ -using API.Entities; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; using AutoMapper; using Microsoft.EntityFrameworkCore; @@ -7,6 +10,8 @@ namespace API.Data.Repositories; public interface IMangaFileRepository { void Update(MangaFile file); + Task AnyMissingExtension(); + Task> GetAllWithMissingExtension(); } public class MangaFileRepository : IMangaFileRepository @@ -24,4 +29,16 @@ public class MangaFileRepository : IMangaFileRepository { _context.Entry(file).State = EntityState.Modified; } + + public async Task AnyMissingExtension() + { + return (await _context.MangaFile.CountAsync(f => string.IsNullOrEmpty(f.Extension))) > 0; + } + + public async Task> GetAllWithMissingExtension() + { + return await _context.MangaFile + .Where(f => string.IsNullOrEmpty(f.Extension)) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 3ad2d93f4..b0c7f4e6a 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -119,6 +119,11 @@ public interface ISeriesRepository Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); Task>> GetFolderPathMap(int libraryId); Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); + /// + /// This is only used for + /// + /// + Task> GetLibraryIdsForSeriesAsync(); } public class SeriesRepository : ISeriesRepository @@ -283,14 +288,7 @@ public class SeriesRepository : ISeriesRepository { if (libraryId == 0) { - return await _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(user => user.Id == userId)) - .IsRestricted(queryContext) - .AsNoTracking() - .AsSplitQuery() - .Select(library => library.Id) - .ToListAsync(); + return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync(); } return new List() @@ -513,6 +511,21 @@ public class SeriesRepository : ISeriesRepository return seriesChapters; } + public async Task> GetLibraryIdsForSeriesAsync() + { + var seriesChapters = new Dictionary(); + var series = await _context.Series.Select(s => new + { + Id = s.Id, LibraryId = s.LibraryId + }).ToListAsync(); + foreach (var s in series) + { + seriesChapters.Add(s.Id, s.LibraryId); + } + + return seriesChapters; + } + public async Task AddSeriesModifiers(int userId, List series) { var userProgress = await _context.AppUserProgresses @@ -672,7 +685,8 @@ public class SeriesRepository : ISeriesRepository var cutoffProgressPoint = DateTime.Now - TimeSpan.FromDays(30); var cutoffLastAddedPoint = DateTime.Now - TimeSpan.FromDays(7); - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Dashboard) + .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); @@ -1046,7 +1060,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.Library.GetUserLibraries(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var usersSeriesIds = _context.Series @@ -1073,7 +1087,8 @@ public class SeriesRepository : ISeriesRepository public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended) + .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); @@ -1100,7 +1115,8 @@ public class SeriesRepository : ISeriesRepository /// public async Task> GetRediscover(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended) + .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses .Where(s => usersSeriesIds.Contains(s.SeriesId)) @@ -1119,7 +1135,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForMangaFile(int mangaFileId, int userId) { - var libraryIds = GetLibraryIdsForUser(userId, 0, QueryContext.Search); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Search); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.MangaFile @@ -1136,7 +1152,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForChapter(int chapterId, int userId) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.Library.GetUserLibraries(userId); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Chapter .Where(m => m.Id == chapterId) @@ -1278,7 +1294,8 @@ public class SeriesRepository : ISeriesRepository public async Task> GetHighlyRated(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended) + .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithHighRating = _context.AppUserRating .Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4) @@ -1299,7 +1316,8 @@ public class SeriesRepository : ISeriesRepository public async Task> GetQuickReads(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended) + .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses .Where(s => usersSeriesIds.Contains(s.SeriesId)) @@ -1325,7 +1343,8 @@ public class SeriesRepository : ISeriesRepository public async Task> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended); + var libraryIds = _context.Library.GetUserLibraries(userId, QueryContext.Recommended) + .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var distinctSeriesIdsWithProgress = _context.AppUserProgresses .Where(s => usersSeriesIds.Contains(s.SeriesId)) @@ -1350,37 +1369,9 @@ public class SeriesRepository : ISeriesRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - /// - /// Returns all library ids for a user - /// - /// - /// 0 for no library filter - /// Defaults to None - The context behind this query, so appropriate restrictions can be placed - /// - private IQueryable GetLibraryIdsForUser(int userId, int libraryId = 0, QueryContext queryContext = QueryContext.None) - { - var user = _context.AppUser - .AsSplitQuery() - .AsNoTracking() - .Where(u => u.Id == userId) - .AsSingleQuery(); - - if (libraryId == 0) - { - return user.SelectMany(l => l.Libraries) - .IsRestricted(queryContext) - .Select(lib => lib.Id); - } - - return user.SelectMany(l => l.Libraries) - .Where(lib => lib.Id == libraryId) - .IsRestricted(queryContext) - .Select(lib => lib.Id); - } - public async Task GetRelatedSeries(int userId, int seriesId) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = _context.Library.GetUserLibraries(userId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); @@ -1486,7 +1477,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter) { - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); var query = _context.AppUser .Where(user => user.Id == userId) .SelectMany(u => u.WantToRead) @@ -1501,8 +1492,7 @@ public class SeriesRepository : ISeriesRepository public async Task IsSeriesInWantToRead(int userId, int seriesId) { - // BUG: This is always returning true for any series - var libraryIds = GetLibraryIdsForUser(userId); + var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); return await _context.AppUser .Where(user => user.Id == userId) .SelectMany(u => u.WantToRead.Where(s => s.Id == seriesId && libraryIds.Contains(s.LibraryId))) diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 904cc64b1..f718aab31 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -59,10 +59,9 @@ public interface IUserRepository Task GetUserIdByUsernameAsync(string username); Task> GetAllBookmarksByIds(IList bookmarkIds); Task GetUserByEmailAsync(string email); - Task> GetAllUsers(); Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); - Task> GetAllUsersAsync(AppUserIncludes includeFlags); + Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByConfirmationToken(string token); } @@ -241,11 +240,6 @@ public class UserRepository : IUserRepository return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(lowerEmail)); } - public async Task> GetAllUsers() - { - return await _context.AppUser - .ToListAsync(); - } public async Task> GetAllPreferencesByThemeAsync(int themeId) { @@ -264,7 +258,7 @@ public class UserRepository : IUserRepository .AnyAsync(library => library.AppUsers.Any(user => user.Id == userId)); } - public async Task> GetAllUsersAsync(AppUserIncludes includeFlags) + public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None) { var query = AddIncludesToQuery(_context.Users.AsQueryable(), includeFlags); return await query.ToListAsync(); diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 6804bfa98..4486d73af 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -7,7 +7,6 @@ namespace API.Entities; /// /// Represents the progress a single user has on a given Chapter. /// -//[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)] public class AppUserProgress : IEntityDate { /// @@ -27,6 +26,10 @@ public class AppUserProgress : IEntityDate /// public int SeriesId { get; set; } /// + /// Library belonging to Chapter + /// + public int LibraryId { get; set; } + /// /// Chapter /// public int ChapterId { get; set; } diff --git a/API/Entities/Enums/MangaFormat.cs b/API/Entities/Enums/MangaFormat.cs index cea506471..26f744b9b 100644 --- a/API/Entities/Enums/MangaFormat.cs +++ b/API/Entities/Enums/MangaFormat.cs @@ -20,8 +20,9 @@ public enum MangaFormat [Description("Archive")] Archive = 1, /// - /// Unknown. Not used. + /// Unknown /// + /// Default state for all files, but at end of processing, will never be Unknown. [Description("Unknown")] Unknown = 2, /// diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 5f78dd7f7..9377a86a7 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -21,9 +21,16 @@ public class MangaFile : IEntityDate /// public int Pages { get; set; } public MangaFormat Format { get; set; } + /// + /// How many bytes make up this file + /// + public long Bytes { get; set; } + /// + /// File extension + /// + public string Extension { get; set; } /// public DateTime Created { get; set; } - /// /// Last time underlying file was modified /// diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index ba2e2f6cf..327290b33 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -48,6 +48,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/DateTimeExtensions.cs b/API/Extensions/DateTimeExtensions.cs index da205608c..3967641ef 100644 --- a/API/Extensions/DateTimeExtensions.cs +++ b/API/Extensions/DateTimeExtensions.cs @@ -15,4 +15,10 @@ public static class DateTimeExtensions { return new DateTime(date.Ticks - (date.Ticks % resolution), date.Kind); } + + public static DateTime StartOfWeek(this DateTime dt, DayOfWeek startOfWeek) + { + int diff = (7 + (dt.DayOfWeek - startOfWeek)) % 7; + return dt.AddDays(-1 * diff).Date; + } } diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs index 3deac20e4..efae46cd2 100644 --- a/API/Extensions/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; @@ -123,6 +125,17 @@ public static class QueryableExtensions return queryable.AsSplitQuery(); } + public static IQueryable Includes(this IQueryable queryable, + ChapterIncludes includes) + { + if (includes.HasFlag(ChapterIncludes.Volumes)) + { + queryable = queryable.Include(v => v.Volume); + } + + return queryable.AsSplitQuery(); + } + public static IQueryable Includes(this IQueryable query, SeriesIncludes includeFlags) { @@ -186,4 +199,25 @@ public static class QueryableExtensions return query; } + + /// + /// Returns all libraries for a given user + /// + /// + /// + /// + /// + public static IQueryable GetUserLibraries(this IQueryable library, int userId, QueryContext queryContext = QueryContext.None) + { + return library + .Include(l => l.AppUsers) + .Where(lib => lib.AppUsers.Any(user => user.Id == userId)) + .IsRestricted(queryContext) + .AsNoTracking() + .AsSplitQuery() + .Select(lib => lib.Id); + } + + public static IEnumerable Range(this DateTime startDate, int numberOfDays) => + Enumerable.Range(0, numberOfDays).Select(e => startDate.AddDays(e)); } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index fcb111d98..04257b5f9 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -103,6 +103,7 @@ public class ReaderService : IReaderService public async Task MarkChaptersAsRead(AppUser user, int seriesId, IList chapters) { var seenVolume = new Dictionary(); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); foreach (var chapter in chapters) { var userProgress = GetUserProgressForChapter(user, chapter); @@ -114,7 +115,8 @@ public class ReaderService : IReaderService PagesRead = chapter.Pages, VolumeId = chapter.VolumeId, SeriesId = seriesId, - ChapterId = chapter.Id + ChapterId = chapter.Id, + LibraryId = series.LibraryId }); } else @@ -239,6 +241,7 @@ public class ReaderService : IReaderService VolumeId = progressDto.VolumeId, SeriesId = progressDto.SeriesId, ChapterId = progressDto.ChapterId, + LibraryId = progressDto.LibraryId, BookScrollId = progressDto.BookScrollId, LastModified = DateTime.Now }); @@ -249,6 +252,7 @@ public class ReaderService : IReaderService userProgress.PagesRead = progressDto.PageNum; userProgress.SeriesId = progressDto.SeriesId; userProgress.VolumeId = progressDto.VolumeId; + userProgress.LibraryId = progressDto.LibraryId; userProgress.BookScrollId = progressDto.BookScrollId; userProgress.LastModified = DateTime.Now; _unitOfWork.AppUserProgressRepository.Update(userProgress); diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 55c842252..81c512756 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -195,7 +195,7 @@ public class ReadingListService : IReadingListService } var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); - var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) + var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes)) .OrderBy(c => Tasks.Scanner.Parser.Parser.MinNumberFromRange(c.Volume.Name)) .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting) .ToList(); diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs new file mode 100644 index 000000000..ea66c9a98 --- /dev/null +++ b/API/Services/StatisticService.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.DTOs.Statistics; +using API.Entities.Enums; +using API.Extensions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IStatisticService +{ + Task GetServerStatistics(); + Task GetUserReadStatistics(int userId, IList libraryIds); + Task>> GetYearCount(); + Task>> GetTopYears(); + Task>> GetPublicationCount(); + Task>> GetMangaFormatCount(); + Task GetFileBreakdown(); + Task> GetTopUsers(int days); + Task> GetReadingHistory(int userId); + Task> GetHistory(); +} + +/// +/// Responsible for computing statistics for the server +/// +/// This performs raw queries and does not use a repository +public class StatisticService : IStatisticService +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; + + public StatisticService(DataContext context, IMapper mapper, IUnitOfWork unitOfWork) + { + _context = context; + _mapper = mapper; + _unitOfWork = unitOfWork; + } + + public async Task GetUserReadStatistics(int userId, IList libraryIds) + { + if (libraryIds.Count == 0) + libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + + // Total Pages Read + var totalPagesRead = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId) + .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 chapterIds = ids.Select(id => id.ChapterId); + + var timeSpentReading = await _context.Chapter + .Where(c => chapterIds.Contains(c.Id)) + .SumAsync(c => c.AvgHoursToRead); + + // Maybe make this top 5 genres? But usually there are 3-5 genres that are always common... + // Maybe use rating to calculate top genres? + // var genres = await _context.Series + // .Where(s => seriesIds.Contains(s.Id)) + // .Select(s => s.Metadata) + // .SelectMany(sm => sm.Genres) + // //.DistinctBy(g => g.NormalizedTitle) + // .ToListAsync(); + + // How many series of each format have you read? (Epub, Archive, etc) + + // Percentage of libraries read. For each library, get the total pages vs read + //var allLibraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var chaptersRead = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId) + .Where(p => libraryIds.Contains(p.LibraryId)) + .Where(p => p.PagesRead >= _context.Chapter.Single(c => c.Id == p.ChapterId).Pages) + .CountAsync(); + + var lastActive = await _context.AppUserProgresses + .OrderByDescending(p => p.LastModified) + .Select(p => p.LastModified) + .FirstOrDefaultAsync(); + + //var + + return new UserReadStatistics() + { + TotalPagesRead = totalPagesRead, + TimeSpentReading = timeSpentReading, + ChaptersRead = chaptersRead, + LastActive = lastActive, + }; + } + + /// + /// Returns the Release Years and their count + /// + /// + public async Task>> GetYearCount() + { + return await _context.SeriesMetadata + .Where(sm => sm.ReleaseYear != 0) + .AsSplitQuery() + .GroupBy(sm => sm.ReleaseYear) + .Select(sm => new StatCount + { + Value = sm.Key, + Count = _context.SeriesMetadata.Where(sm2 => sm2.ReleaseYear == sm.Key).Distinct().Count() + }) + .OrderByDescending(d => d.Value) + .ToListAsync(); + } + + public async Task>> GetTopYears() + { + return await _context.SeriesMetadata + .Where(sm => sm.ReleaseYear != 0) + .AsSplitQuery() + .GroupBy(sm => sm.ReleaseYear) + .Select(sm => new StatCount + { + Value = sm.Key, + Count = _context.SeriesMetadata.Where(sm2 => sm2.ReleaseYear == sm.Key).Distinct().Count() + }) + .OrderByDescending(d => d.Count) + .Take(5) + .ToListAsync(); + } + + + + public async Task>> GetPublicationCount() + { + return await _context.SeriesMetadata + .AsSplitQuery() + .GroupBy(sm => sm.PublicationStatus) + .Select(sm => new StatCount + { + Value = sm.Key, + Count = _context.SeriesMetadata.Where(sm2 => sm2.PublicationStatus == sm.Key).Distinct().Count() + }) + .ToListAsync(); + } + + public async Task>> GetMangaFormatCount() + { + return await _context.MangaFile + .AsSplitQuery() + .GroupBy(sm => sm.Format) + .Select(mf => new StatCount + { + Value = mf.Key, + Count = _context.MangaFile.Where(mf2 => mf2.Format == mf.Key).Distinct().Count() + }) + .ToListAsync(); + } + + + public async Task GetServerStatistics() + { + var mostActiveUsers = _context.AppUserProgresses + .AsSplitQuery() + .AsEnumerable() + .GroupBy(sm => sm.AppUserId) + .Select(sm => new StatCount + { + Value = _context.AppUser.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) + .Single(), + Count = _context.AppUserProgresses.Where(u => u.AppUserId == sm.Key).Distinct().Count() + }) + .OrderByDescending(d => d.Count) + .Take(5); + + var mostActiveLibrary = _context.AppUserProgresses + .AsSplitQuery() + .AsEnumerable() + .Where(sm => sm.LibraryId > 0) + .GroupBy(sm => sm.LibraryId) + .Select(sm => new StatCount + { + Value = _context.Library.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) + .Single(), + Count = _context.AppUserProgresses.Where(u => u.LibraryId == sm.Key).Distinct().Count() + }) + .OrderByDescending(d => d.Count) + .Take(5); + + var mostPopularSeries = _context.AppUserProgresses + .AsSplitQuery() + .AsEnumerable() + .GroupBy(sm => sm.SeriesId) + .Select(sm => new StatCount + { + Value = _context.Series.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) + .Single(), + Count = _context.AppUserProgresses.Where(u => u.SeriesId == sm.Key).Distinct().Count() + }) + .OrderByDescending(d => d.Count) + .Take(5); + + var mostReadSeries = _context.AppUserProgresses + .AsSplitQuery() + .AsEnumerable() + .GroupBy(sm => sm.SeriesId) + .Select(sm => new StatCount + { + Value = _context.Series.Where(u => u.Id == sm.Key).ProjectTo(_mapper.ConfigurationProvider) + .Single(), + Count = _context.AppUserProgresses.Where(u => u.SeriesId == sm.Key).AsEnumerable().DistinctBy(p => p.AppUserId).Count() + }) + .OrderByDescending(d => d.Count) + .Take(5); + + var seriesIds = (await _context.AppUserProgresses + .AsSplitQuery() + .OrderByDescending(d => d.LastModified) + .Select(d => d.SeriesId) + .ToListAsync()) + .Distinct() + .Take(5); + + var recentlyRead = _context.Series + .AsSplitQuery() + .Where(s => seriesIds.Contains(s.Id)) + .ProjectTo(_mapper.ConfigurationProvider) + .AsEnumerable(); + + return new ServerStatistics() + { + ChapterCount = await _context.Chapter.CountAsync(), + SeriesCount = await _context.Series.CountAsync(), + TotalFiles = await _context.MangaFile.CountAsync(), + TotalGenres = await _context.Genre.CountAsync(), + TotalPeople = await _context.Person.CountAsync(), + TotalSize = await _context.MangaFile.SumAsync(m => m.Bytes), + TotalTags = await _context.Tag.CountAsync(), + VolumeCount = await _context.Volume.Where(v => v.Number != 0).CountAsync(), + MostActiveUsers = mostActiveUsers, + MostActiveLibraries = mostActiveLibrary, + MostPopularSeries = mostPopularSeries, + MostReadSeries = mostReadSeries, + RecentlyRead = recentlyRead + }; + } + + public async Task GetFileBreakdown() + { + return new FileExtensionBreakdownDto() + { + FileBreakdown = await _context.MangaFile + .AsSplitQuery() + .AsNoTracking() + .GroupBy(sm => sm.Extension) + .Select(mf => new FileExtensionDto() + { + Extension = mf.Key, + Format =_context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Select(mf2 => mf2.Format).Single(), + TotalSize = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Sum(mf2 => mf2.Bytes), + TotalFiles = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Count() + }) + .ToListAsync(), + TotalFileSize = await _context.MangaFile + .AsNoTracking() + .AsSplitQuery() + .SumAsync(f => f.Bytes) + }; + } + + public async Task> GetReadingHistory(int userId) + { + return await _context.AppUserProgresses + .Where(u => u.AppUserId == userId) + .AsNoTracking() + .AsSplitQuery() + .Select(u => new ReadHistoryEvent + { + UserId = u.AppUserId, + UserName = _context.AppUser.Single(u => u.Id == userId).UserName, + SeriesName = _context.Series.Single(s => s.Id == u.SeriesId).Name, + SeriesId = u.SeriesId, + LibraryId = u.LibraryId, + ReadDate = u.LastModified, + ChapterId = u.ChapterId, + ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).Number + }) + .OrderByDescending(d => d.ReadDate) + .ToListAsync(); + } + + public Task> GetHistory() + { + // _context.AppUserProgresses + // .AsSplitQuery() + // .AsEnumerable() + // .GroupBy(sm => sm.LastModified) + // .Select(sm => new + // { + // User = _context.AppUser.Single(u => u.Id == sm.Key), + // Chapters = _context.Chapter.Where(c => _context.AppUserProgresses + // .Where(u => u.AppUserId == sm.Key) + // .Where(p => p.PagesRead > 0) + // .Select(p => p.ChapterId) + // .Distinct() + // .Contains(c.Id)) + // }) + // .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead)) + // .Take(5) + // .ToList(); + + var firstOfWeek = DateTime.Now.StartOfWeek(DayOfWeek.Monday); + var groupedReadingDays = _context.AppUserProgresses + .Where(x => x.LastModified >= firstOfWeek) + .GroupBy(x => x.LastModified.Day) + .Select(g => new StatCount() + { + Value = g.Key, + Count = _context.AppUserProgresses.Where(p => p.LastModified.Day == g.Key).Select(p => p.ChapterId).Distinct().Count() + }) + .AsEnumerable(); + + // var records = firstOfWeek.Range(7) + // .GroupJoin(groupedReadingDays, wd => wd.Day, lg => lg.Key, (_, lg) => lg.Any() ? lg.First().Count() : 0).ToArray(); + return Task.FromResult>(null); + } + + + public async Task> GetTopUsers(int days) + { + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + var users = (await _unitOfWork.UserRepository.GetAllUsersAsync()).ToList(); + var minDate = DateTime.Now.Subtract(TimeSpan.FromDays(days)); + + var topUsersAndReadChapters = _context.AppUserProgresses + .AsSplitQuery() + .AsEnumerable() + .GroupBy(sm => sm.AppUserId) + .Select(sm => new + { + User = _context.AppUser.Single(u => u.Id == sm.Key), + Chapters = _context.Chapter.Where(c => _context.AppUserProgresses + .Where(u => u.AppUserId == sm.Key) + .Where(p => p.PagesRead > 0) + .Where(p => days == 0 || (p.Created >= minDate && p.LastModified >= minDate)) + .Select(p => p.ChapterId) + .Distinct() + .Contains(c.Id)) + }) + .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead)) + .Take(5) + .ToList(); + + + // Need a mapping of Library to chapter ids + var chapterIdWithLibraryId = topUsersAndReadChapters + .SelectMany(u => u.Chapters + .Select(c => c.Id)).Select(d => new + { + LibraryId = _context.Chapter.Where(c => c.Id == d).AsSplitQuery().Select(c => c.Volume).Select(v => v.Series).Select(s => s.LibraryId).Single(), + ChapterId = d + }) + .ToList(); + + var chapterLibLookup = new Dictionary(); + foreach (var cl in chapterIdWithLibraryId) + { + if (chapterLibLookup.ContainsKey(cl.ChapterId)) continue; + chapterLibLookup.Add(cl.ChapterId, cl.LibraryId); + } + + var user = new Dictionary>(); + foreach (var userChapter in topUsersAndReadChapters) + { + if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, new Dictionary()); + var libraryTimes = user[userChapter.User.Id]; + + foreach (var chapter in userChapter.Chapters) + { + var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]); + if (!libraryTimes.ContainsKey(library.Type)) libraryTimes.Add(library.Type, 0L); + var existingHours = libraryTimes[library.Type]; + libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead; + } + + user[userChapter.User.Id] = libraryTimes; + } + + var ret = new List(); + foreach (var userId in user.Keys) + { + ret.Add(new TopReadDto() + { + UserId = userId, + Username = users.First(u => u.Id == userId).UserName, + BooksTime = user[userId].ContainsKey(LibraryType.Book) ? user[userId][LibraryType.Book] : 0, + ComicsTime = user[userId].ContainsKey(LibraryType.Comic) ? user[userId][LibraryType.Comic] : 0, + MangaTime = user[userId].ContainsKey(LibraryType.Manga) ? user[userId][LibraryType.Manga] : 0, + }); + } + + return ret; + } +} diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index c61b72bdb..d47520084 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -27,7 +27,7 @@ public interface IProcessSeries /// /// Task Prime(); - Task ProcessSeriesAsync(IList parsedInfos, Library library); + Task ProcessSeriesAsync(IList parsedInfos, Library library, bool forceUpdate = false); void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false); } @@ -75,7 +75,7 @@ public class ProcessSeries : IProcessSeries _tags = await _unitOfWork.TagRepository.GetAllTagsAsync(); } - public async Task ProcessSeriesAsync(IList parsedInfos, Library library) + public async Task ProcessSeriesAsync(IList parsedInfos, Library library, bool forceUpdate = false) { if (!parsedInfos.Any()) return; @@ -120,7 +120,7 @@ public class ProcessSeries : IProcessSeries // parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort) var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo); - UpdateVolumes(series, parsedInfos); + UpdateVolumes(series, parsedInfos, forceUpdate); series.Pages = series.Volumes.Sum(v => v.Pages); series.NormalizedName = Parser.Parser.Normalize(series.Name); @@ -430,7 +430,7 @@ public class ProcessSeries : IProcessSeries }); } - private void UpdateVolumes(Series series, IList parsedInfos) + private void UpdateVolumes(Series series, IList parsedInfos, bool forceUpdate = false) { var startingVolumeCount = series.Volumes.Count; // Add new volumes and update chapters per volume @@ -465,7 +465,7 @@ public class ProcessSeries : IProcessSeries _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(series, volume, infos); + UpdateChapters(series, volume, infos, forceUpdate); volume.Pages = volume.Chapters.Sum(c => c.Pages); // Update all the metadata on the Chapters @@ -512,7 +512,7 @@ public class ProcessSeries : IProcessSeries series.Name, startingVolumeCount, series.Volumes.Count); } - private void UpdateChapters(Series series, Volume volume, IList parsedInfos) + private void UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) { // Add new chapters foreach (var info in parsedInfos) @@ -546,7 +546,7 @@ public class ProcessSeries : IProcessSeries if (chapter == null) continue; // Add files var specialTreatment = info.IsSpecialInfo(); - AddOrUpdateFileForChapter(chapter, info); + AddOrUpdateFileForChapter(chapter, info, forceUpdate); chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters) + string.Empty; chapter.Range = specialTreatment ? info.Filename : info.Chapters; } @@ -572,22 +572,26 @@ public class ProcessSeries : IProcessSeries } } - private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false) { chapter.Files ??= new List(); var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(info.FullFilePath); if (existingFile != null) { existingFile.Format = info.Format; - if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; + if (!forceUpdate && !_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); + existingFile.Extension = fileInfo.Extension.ToLowerInvariant(); + existingFile.Bytes = fileInfo.Length; // We skip updating DB here with last modified time so that metadata refresh can do it } else { var file = DbFactory.MangaFile(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format)); if (file == null) return; - + file.Extension = fileInfo.Extension.ToLowerInvariant(); + file.Bytes = fileInfo.Length; chapter.Files.Add(file); } } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index f934e6ba6..5eedb6734 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -42,6 +42,7 @@ public interface IScannerService Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true); Task ScanFolder(string folder); + Task AnalyzeFiles(); } @@ -97,6 +98,35 @@ public class ScannerService : IScannerService _wordCountAnalyzerService = wordCountAnalyzerService; } + /// + /// This is only used for v0.7 to get files analyzed + /// + public async Task AnalyzeFiles() + { + _logger.LogInformation("Starting Analyze Files task"); + var missingExtensions = await _unitOfWork.MangaFileRepository.GetAllWithMissingExtension(); + if (missingExtensions.Count == 0) + { + _logger.LogInformation("Nothing to do"); + return; + } + + var sw = Stopwatch.StartNew(); + + foreach (var file in missingExtensions) + { + var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(file.FilePath); + if (!fileInfo.Exists)continue; + file.Extension = fileInfo.Extension.ToLowerInvariant(); + file.Bytes = fileInfo.Length; + _unitOfWork.MangaFileRepository.Update(file); + } + + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Completed Analyze Files task in {ElapsedTime}", sw.Elapsed); + } + /// /// Given a generic folder path, will invoke a Series scan or Library scan. /// @@ -483,7 +513,7 @@ public class ScannerService : IScannerService seenSeries.Add(foundParsedSeries); - processTasks.Add(async () => await _processSeries.ProcessSeriesAsync(parsedFiles, library)); + processTasks.Add(async () => await _processSeries.ProcessSeriesAsync(parsedFiles, library, forceUpdate)); return Task.CompletedTask; } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 0f1653a7c..5cd6510d0 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -121,7 +121,7 @@ public class StatsService : IStatsService NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), OPDSEnabled = serverSettings.EnableOpds, - NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsers()).Count(), + NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(), TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(), TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), diff --git a/API/Startup.cs b/API/Startup.cs index 55694a923..d3481ed51 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -7,6 +7,7 @@ using System.Net; using System.Net.Sockets; using System.Reflection; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Entities; using API.Entities.Enums; @@ -59,35 +60,40 @@ public class Startup services.AddControllers(options => { - options.CacheProfiles.Add("Images", + options.CacheProfiles.Add(ResponseCacheProfiles.Images, new CacheProfile() { Duration = 60, Location = ResponseCacheLocation.None, NoStore = false }); - options.CacheProfiles.Add("Hour", + options.CacheProfiles.Add(ResponseCacheProfiles.Hour, new CacheProfile() { Duration = 60 * 60, Location = ResponseCacheLocation.None, NoStore = false }); - options.CacheProfiles.Add("10Minute", + options.CacheProfiles.Add(ResponseCacheProfiles.TenMinute, new CacheProfile() { Duration = 60 * 10, Location = ResponseCacheLocation.None, NoStore = false }); - options.CacheProfiles.Add("5Minute", + options.CacheProfiles.Add(ResponseCacheProfiles.FiveMinute, new CacheProfile() { Duration = 60 * 5, Location = ResponseCacheLocation.None, }); - // Instant is a very quick cache, because we can't bust based on the query params, but rather body - options.CacheProfiles.Add("Instant", + options.CacheProfiles.Add(ResponseCacheProfiles.Statistics, + new CacheProfile() + { + Duration = 60 * 60 * 6, + Location = ResponseCacheLocation.None, + }); + options.CacheProfiles.Add(ResponseCacheProfiles.Instant, new CacheProfile() { Duration = 30, @@ -217,6 +223,9 @@ public class Startup // v0.6.2 or v0.7 await MigrateSeriesRelationsImport.Migrate(dataContext, logger); + // v0.6.8 or v0.7 + await MigrateUserProgressLibraryId.Migrate(unitOfWork, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/UI/Web/libs/iharbeck-ngx-virtual-scroller-14.0.5.tgz b/UI/Web/libs/iharbeck-ngx-virtual-scroller-14.0.5.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5b8eb6a3aab33be58243eedebee31110da585dee GIT binary patch literal 107154 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

x=th09pBvTOx3!-c0)qMCCf?mD}3 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 Z?kjWi_4D=f_49B1`G3?*GN=HI2mtZ?mJ { + markChapterAsRead(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { + this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => { chapter.pagesRead = chapter.pages; this.toastr.success('Marked as Read'); if (callback) { @@ -261,8 +261,8 @@ export class ActionService implements OnDestroy { * @param chapter Chapter, should have id, pages, volumeId populated * @param callback Optional callback to perform actions after API completes */ - markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { - this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => { + markChapterAsUnread(libraryId: number, seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { + this.readerService.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0).pipe(take(1)).subscribe(results => { chapter.pagesRead = 0; this.toastr.success('Marked as Unread'); if (callback) { diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 7b9a3e99b..c62e55910 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -106,8 +106,8 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId); } - saveProgress(seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) { - return this.httpClient.post(this.baseUrl + 'reader/progress', {seriesId, volumeId, chapterId, pageNum: page, bookScrollId}); + saveProgress(libraryId: number, seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) { + return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId}); } markVolumeRead(seriesId: number, volumeId: number) { diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index bd6e649c1..9752394b8 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -34,6 +34,10 @@ export class ServerService { return this.httpClient.post(this.baseUrl + 'server/backup-db', {}); } + analyzeFiles() { + return this.httpClient.post(this.baseUrl + 'server/analyze-files', {}); + } + checkForUpdate() { return this.httpClient.get(this.baseUrl + 'server/check-update', {}); } diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts new file mode 100644 index 000000000..4cb72fd27 --- /dev/null +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -0,0 +1,84 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from 'src/environments/environment'; +import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; +import { PublicationStatusPipe } from '../pipe/publication-status.pipe'; +import { map } from 'rxjs'; +import { MangaFormatPipe } from '../pipe/manga-format.pipe'; +import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; +import { TopUserRead } from '../statistics/_models/top-reads'; +import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; +import { ServerStatistics } from '../statistics/_models/server-statistics'; +import { StatCount } from '../statistics/_models/stat-count'; +import { PublicationStatus } from '../_models/metadata/publication-status'; +import { MangaFormat } from '../_models/manga-format'; + + +const publicationStatusPipe = new PublicationStatusPipe(); +const mangaFormatPipe = new MangaFormatPipe(); + +@Injectable({ + providedIn: 'root' +}) +export class StatisticsService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getUserStatistics(userId: number, libraryIds: Array = []) { + // TODO: Convert to httpParams object + let url = 'stats/user/' + userId + '/read'; + if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(','); + + return this.httpClient.get(this.baseUrl + url); + } + + getServerStatistics() { + return this.httpClient.get(this.baseUrl + 'stats/server/stats'); + } + + getYearRange() { + return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/year').pipe( + map(spreads => spreads.map(spread => { + return {name: spread.value + '', value: spread.count}; + }))); + } + + getTopYears() { + return this.httpClient.get[]>(this.baseUrl + 'stats/server/top/years').pipe( + map(spreads => spreads.map(spread => { + return {name: spread.value + '', value: spread.count}; + }))); + } + + getTopUsers(days: number = 0) { + return this.httpClient.get(this.baseUrl + 'stats/server/top/users?days=' + days); + } + + getReadingHistory(userId: number) { + return this.httpClient.get(this.baseUrl + 'stats/user/reading-history?userId=' + userId); + } + + getPublicationStatus() { + return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/publication-status').pipe( + map(spreads => spreads.map(spread => { + return {name: publicationStatusPipe.transform(spread.value), value: spread.count}; + }))); + } + + getMangaFormat() { + return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/manga-format').pipe( + map(spreads => spreads.map(spread => { + return {name: mangaFormatPipe.transform(spread.value), value: spread.count}; + }))); + } + + getTotalSize() { + return this.httpClient.get(this.baseUrl + 'stats/server/file-size', { responseType: 'text' as 'json'}); + } + + getFileBreakdown() { + return this.httpClient.get(this.baseUrl + 'stats/server/file-breakdown'); + } +} diff --git a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts new file mode 100644 index 000000000..a5f19f59a --- /dev/null +++ b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts @@ -0,0 +1,30 @@ +import { Directive, EventEmitter, Input, Output } from "@angular/core"; + +export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0); +export type SortColumn = keyof T | ''; +export type SortDirection = 'asc' | 'desc' | ''; +const rotate: { [key: string]: SortDirection } = { asc: 'desc', desc: 'asc', '': 'asc' }; + +export interface SortEvent { + column: SortColumn; + direction: SortDirection; +} + +@Directive({ + selector: 'th[sortable]', + host: { + '[class.asc]': 'direction === "asc"', + '[class.desc]': 'direction === "desc"', + '(click)': 'rotate()', + }, +}) +export class SortableHeader { + @Input() sortable: SortColumn = ''; + @Input() direction: SortDirection = ''; + @Output() sort = new EventEmitter>(); + + rotate() { + this.direction = rotate[this.direction]; + this.sort.emit({ column: this.sortable, direction: this.direction }); + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/table/table.module.ts b/UI/Web/src/app/_single-module/table/table.module.ts new file mode 100644 index 000000000..e46dc2eea --- /dev/null +++ b/UI/Web/src/app/_single-module/table/table.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SortableHeader } from './_directives/sortable-header.directive'; + + + +@NgModule({ + declarations: [ + SortableHeader + ], + imports: [ + CommonModule + ], + exports: [ + SortableHeader + ] +}) +export class TableModule { } diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index efad95baa..a70ffd69f 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -24,6 +24,7 @@ import { ManageEmailSettingsComponent } from './manage-email-settings/manage-ema import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tasks-settings.component'; import { ManageLogsComponent } from './manage-logs/manage-logs.component'; import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; +import { StatisticsModule } from '../statistics/statistics.module'; @@ -60,7 +61,9 @@ import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; PipeModule, SidenavModule, UserSettingsModule, // API-key componet - VirtualScrollerModule + VirtualScrollerModule, + + StatisticsModule ], providers: [] }) diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.html b/UI/Web/src/app/admin/dashboard/dashboard.component.html index 1b8fc85a8..76ce45d6d 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.html @@ -29,6 +29,9 @@ + + + diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.ts b/UI/Web/src/app/admin/dashboard/dashboard.component.ts index c50c9344b..72a95227c 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.ts +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.ts @@ -14,7 +14,9 @@ enum TabID { System = 'system', Plugins = 'plugins', Tasks = 'tasks', - Logs = 'logs' + Logs = 'logs', + Statistics = 'statistics', + } @Component({ @@ -33,6 +35,7 @@ export class DashboardComponent implements OnInit { {title: 'Email', fragment: TabID.Email}, //{title: 'Plugins', fragment: TabID.Plugins}, {title: 'Tasks', fragment: TabID.Tasks}, + {title: 'Statistics', fragment: TabID.Statistics}, {title: 'System', fragment: TabID.System}, ]; counter = this.tabs.length + 1; diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index b34b80e56..b3e8f3876 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -63,6 +63,12 @@ export class ManageTasksSettingsComponent implements OnInit { api: defer(() => of(this.downloadService.download('logs', undefined))), successMessage: '' }, + { + name: 'Analyze Files', + description: 'Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release.', + api: this.serverService.analyzeFiles(), + successMessage: 'File analysis has been queued' + }, { name: 'Check for Updates', description: 'See if there are any Stable releases ahead of your version', diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 88e445e90..a43c21e84 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -448,7 +448,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (!this.incognitoMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); + this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); } } diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index e9817462d..a1899e454 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -194,7 +194,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { return; } - this.actionService.markChapterAsRead(this.seriesId, chapter, () => { this.cdRef.markForCheck(); }); + this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); }); } markChapterAsUnread(chapter: Chapter) { @@ -202,7 +202,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { return; } - this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { this.cdRef.markForCheck(); }); + this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); }); } handleChapterActionCallback(action: ActionItem, chapter: Chapter) { diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 070d33ff3..cda419fb4 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -1234,7 +1234,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } if (!this.incognitoMode && !this.bookmarkMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */}); + this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */}); } } @@ -1382,7 +1382,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { window.history.replaceState({}, '', newRoute); this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); if (!this.bookmarkMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); + this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); } } diff --git a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts index 57b0d61b9..0dc10a4a0 100644 --- a/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts +++ b/UI/Web/src/app/pdf-reader/_components/pdf-reader/pdf-reader.component.ts @@ -197,7 +197,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy { saveProgress() { if (this.incognitoMode) return; - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {}); + this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.currentPage).subscribe(() => {}); } closeReader() { diff --git a/UI/Web/src/app/pipe/bytes.pipe.ts b/UI/Web/src/app/pipe/bytes.pipe.ts new file mode 100644 index 000000000..e181c70d9 --- /dev/null +++ b/UI/Web/src/app/pipe/bytes.pipe.ts @@ -0,0 +1,42 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'bytes' +}) +export class BytesPipe implements PipeTransform { + + /** + * Format bytes as human-readable text. + * + * @param bytes Number of bytes. + * @param si True to use metric (SI) units, aka powers of 1000. False to use + * binary (IEC), aka powers of 1024. + * @param dp Number of decimal places to display. + * + * @return Formatted string. + * + * Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string + */ + transform(bytes: number, si=true, dp=0): string { + const thresh = si ? 1000 : 1024; + + if (Math.abs(bytes) < thresh) { + return bytes + ' B'; + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + let u = -1; + const r = 10**dp; + + do { + bytes /= thresh; + ++u; + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); + + + return bytes.toFixed(dp) + ' ' + units[u]; + } + +} diff --git a/UI/Web/src/app/pipe/pipe.module.ts b/UI/Web/src/app/pipe/pipe.module.ts index 979545283..527c23172 100644 --- a/UI/Web/src/app/pipe/pipe.module.ts +++ b/UI/Web/src/app/pipe/pipe.module.ts @@ -15,6 +15,7 @@ import { MangaFormatIconPipe } from './manga-format-icon.pipe'; import { LibraryTypePipe } from './library-type.pipe'; import { SafeStylePipe } from './safe-style.pipe'; import { DefaultDatePipe } from './default-date.pipe'; +import { BytesPipe } from './bytes.pipe'; @@ -35,6 +36,7 @@ import { DefaultDatePipe } from './default-date.pipe'; LibraryTypePipe, SafeStylePipe, DefaultDatePipe, + BytesPipe, ], imports: [ CommonModule, @@ -55,6 +57,7 @@ import { DefaultDatePipe } from './default-date.pipe'; LibraryTypePipe, SafeStylePipe, DefaultDatePipe, + BytesPipe ] }) export class PipeModule { } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 4be6d367b..14eca4e73 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -639,7 +639,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe return; } - this.actionService.markChapterAsRead(this.seriesId, chapter, () => { + this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => { this.setContinuePoint(); }); } @@ -649,7 +649,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe return; } - this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { + this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => { this.setContinuePoint(); }); } diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 8fbee570f..65724520b 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpErrorResponse, HttpEventType } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { Inject, Injectable } from '@angular/core'; import { Series } from 'src/app/_models/series'; import { environment } from 'src/environments/environment'; @@ -12,9 +12,12 @@ import { download, Download } from '../_models/download'; import { PageBookmark } from 'src/app/_models/readers/page-bookmark'; import { switchMap, takeWhile, throttleTime } from 'rxjs/operators'; import { AccountService } from 'src/app/_services/account.service'; +import { BytesPipe } from 'src/app/pipe/bytes.pipe'; export const DEBOUNCE_TIME = 100; +const bytesPipe = new BytesPipe(); + export interface DownloadEvent { /** * Type of entity being downloaded @@ -235,7 +238,7 @@ export class DownloadService { } private async confirmSize(size: number, entityType: DownloadEntityType) { - return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')); + return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + bytesPipe.transform(size) + '. Are you sure you want to continue?')); } private downloadBookmarks(bookmarks: PageBookmark[]) { @@ -253,38 +256,4 @@ export class DownloadService { finalize(() => this.finalizeDownloadState(downloadType, subtitle)) ); } - - /** - * Format bytes as human-readable text. - * - * @param bytes Number of bytes. - * @param si True to use metric (SI) units, aka powers of 1000. False to use - * binary (IEC), aka powers of 1024. - * @param dp Number of decimal places to display. - * - * @return Formatted string. - * - * Credit: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string - */ - private humanFileSize(bytes: number, si=true, dp=0) { - const thresh = si ? 1000 : 1024; - - if (Math.abs(bytes) < thresh) { - return bytes + ' B'; - } - - const units = si - ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; - let u = -1; - const r = 10**dp; - - do { - bytes /= thresh; - ++u; - } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1); - - - return bytes.toFixed(dp) + ' ' + units[u]; - } } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index 41bf6d91e..9536ee156 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -150,6 +150,7 @@