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 000000000..5b8eb6a3a Binary files /dev/null and b/UI/Web/libs/iharbeck-ngx-virtual-scroller-14.0.5.tgz differ diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 96d2c455d..2230390c8 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -6188,6 +6188,26 @@ "@sinonjs/commons": "^1.7.0" } }, + "@swimlane/ngx-charts": { + "version": "20.1.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.1.0.tgz", + "integrity": "sha512-PY/X+eW+ZEvF3N1kuUVV5H3NHoFXlIWOvNnCKAs874yye//ttgfL/Qf9haHQpki5WIHQtpwn8xM1ylVEQT98bg==", + "requires": { + "@types/d3-shape": "^2.0.0", + "d3-array": "^2.9.1", + "d3-brush": "^2.1.0", + "d3-color": "^2.0.0", + "d3-format": "^2.0.0", + "d3-hierarchy": "^2.0.0", + "d3-interpolate": "^2.0.1", + "d3-scale": "^3.2.3", + "d3-selection": "^2.0.0", + "d3-shape": "^2.0.0", + "d3-time-format": "^3.0.0", + "d3-transition": "^2.0.0", + "tslib": "^2.0.0" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -6298,6 +6318,257 @@ "@types/node": "*" } }, + "@types/d3": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.0.tgz", + "integrity": "sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==", + "dev": true, + "requires": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "dev": true + }, + "@types/d3-axis": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.1.tgz", + "integrity": "sha512-zji/iIbdd49g9WN0aIsGcwcTBUkgLsCSwB+uH+LPVDAiKWENMtI3cJEWt+7/YYwelMoZmbBfzA3qCdrZ2XFNnw==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-brush": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.1.tgz", + "integrity": "sha512-B532DozsiTuQMHu2YChdZU0qsFJSio3Q6jmBYGYNp3gMDzBmuFFgPt9qKA4VYuLZMp4qc6eX7IUFUEsvHiXZAw==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-eQfcxIHrg7V++W8Qxn6QkqBNBokyhdWSAS73AbkbMzvLQmVVBviknoz2SRS/ZJdIOmhcmmdCRE/NFOm28Z1AMw==", + "dev": true + }, + "@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "dev": true + }, + "@types/d3-contour": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.1.tgz", + "integrity": "sha512-C3zfBrhHZvrpAAK3YXqLWVAGo87A4SvJ83Q/zVJ8rFWJdKejUnDYaWZPkA8K84kb2vDA/g90LTQAz7etXcgoQQ==", + "dev": true, + "requires": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "dev": true + }, + "@types/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-NhxMn3bAkqhjoxabVJWKryhnZXXYYVQxaBnbANu0O94+O/nX9qSjrA1P1jbAQJxJf+VC72TxDX/YJcKue5bRqw==", + "dev": true + }, + "@types/d3-drag": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.1.tgz", + "integrity": "sha512-o1Va7bLwwk6h03+nSM8dpaGEYnoIG19P0lKqlic8Un36ymh9NSkNFX1yiXMKNMx8rJ0Kfnn2eovuFaL6Jvj0zA==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-dsv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.0.tgz", + "integrity": "sha512-o0/7RlMl9p5n6FQDptuJVMxDf/7EDEv2SYEO/CwdG2tr1hTfUVi0Iavkk2ax+VpaQ/1jVhpnj5rq1nj8vwhn2A==", + "dev": true + }, + "@types/d3-ease": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz", + "integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==", + "dev": true + }, + "@types/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-toZJNOwrOIqz7Oh6Q7l2zkaNfXkfR7mFSJvGvlD/Ciq/+SQ39d5gynHJZ/0fjt83ec3WL7+u3ssqIijQtBISsw==", + "dev": true, + "requires": { + "@types/d3-dsv": "*" + } + }, + "@types/d3-force": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.3.tgz", + "integrity": "sha512-z8GteGVfkWJMKsx6hwC3SiTSLspL98VNpmvLpEFJQpZPq6xpA1I8HNBDNSpukfK0Vb0l64zGFhzunLgEAcBWSA==", + "dev": true + }, + "@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "dev": true + }, + "@types/d3-geo": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.2.tgz", + "integrity": "sha512-DbqK7MLYA8LpyHQfv6Klz0426bQEf7bRTvhMy44sNGVyZoWn//B0c+Qbeg8Osi2Obdc9BLLXYAKpyWege2/7LQ==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/d3-hierarchy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.0.tgz", + "integrity": "sha512-g+sey7qrCa3UbsQlMZZBOHROkFqx7KZKvUpRzI/tAp/8erZWpYq7FgNKvYwebi2LaEiVs1klhUfd3WCThxmmWQ==", + "dev": true + }, + "@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "dev": true, + "requires": { + "@types/d3-color": "*" + } + }, + "@types/d3-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-2.0.2.tgz", + "integrity": "sha512-3YHpvDw9LzONaJzejXLOwZ3LqwwkoXb9LI2YN7Hbd6pkGo5nIlJ09ul4bQhBN4hQZJKmUpX8HkVqbzgUKY48cg==" + }, + "@types/d3-polygon": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz", + "integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==", + "dev": true + }, + "@types/d3-quadtree": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz", + "integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==", + "dev": true + }, + "@types/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==", + "dev": true + }, + "@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "dev": true, + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==", + "dev": true + }, + "@types/d3-selection": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.3.tgz", + "integrity": "sha512-Mw5cf6nlW1MlefpD9zrshZ+DAWL4IQ5LnWfRheW6xwsdaWOb6IRRu2H7XPAQcyXEx1D7XQWgdoKR83ui1/HlEA==", + "dev": true + }, + "@types/d3-shape": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-2.1.3.tgz", + "integrity": "sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==", + "requires": { + "@types/d3-path": "^2" + } + }, + "@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "dev": true + }, + "@types/d3-time-format": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz", + "integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==", + "dev": true + }, + "@types/d3-timer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz", + "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==", + "dev": true + }, + "@types/d3-transition": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.2.tgz", + "integrity": "sha512-jo5o/Rf+/u6uerJ/963Dc39NI16FQzqwOc54bwvksGAdVfvDrqDpVeq95bEvPtBwLCVZutAEyAtmSyEMxN7vxQ==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, + "@types/d3-zoom": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.1.tgz", + "integrity": "sha512-7s5L9TjfqIYQmQQEUcpMAcBOahem7TRoSO/+Gkz02GbMVuULiZzjF2BOdw291dbO2aNon4m2OdFsRGaCq2caLQ==", + "dev": true, + "requires": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "@types/eslint": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.1.tgz", @@ -6347,6 +6618,12 @@ "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==" }, + "@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -8197,6 +8474,131 @@ } } }, + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-brush": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-2.1.0.tgz", + "integrity": "sha512-cHLLAFatBATyIKqZOkk/mDHUbzne2B3ZwxkzMHvFTCZCmLaXDpZRihQSn8UNXTkGD/3lb/W2sQz0etAftmHMJQ==", + "requires": { + "d3-dispatch": "1 - 2", + "d3-drag": "2", + "d3-interpolate": "1 - 2", + "d3-selection": "2", + "d3-transition": "2" + } + }, + "d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==" + }, + "d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==" + }, + "d3-drag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", + "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", + "requires": { + "d3-dispatch": "1 - 2", + "d3-selection": "2" + } + }, + "d3-ease": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", + "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==" + }, + "d3-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-2.0.0.tgz", + "integrity": "sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA==" + }, + "d3-hierarchy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-2.0.0.tgz", + "integrity": "sha512-SwIdqM3HxQX2214EG9GTjgmCc/mbSx4mQBn+DuEETubhOw6/U3fmnji4uCVrmzOydMHSO1nZle5gh6HB/wdOzw==" + }, + "d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "requires": { + "d3-color": "1 - 2" + } + }, + "d3-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-2.0.0.tgz", + "integrity": "sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==" + }, + "d3-scale": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz", + "integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==", + "requires": { + "d3-array": "^2.3.0", + "d3-format": "1 - 2", + "d3-interpolate": "1.2.0 - 2", + "d3-time": "^2.1.1", + "d3-time-format": "2 - 3" + } + }, + "d3-selection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", + "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==" + }, + "d3-shape": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-2.1.0.tgz", + "integrity": "sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==", + "requires": { + "d3-path": "1 - 2" + } + }, + "d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "requires": { + "d3-array": "2" + } + }, + "d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "requires": { + "d3-time": "1 - 2" + } + }, + "d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==" + }, + "d3-transition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", + "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", + "requires": { + "d3-color": "1 - 2", + "d3-dispatch": "1 - 2", + "d3-ease": "1 - 2", + "d3-interpolate": "1 - 2", + "d3-timer": "1 - 2" + } + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -9820,6 +10222,11 @@ } } }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "ip": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", @@ -14591,9 +14998,9 @@ }, "dependencies": { "qs": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", "dev": true } } diff --git a/UI/Web/package.json b/UI/Web/package.json index 7df5cdf86..13c675ecd 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -31,6 +31,7 @@ "@microsoft/signalr": "^6.0.2", "@ng-bootstrap/ng-bootstrap": "^13.0.0", "@popperjs/core": "^2.11.2", + "@swimlane/ngx-charts": "^20.1.0", "@types/file-saver": "^2.0.5", "bootstrap": "^5.2.0", "bowser": "^2.11.0", @@ -54,6 +55,7 @@ "@angular/cli": "^14.1.1", "@angular/compiler-cli": "^14.1.1", "@playwright/test": "^1.23.2", + "@types/d3": "^7.4.0", "@types/jest": "^27.4.0", "@types/node": "^17.0.17", "codelyzer": "^6.0.2", diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 0fd52cd60..bd3e255ae 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -245,8 +245,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 */ - markChapterAsRead(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) { - this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => { + 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 @@