From 7616eb5b0fe8f13982db684e24f929bd892c1659 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 11 Feb 2023 04:01:24 -0800 Subject: [PATCH] UTC Dates + CDisplayEx API Enhancements (#1781) * Introduced a new claim on the Token to get UserId as well as Username, thus allowing for many places of reduced DB calls. All users will need to reauthenticate. Introduced UTC Dates throughout the application, they are not exposed in all DTOs, that will come later when we fully switch over. For now, Utc dates will be updated along side timezone specific dates. Refactored get-progress/progress api to be 50% faster by reducing how much data is loaded from the query. * Speed up the following apis: collection/search, download/bookmarks, reader/bookmark-info, recommended/quick-reads, recommended/quick-catchup-reads, recommended/highly-rated, recommended/more-in, recommended/rediscover, want-to-read/ * Added a migration to sync all dates with their new UTC counterpart. * Added LastReadingProgressUtc onto ChapterDto for some browsing apis, but not all. Added LastReadingProgressUtc to reading list items. Refactored the migration to run raw SQL which is much faster. * Added LastReadingProgressUtc onto ChapterDto for some browsing apis, but not all. Added LastReadingProgressUtc to reading list items. Refactored the migration to run raw SQL which is much faster. * Fixed the unit tests * Fixed an issue with auto mapper which was causing progress page number to not get sent to UI * series/volume has chapter last reading progress * Added filesize and library name on reading list item dto for CDisplayEx. * Some minor code cleanup * Forgot to fill a field --- API.Tests/Services/CleanupServiceTests.cs | 3 +- API/Controllers/AccountController.cs | 7 +- API/Controllers/CollectionController.cs | 3 +- API/Controllers/DownloadController.cs | 9 +- API/Controllers/ReaderController.cs | 27 +- API/Controllers/ReadingListController.cs | 3 +- API/Controllers/RecommendedController.cs | 25 +- API/Controllers/SeriesController.cs | 6 +- API/Controllers/TachiyomiController.cs | 3 +- API/Controllers/UploadController.cs | 2 +- API/Controllers/WantToReadController.cs | 6 +- API/DTOs/ChapterDto.cs | 14 +- API/DTOs/Device/DeviceDto.cs | 4 + API/DTOs/Jobs/JobDto.cs | 8 + API/DTOs/ProgressDto.cs | 6 +- API/DTOs/ReadingLists/ReadingListItemDto.cs | 10 + API/DTOs/Theme/SiteThemeDto.cs | 5 +- API/Data/DataContext.cs | 15 +- API/Data/DbFactory.cs | 3 +- API/Data/MigrateToUtcDates.cs | 153 ++ .../20230210153842_UtcTimes.Designer.cs | 1836 +++++++++++++++++ .../Migrations/20230210153842_UtcTimes.cs | 323 +++ .../Migrations/DataContextModelSnapshot.cs | 88 + .../Repositories/AppUserProgressRepository.cs | 16 +- API/Data/Repositories/ChapterRepository.cs | 50 +- .../Repositories/ReadingListRepository.cs | 17 +- API/Data/Repositories/VolumeRepository.cs | 5 +- API/Data/UnitOfWork.cs | 2 +- API/Entities/AppUser.cs | 8 + API/Entities/AppUserBookmark.cs | 2 + API/Entities/AppUserProgress.cs | 3 + API/Entities/Chapter.cs | 4 + API/Entities/Device.cs | 9 + API/Entities/FolderPath.cs | 12 + API/Entities/Interfaces/IEntityDate.cs | 2 + API/Entities/Library.cs | 20 + API/Entities/MangaFile.cs | 12 + API/Entities/ReadingList.cs | 2 + API/Entities/ReadingListItem.cs | 1 - API/Entities/Series.cs | 21 + API/Entities/SiteTheme.cs | 2 + API/Entities/Volume.cs | 3 + API/Extensions/ClaimsPrincipalExtensions.cs | 10 +- API/Extensions/QueryableExtensions.cs | 7 + API/Helpers/AutoMapperProfiles.cs | 5 + API/Services/ArchiveService.cs | 2 +- API/Services/DeviceService.cs | 2 +- API/Services/ReaderService.cs | 1 + API/Services/SeriesService.cs | 2 +- API/Services/Tasks/BackupService.cs | 2 +- .../Metadata/WordCountAnalyzerService.cs | 2 +- API/Services/Tasks/Scanner/ProcessSeries.cs | 4 +- API/Services/Tasks/ScannerService.cs | 6 +- API/Services/TokenService.cs | 9 +- API/SignalR/Presence/PresenceTracker.cs | 2 +- API/Startup.cs | 1 + UI/Web/src/app/_models/reading-list.ts | 1 + openapi.json | 328 ++- 58 files changed, 3003 insertions(+), 131 deletions(-) create mode 100644 API/Data/MigrateToUtcDates.cs create mode 100644 API/Data/Migrations/20230210153842_UtcTimes.Designer.cs create mode 100644 API/Data/Migrations/20230210153842_UtcTimes.cs diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 77fa9ac1f..84e4d5fd5 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -448,8 +448,9 @@ public class CleanupServiceTests : AbstractDbTest // Delete the Chapter _context.Chapter.Remove(c); await _unitOfWork.CommitAsync(); - Assert.Single(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); + // NOTE: This may not be needed, the underlying DB structure seems fixed as of v0.7 await cleanupService.CleanupDbEntries(); Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1)); diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index a9229fa7c..9f05add96 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -41,7 +41,6 @@ public class AccountController : BaseApiController private readonly IMapper _mapper; private readonly IAccountService _accountService; private readonly IEmailService _emailService; - private readonly IHostEnvironment _environment; private readonly IEventHub _eventHub; /// @@ -50,8 +49,7 @@ public class AccountController : BaseApiController ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, IAccountService accountService, - IEmailService emailService, IHostEnvironment environment, - IEventHub eventHub) + IEmailService emailService, IEventHub eventHub) { _userManager = userManager; _signInManager = signInManager; @@ -61,7 +59,6 @@ public class AccountController : BaseApiController _mapper = mapper; _accountService = accountService; _emailService = emailService; - _environment = environment; _eventHub = eventHub; } @@ -202,7 +199,7 @@ public class AccountController : BaseApiController } // Update LastActive on account - user.LastActive = DateTime.Now; + user.UpdateLastActive(); user.UserPreferences ??= new AppUserPreferences { Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index cd9402fe7..0800f3977 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -61,8 +61,7 @@ public class CollectionController : BaseApiController queryString = queryString.Replace(@"%", string.Empty); if (queryString.Length == 0) return await GetAllTags(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, user.Id); + return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId()); } /// diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 98fa072e9..6c44c2659 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -201,19 +201,20 @@ public class DownloadController : BaseApiController if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest("Bookmarks cannot be empty"); // We know that all bookmarks will be for one single seriesId - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = User.GetUserId(); + var username = User.GetUsername(); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId); var files = await _bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id)); var filename = $"{series.Name} - Bookmarks.zip"; await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F)); + MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 0F)); var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct()); var filePath = _archiveService.CreateZipForDownload(files, - $"download_{user.Id}_{seriesIds}_bookmarks"); + $"download_{userId}_{seriesIds}_bookmarks"); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F)); + MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 1F)); return PhysicalFile(filePath, DefaultContentType, filename, true); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 800743766..79948ce60 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -244,8 +244,7 @@ public class ReaderController : BaseApiController [HttpGet("bookmark-info")] public async Task> GetBookmarkInfo(int seriesId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var totalPages = await _cacheService.CacheBookmarkForSeries(user.Id, seriesId); + var totalPages = await _cacheService.CacheBookmarkForSeries(User.GetUserId(), seriesId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); return Ok(new BookmarkInfoDto() @@ -451,25 +450,15 @@ public class ReaderController : BaseApiController [HttpGet("get-progress")] public async Task> GetProgress(int chapterId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var progressBookmark = new ProgressDto() + var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, User.GetUserId()); + if (progress == null) return Ok(new ProgressDto() { PageNum = 0, ChapterId = chapterId, VolumeId = 0, SeriesId = 0 - }; - if (user.Progresses == null) return Ok(progressBookmark); - var progress = user.Progresses.FirstOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId); - - if (progress != null) - { - progressBookmark.SeriesId = progress.SeriesId; - progressBookmark.VolumeId = progress.VolumeId; - progressBookmark.PageNum = progress.PagesRead; - progressBookmark.BookScrollId = progress.BookScrollId; - } - return Ok(progressBookmark); + }); + return Ok(progress); } /// @@ -480,9 +469,7 @@ public class ReaderController : BaseApiController [HttpPost("progress")] public async Task BookmarkProgress(ProgressDto progressDto) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - - if (await _readerService.SaveReadingProgress(progressDto, user.Id)) return Ok(true); + if (await _readerService.SaveReadingProgress(progressDto, User.GetUserId())) return Ok(true); return BadRequest("Could not save progress"); } @@ -506,7 +493,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("has-progress")] - public async Task> HasProgress(int seriesId) + public async Task> HasProgress(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId)); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 72678780c..1ecebf23c 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -82,8 +82,7 @@ public class ReadingListController : BaseApiController [HttpGet("items")] public async Task>> GetListForUser(int readingListId) { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, User.GetUserId()); return Ok(items); } diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs index 893cb852a..f945c735d 100644 --- a/API/Controllers/RecommendedController.cs +++ b/API/Controllers/RecommendedController.cs @@ -26,10 +26,8 @@ public class RecommendedController : BaseApiController [HttpGet("quick-reads")] public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - userParams ??= new UserParams(); - var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams); + var series = await _unitOfWork.SeriesRepository.GetQuickReads(User.GetUserId(), libraryId, userParams); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); @@ -44,10 +42,8 @@ public class RecommendedController : BaseApiController [HttpGet("quick-catchup-reads")] public async Task>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - userParams ??= new UserParams(); - var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(user.Id, libraryId, userParams); + var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(User.GetUserId(), libraryId, userParams); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); @@ -62,11 +58,10 @@ public class RecommendedController : BaseApiController [HttpGet("highly-rated")] public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - + var userId = User.GetUserId(); userParams ??= new UserParams(); - var series = await _unitOfWork.SeriesRepository.GetHighlyRated(user.Id, libraryId, userParams); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + var series = await _unitOfWork.SeriesRepository.GetHighlyRated(userId, libraryId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); } @@ -81,11 +76,11 @@ public class RecommendedController : BaseApiController [HttpGet("more-in")] public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userId = User.GetUserId(); userParams ??= new UserParams(); - var series = await _unitOfWork.SeriesRepository.GetMoreIn(user.Id, libraryId, genreId, userParams); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series); + var series = await _unitOfWork.SeriesRepository.GetMoreIn(userId, libraryId, genreId, userParams); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); @@ -100,10 +95,8 @@ public class RecommendedController : BaseApiController [HttpGet("rediscover")] public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams userParams) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - userParams ??= new UserParams(); - var series = await _unitOfWork.SeriesRepository.GetRediscover(user.Id, libraryId, userParams); + var series = await _unitOfWork.SeriesRepository.GetRediscover(User.GetUserId(), libraryId, userParams); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); return Ok(series); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index c93e93fe9..c0059e0e5 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -7,6 +7,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.Metadata; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; @@ -121,11 +122,12 @@ public class SeriesController : BaseApiController [HttpGet("chapter")] public async Task> GetChapter(int chapterId) { - return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId)); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); + return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter)); } [HttpGet("chapter-metadata")] - public async Task> GetChapterMetadata(int chapterId) + public async Task> GetChapterMetadata(int chapterId) { return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); } diff --git a/API/Controllers/TachiyomiController.cs b/API/Controllers/TachiyomiController.cs index 77f32764d..84bde35d1 100644 --- a/API/Controllers/TachiyomiController.cs +++ b/API/Controllers/TachiyomiController.cs @@ -32,8 +32,7 @@ public class TachiyomiController : BaseApiController public async Task> GetLatestChapter(int seriesId) { if (seriesId < 1) return BadRequest("seriesId must be greater than 0"); - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _tachiyomiService.GetLatestChapter(seriesId, userId)); + return Ok(await _tachiyomiService.GetLatestChapter(seriesId, User.GetUserId())); } /// diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 3a52866e0..434117567 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -49,7 +49,7 @@ public class UploadController : BaseApiController [HttpPost("upload-by-url")] public async Task> GetImageFromFile(UploadUrlDto dto) { - var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace('/', '_').Replace(':', '_'); + var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace('/', '_').Replace(':', '_'); var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty); try { diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 12bbeaa85..424681cf4 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -35,8 +35,7 @@ public class WantToReadController : BaseApiController public async Task>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto) { userParams ??= new UserParams(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, userParams, filterDto); + var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(User.GetUserId(), userParams, filterDto); Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); return Ok(pagedList); } @@ -44,8 +43,7 @@ public class WantToReadController : BaseApiController [HttpGet] public async Task> IsSeriesInWantToRead([FromQuery] int seriesId) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(user.Id, seriesId)); + return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(User.GetUserId(), seriesId)); } /// diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 60e08b554..b93bd124a 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using API.DTOs.Metadata; -using API.DTOs.Reader; using API.Entities.Enums; using API.Entities.Interfaces; @@ -11,7 +9,7 @@ namespace API.DTOs; /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying /// file (abstracted from type). /// -public class ChapterDto : IHasReadTimeEstimate +public class ChapterDto : IHasReadTimeEstimate, IEntityDate { public int Id { get; init; } /// @@ -43,6 +41,10 @@ public class ChapterDto : IHasReadTimeEstimate /// public int PagesRead { get; set; } /// + /// The last time a chapter was read by current authenticated user + /// + public DateTime LastReadingProgressUtc { get; set; } + /// /// If the Cover Image is locked for this entity /// public bool CoverImageLocked { get; set; } @@ -53,7 +55,10 @@ public class ChapterDto : IHasReadTimeEstimate /// /// When chapter was created /// - public DateTime Created { get; init; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } /// /// When the chapter was released. /// @@ -77,7 +82,6 @@ public class ChapterDto : IHasReadTimeEstimate /// Total words in a Chapter (books only) /// public long WordCount { get; set; } = 0L; - /// /// Formatted Volume title ie) Volume 2. /// diff --git a/API/DTOs/Device/DeviceDto.cs b/API/DTOs/Device/DeviceDto.cs index e5344f31e..c05671113 100644 --- a/API/DTOs/Device/DeviceDto.cs +++ b/API/DTOs/Device/DeviceDto.cs @@ -30,4 +30,8 @@ public class DeviceDto /// Last time this device was used to send a file /// public DateTime LastUsed { get; set; } + /// + /// Last time this device was used to send a file + /// + public DateTime LastUsedUtc { get; set; } } diff --git a/API/DTOs/Jobs/JobDto.cs b/API/DTOs/Jobs/JobDto.cs index 5af700528..dc566961e 100644 --- a/API/DTOs/Jobs/JobDto.cs +++ b/API/DTOs/Jobs/JobDto.cs @@ -20,5 +20,13 @@ public class JobDto /// Last time the job was run /// public DateTime? LastExecution { get; set; } + /// + /// When the job was created + /// + public DateTime? CreatedAtUtc { get; set; } + /// + /// Last time the job was run + /// + public DateTime? LastExecutionUtc { get; set; } public string Cron { get; set; } } diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs index b3ffd2914..e5f796bcc 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/ProgressDto.cs @@ -16,12 +16,8 @@ public class ProgressDto [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 + /// For EPUB 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". /// public string BookScrollId { get; set; } - /// - /// Last time the progress was synced from UI or external app - /// - public DateTime LastModified { get; set; } } diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 91c436264..79fdd9d8f 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -19,6 +19,7 @@ public class ReadingListItemDto public int VolumeId { get; set; } public int LibraryId { get; set; } public LibraryType LibraryType { get; set; } + public string LibraryName { get; set; } public string Title { get; set; } /// /// Release Date from Chapter @@ -28,4 +29,13 @@ public class ReadingListItemDto /// Used internally only /// public int ReadingListId { get; set; } + /// + /// The last time a reading list item (underlying chapter) was read by current authenticated user + /// + public DateTime LastReadingProgressUtc { get; set; } + /// + /// File size of underlying item + /// + /// This is only used for CDisplayEx + public long FileSize { get; set; } } diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index 7c44a1cd0..6e3650e21 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -1,5 +1,6 @@ using System; using API.Entities.Enums.Theme; +using API.Entities.Interfaces; using API.Services; namespace API.DTOs.Theme; @@ -7,7 +8,7 @@ namespace API.DTOs.Theme; /// /// Represents a set of css overrides the user can upload to Kavita and will load into webui /// -public class SiteThemeDto +public class SiteThemeDto : IEntityDate { public int Id { get; set; } /// @@ -29,5 +30,7 @@ public class SiteThemeDto public ThemeProvider Provider { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } public string Selector => "bg-" + Name.ToLower(); } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 1606f9c71..9a6af8b3e 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -112,18 +112,19 @@ public sealed class DataContext : IdentityDbContext +/// Introduced in v0.6.1.38 or v0.7.0, +/// +public static class MigrateToUtcDates +{ + public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger) + { + // if current version is > 0.6.1.38, then we can exit and not perform + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (Version.Parse(settings.InstallVersion) > new Version(0, 6, 1, 38)) + { + return; + } + logger.LogCritical("Running MigrateToUtcDates migration. Please be patient, this may take some time depending on the size of your library. Do not abort, this can break your Database"); + + #region Series + logger.LogInformation("Updating Dates on Series..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE Series SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc'), + [LastChapterAddedUtc] = datetime([LastChapterAdded], 'utc'), + [LastFolderScannedUtc] = datetime([LastFolderScanned], 'utc') + ; + "); + logger.LogInformation("Updating Dates on Series...Done"); + #endregion + + #region Library + logger.LogInformation("Updating Dates on Libraries..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE Library SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc') + ; + "); + logger.LogInformation("Updating Dates on Libraries...Done"); + #endregion + + #region Volume + try + { + logger.LogInformation("Updating Dates on Volumes..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE Volume SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc'); + "); + logger.LogInformation("Updating Dates on Volumes...Done"); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Updating Dates on Volumes...Failed"); + } + #endregion + + #region Chapter + try + { + logger.LogInformation("Updating Dates on Chapters..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE Chapter SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc') + ; + "); + logger.LogInformation("Updating Dates on Chapters...Done"); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Updating Dates on Chapters...Failed"); + } + #endregion + + #region AppUserBookmark + logger.LogInformation("Updating Dates on Bookmarks..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE AppUserBookmark SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc') + ; + "); + logger.LogInformation("Updating Dates on Bookmarks...Done"); + #endregion + + #region AppUserProgress + logger.LogInformation("Updating Dates on Progress..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE AppUserProgresses SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc') + ; + "); + logger.LogInformation("Updating Dates on Progress...Done"); + #endregion + + #region Device + logger.LogInformation("Updating Dates on Device..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE Device SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc'), + [LastUsedUtc] = datetime([LastUsed], 'utc') + ; + "); + logger.LogInformation("Updating Dates on Device...Done"); + #endregion + + #region MangaFile + logger.LogInformation("Updating Dates on MangaFile..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE MangaFile SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc'), + [LastFileAnalysisUtc] = datetime([LastFileAnalysis], 'utc') + ; + "); + logger.LogInformation("Updating Dates on MangaFile...Done"); + #endregion + + #region ReadingList + logger.LogInformation("Updating Dates on ReadingList..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE ReadingList SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc') + ; + "); + logger.LogInformation("Updating Dates on ReadingList...Done"); + #endregion + + #region SiteTheme + logger.LogInformation("Updating Dates on SiteTheme..."); + await dataContext.Database.ExecuteSqlRawAsync(@" + UPDATE SiteTheme SET + [LastModifiedUtc] = datetime([LastModified], 'utc'), + [CreatedUtc] = datetime([Created], 'utc') + ; + "); + logger.LogInformation("Updating Dates on SiteTheme...Done"); + #endregion + + logger.LogInformation("MigrateToUtcDates migration finished"); + + } +} diff --git a/API/Data/Migrations/20230210153842_UtcTimes.Designer.cs b/API/Data/Migrations/20230210153842_UtcTimes.Designer.cs new file mode 100644 index 000000000..ff9394649 --- /dev/null +++ b/API/Data/Migrations/20230210153842_UtcTimes.Designer.cs @@ -0,0 +1,1836 @@ +// +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("20230210153842_UtcTimes")] + partial class UtcTimes + { + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("EmulateBook") + .HasColumnType("INTEGER"); + + 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("SwipeToPaginate") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("ChapterId"); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("SeriesGroup") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .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("CreatedUtc") + .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("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .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"); + + b.Navigation("UserProgress"); + }); + + 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/20230210153842_UtcTimes.cs b/API/Data/Migrations/20230210153842_UtcTimes.cs new file mode 100644 index 000000000..9354cded7 --- /dev/null +++ b/API/Data/Migrations/20230210153842_UtcTimes.cs @@ -0,0 +1,323 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class UtcTimes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "Volume", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "Volume", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "SiteTheme", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "SiteTheme", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastChapterAddedUtc", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastFolderScannedUtc", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "Series", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "ReadingList", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "ReadingList", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "MangaFile", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastFileAnalysisUtc", + table: "MangaFile", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "MangaFile", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "Library", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "Library", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "Device", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "Device", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastUsedUtc", + table: "Device", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "Chapter", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "Chapter", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "AspNetUsers", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastActiveUtc", + table: "AspNetUsers", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "AppUserProgresses", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "AppUserProgresses", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "CreatedUtc", + table: "AppUserBookmark", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "LastModifiedUtc", + table: "AppUserBookmark", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.CreateIndex( + name: "IX_AppUserProgresses_ChapterId", + table: "AppUserProgresses", + column: "ChapterId"); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserProgresses_Chapter_ChapterId", + table: "AppUserProgresses", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserProgresses_Chapter_ChapterId", + table: "AppUserProgresses"); + + migrationBuilder.DropIndex( + name: "IX_AppUserProgresses_ChapterId", + table: "AppUserProgresses"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "Series"); + + migrationBuilder.DropColumn( + name: "LastChapterAddedUtc", + table: "Series"); + + migrationBuilder.DropColumn( + name: "LastFolderScannedUtc", + table: "Series"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "Series"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "LastFileAnalysisUtc", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "Library"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "Library"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "Device"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "Device"); + + migrationBuilder.DropColumn( + name: "LastUsedUtc", + table: "Device"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "LastActiveUtc", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "AppUserProgresses"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "AppUserProgresses"); + + migrationBuilder.DropColumn( + name: "CreatedUtc", + table: "AppUserBookmark"); + + migrationBuilder.DropColumn( + name: "LastModifiedUtc", + table: "AppUserBookmark"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 13a553b30..56a8cacbd 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -72,6 +72,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("TEXT"); @@ -82,6 +85,9 @@ namespace API.Data.Migrations b.Property("LastActive") .HasColumnType("TEXT"); + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + b.Property("LockoutEnabled") .HasColumnType("INTEGER"); @@ -146,12 +152,18 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("FileName") .HasColumnType("TEXT"); b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("Page") .HasColumnType("INTEGER"); @@ -283,9 +295,15 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("LibraryId") .HasColumnType("INTEGER"); @@ -302,6 +320,8 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); + b.HasIndex("ChapterId"); + b.HasIndex("SeriesId"); b.ToTable("AppUserProgresses"); @@ -373,6 +393,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("IsSpecial") .HasColumnType("INTEGER"); @@ -382,6 +405,9 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("MaxHoursToRead") .HasColumnType("INTEGER"); @@ -475,6 +501,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("EmailAddress") .HasColumnType("TEXT"); @@ -484,9 +513,15 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("LastUsed") .HasColumnType("TEXT"); + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + b.Property("Name") .HasColumnType("TEXT"); @@ -554,6 +589,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("FolderWatching") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -577,6 +615,9 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("LastScanned") .HasColumnType("TEXT"); @@ -611,6 +652,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("Extension") .HasColumnType("TEXT"); @@ -623,9 +667,15 @@ namespace API.Data.Migrations b.Property("LastFileAnalysis") .HasColumnType("TEXT"); + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("Pages") .HasColumnType("INTEGER"); @@ -797,9 +847,15 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("NormalizedTitle") .HasColumnType("TEXT"); @@ -874,6 +930,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("FolderPath") .HasColumnType("TEXT"); @@ -883,12 +942,21 @@ namespace API.Data.Migrations b.Property("LastChapterAdded") .HasColumnType("TEXT"); + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + b.Property("LastFolderScanned") .HasColumnType("TEXT"); + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("LibraryId") .HasColumnType("INTEGER"); @@ -1004,6 +1072,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("FileName") .HasColumnType("TEXT"); @@ -1013,6 +1084,9 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("Name") .HasColumnType("TEXT"); @@ -1062,9 +1136,15 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + b.Property("MaxHoursToRead") .HasColumnType("INTEGER"); @@ -1333,6 +1413,12 @@ namespace API.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.HasOne("API.Entities.Series", null) .WithMany("Progress") .HasForeignKey("SeriesId") @@ -1707,6 +1793,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Chapter", b => { b.Navigation("Files"); + + b.Navigation("UserProgress"); }); modelBuilder.Entity("API.Entities.Library", b => diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 172ba8648..af442081e 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -1,8 +1,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs; using API.Entities; using API.Entities.Enums; +using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; @@ -21,15 +24,18 @@ public interface IAppUserProgressRepository Task GetAnyProgress(); Task> GetUserProgressForSeriesAsync(int seriesId, int userId); Task> GetAllProgress(); + Task GetUserProgressDtoAsync(int chapterId, int userId); } public class AppUserProgressRepository : IAppUserProgressRepository { private readonly DataContext _context; + private readonly IMapper _mapper; - public AppUserProgressRepository(DataContext context) + public AppUserProgressRepository(DataContext context, IMapper mapper) { _context = context; + _mapper = mapper; } public void Update(AppUserProgress userProgress) @@ -114,6 +120,14 @@ public class AppUserProgressRepository : IAppUserProgressRepository return await _context.AppUserProgresses.ToListAsync(); } + public async Task GetUserProgressDtoAsync(int chapterId, int userId) + { + return await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && p.ChapterId == chapterId) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + 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 1c5d9099f..82944fe77 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -18,7 +18,7 @@ public enum ChapterIncludes { None = 1, Volumes = 2, - Files = 4 + Files = 4, } public interface IChapterRepository @@ -28,8 +28,8 @@ public interface IChapterRepository Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); Task GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); - Task GetChapterDtoAsync(int chapterId); - Task GetChapterMetadataDtoAsync(int chapterId); + Task GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); + Task GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task> GetFilesForChapterAsync(int chapterId); Task> GetChaptersAsync(int volumeId); Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); @@ -37,6 +37,7 @@ public interface IChapterRepository Task> GetAllCoverImagesAsync(); Task> GetAllChaptersWithNonWebPCovers(); Task> GetCoverImagesForLockedChaptersAsync(); + Task AddChapterModifiers(int userId, ChapterDto chapter); } public class ChapterRepository : IChapterRepository { @@ -121,24 +122,24 @@ public class ChapterRepository : IChapterRepository return _context.Chapter .Where(c => c.Id == chapterId) .Select(c => c.Pages) - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); } - public async Task GetChapterDtoAsync(int chapterId) + public async Task GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files) { var chapter = await _context.Chapter - .Include(c => c.Files) + .Includes(includes) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() .AsSplitQuery() - .SingleOrDefaultAsync(c => c.Id == chapterId); + .FirstOrDefaultAsync(c => c.Id == chapterId); return chapter; } - public async Task GetChapterMetadataDtoAsync(int chapterId) + public async Task GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files) { var chapter = await _context.Chapter - .Include(c => c.Files) + .Includes(includes) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() .AsSplitQuery() @@ -168,14 +169,9 @@ public class ChapterRepository : IChapterRepository /// public async Task GetChapterAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files) { - var query = _context.Chapter - .AsSplitQuery(); - - if (includes.HasFlag(ChapterIncludes.Files)) query = query.Include(c => c.Files); - if (includes.HasFlag(ChapterIncludes.Volumes)) query = query.Include(c => c.Volume); - - return await query - .SingleOrDefaultAsync(c => c.Id == chapterId); + return await _context.Chapter + .Includes(includes) + .FirstOrDefaultAsync(c => c.Id == chapterId); } /// @@ -247,4 +243,24 @@ public class ChapterRepository : IChapterRepository .AsNoTracking() .ToListAsync(); } + + public async Task AddChapterModifiers(int userId, ChapterDto chapter) + { + var progress = await _context.AppUserProgresses.Where(x => + x.AppUserId == userId && x.ChapterId == chapter.Id) + .AsNoTracking() + .FirstOrDefaultAsync(); + if (progress != null) + { + chapter.PagesRead = progress.PagesRead ; + chapter.LastReadingProgressUtc = progress.LastModifiedUtc; + } + else + { + chapter.PagesRead = 0; + chapter.LastReadingProgressUtc = DateTime.MinValue; + } + + return chapter; + } } diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index bae5d9591..5d9f041e2 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -149,6 +149,7 @@ public class ReadingListRepository : IReadingListRepository chapter.ReleaseDate, ReadingListItem = data, ChapterTitleName = chapter.TitleName, + FileSize = chapter.Files.Sum(f => f.Bytes) }) .Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new @@ -158,6 +159,7 @@ public class ReadingListRepository : IReadingListRepository data.ChapterNumber, data.ReleaseDate, data.ChapterTitleName, + data.FileSize, VolumeId = volume.Id, VolumeNumber = volume.Name, }) @@ -174,6 +176,8 @@ public class ReadingListRepository : IReadingListRepository data.VolumeId, data.ReleaseDate, data.ChapterTitleName, + data.FileSize, + LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(), LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single() }) .Select(data => new ReadingListItemDto() @@ -192,7 +196,9 @@ public class ReadingListRepository : IReadingListRepository ReadingListId = data.ReadingListItem.ReadingListId, ReleaseDate = data.ReleaseDate, LibraryType = data.LibraryType, - ChapterTitleName = data.ChapterTitleName + ChapterTitleName = data.ChapterTitleName, + LibraryName = data.LibraryName, + FileSize = data.FileSize }) .Where(o => userLibraries.Contains(o.LibraryId)) .OrderBy(rli => rli.Order) @@ -218,6 +224,7 @@ public class ReadingListRepository : IReadingListRepository if (progressItem == null) continue; progressItem.PagesRead = progress.PagesRead; + progressItem.LastReadingProgressUtc = progress.LastModifiedUtc; } return items; @@ -233,7 +240,7 @@ public class ReadingListRepository : IReadingListRepository public async Task> AddReadingProgressModifiers(int userId, IList items) { - var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList(); + var chapterIds = items.Select(i => i.ChapterId).Distinct(); var userProgress = await _context.AppUserProgresses .Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId)) .AsNoTracking() @@ -241,8 +248,10 @@ public class ReadingListRepository : IReadingListRepository foreach (var item in items) { - var progress = userProgress.Where(p => p.ChapterId == item.ChapterId); - item.PagesRead = progress.Sum(p => p.PagesRead); + var progress = userProgress.Where(p => p.ChapterId == item.ChapterId).ToList(); + if (progress.Count == 0) continue; + item.PagesRead = progress.Sum(p => p.PagesRead); + item.LastReadingProgressUtc = progress.Max(p => p.LastModifiedUtc); } return items; diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 04a91c95c..c46110dd4 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -218,7 +218,10 @@ public class VolumeRepository : IVolumeRepository { foreach (var c in v.Chapters) { - c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); + var progresses = userProgress.Where(p => p.ChapterId == c.Id).ToList(); + if (progresses.Count == 0) continue; + c.PagesRead = progresses.Sum(p => p.PagesRead); + c.LastReadingProgressUtc = progresses.Max(p => p.LastModifiedUtc); } v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 50aadf421..a62ac4552 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -51,7 +51,7 @@ public class UnitOfWork : IUnitOfWork public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); - public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); + public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context, _mapper); public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper); public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 8a603ba57..37db2687a 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -11,7 +11,9 @@ namespace API.Entities; public class AppUser : IdentityUser, IHasConcurrencyToken { public DateTime Created { get; set; } = DateTime.Now; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime LastActive { get; set; } + public DateTime LastActiveUtc { get; set; } public ICollection Libraries { get; set; } public ICollection UserRoles { get; set; } public ICollection Progresses { get; set; } @@ -60,4 +62,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken RowVersion++; } + public void UpdateLastActive() + { + LastActive = DateTime.Now; + LastActiveUtc = DateTime.UtcNow; + } + } diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index faaf431b3..f0c8dfaea 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -27,4 +27,6 @@ public class AppUserBookmark : IEntityDate public int AppUserId { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } } diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 4486d73af..3d7ca6ee4 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -47,6 +47,9 @@ public class AppUserProgress : IEntityDate /// public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + // Relationships /// /// Navigational Property for EF. Links to a unique AppUser diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 311f769dc..7e0821bf4 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -24,6 +24,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate public ICollection Files { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + /// /// Relative path to the (managed) image file representing the cover image /// @@ -101,6 +104,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate public ICollection Genres { get; set; } = new List(); public ICollection Tags { get; set; } = new List(); + public ICollection UserProgress { get; set; } diff --git a/API/Entities/Device.cs b/API/Entities/Device.cs index e4ceabff5..4e7ca32dd 100644 --- a/API/Entities/Device.cs +++ b/API/Entities/Device.cs @@ -41,6 +41,15 @@ public class Device : IEntityDate /// Last time this device was used to send a file /// public DateTime LastUsed { get; set; } + public DateTime LastUsedUtc { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + + public void UpdateLastUsed() + { + LastUsed = DateTime.Now; + LastUsedUtc = DateTime.UtcNow; + } } diff --git a/API/Entities/FolderPath.cs b/API/Entities/FolderPath.cs index fe0e73493..98b6e503e 100644 --- a/API/Entities/FolderPath.cs +++ b/API/Entities/FolderPath.cs @@ -16,4 +16,16 @@ public class FolderPath // Relationship public Library Library { get; set; } public int LibraryId { get; set; } + + public void UpdateLastScanned(DateTime? time) + { + if (time == null) + { + LastScanned = DateTime.Now; + } + else + { + LastScanned = (DateTime) time; + } + } } diff --git a/API/Entities/Interfaces/IEntityDate.cs b/API/Entities/Interfaces/IEntityDate.cs index 11b4e8969..fa08ffdda 100644 --- a/API/Entities/Interfaces/IEntityDate.cs +++ b/API/Entities/Interfaces/IEntityDate.cs @@ -6,4 +6,6 @@ public interface IEntityDate { DateTime Created { get; set; } DateTime LastModified { get; set; } + DateTime CreatedUtc { get; set; } + DateTime LastModifiedUtc { get; set; } } diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index 18eb69b0f..4a3369c6d 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -33,6 +33,9 @@ public class Library : IEntityDate public bool ManageCollections { get; set; } = true; public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + /// /// Last time Library was scanned /// @@ -42,4 +45,21 @@ public class Library : IEntityDate public ICollection AppUsers { get; set; } public ICollection Series { get; set; } + public void UpdateLastModified() + { + LastModified = DateTime.Now; + LastModifiedUtc = DateTime.UtcNow; + } + + public void UpdateLastScanned(DateTime? time) + { + if (time == null) + { + LastScanned = DateTime.Now; + } + else + { + LastScanned = (DateTime) time; + } + } } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 9377a86a7..888548863 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -36,10 +36,15 @@ public class MangaFile : IEntityDate /// /// This gets updated anytime the file is scanned public DateTime LastModified { get; set; } + + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + /// /// Last time file analysis ran on this file /// public DateTime LastFileAnalysis { get; set; } + public DateTime LastFileAnalysisUtc { get; set; } // Relationship Mapping @@ -53,5 +58,12 @@ public class MangaFile : IEntityDate public void UpdateLastModified() { LastModified = File.GetLastWriteTime(FilePath); + LastModifiedUtc = File.GetLastWriteTimeUtc(FilePath); + } + + public void UpdateLastFileAnalysis() + { + LastFileAnalysis = DateTime.Now; + LastFileAnalysisUtc = DateTime.UtcNow; } } diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index 6712fe923..0d710728f 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -37,6 +37,8 @@ public class ReadingList : IEntityDate public ICollection Items { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } // Relationships public int AppUserId { get; set; } diff --git a/API/Entities/ReadingListItem.cs b/API/Entities/ReadingListItem.cs index a68042d3d..bba133df7 100644 --- a/API/Entities/ReadingListItem.cs +++ b/API/Entities/ReadingListItem.cs @@ -19,5 +19,4 @@ public class ReadingListItem public Series Series { get; set; } public Volume Volume { get; set; } public Chapter Chapter { get; set; } - } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 7fa02f67b..91c469fb8 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -41,6 +41,10 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc /// public DateTime LastModified { get; set; } + + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + /// /// Absolute path to the (managed) image file /// @@ -64,6 +68,10 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// public DateTime LastFolderScanned { get; set; } /// + /// Last time the folder was scanned in Utc + /// + public DateTime LastFolderScannedUtc { get; set; } + /// /// The type of all the files attached to this series /// public MangaFormat Format { get; set; } = MangaFormat.Unknown; @@ -76,6 +84,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// When a Chapter was last added onto the Series /// public DateTime LastChapterAdded { get; set; } + public DateTime LastChapterAddedUtc { get; set; } /// /// Total Word count of all chapters in this chapter. @@ -104,4 +113,16 @@ public class Series : IEntityDate, IHasReadTimeEstimate public List Volumes { get; set; } public Library Library { get; set; } public int LibraryId { get; set; } + + public void UpdateLastFolderScanned() + { + LastFolderScanned = DateTime.Now; + LastFolderScannedUtc = DateTime.UtcNow; + } + + public void UpdateLastChapterAdded() + { + LastChapterAdded = DateTime.Now; + LastChapterAddedUtc = DateTime.UtcNow; + } } diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs index a4847a7d6..79424014f 100644 --- a/API/Entities/SiteTheme.cs +++ b/API/Entities/SiteTheme.cs @@ -35,4 +35,6 @@ public class SiteTheme : IEntityDate, ITheme public ThemeProvider Provider { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 2caddbb73..36747f1d7 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -19,6 +19,9 @@ public class Volume : IEntityDate, IHasReadTimeEstimate public IList Chapters { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + /// /// Absolute path to the (managed) image file /// diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index f351aea42..07d94b23f 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using Kavita.Common; +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; namespace API.Extensions; @@ -7,8 +8,15 @@ public static class ClaimsPrincipalExtensions { public static string GetUsername(this ClaimsPrincipal user) { - var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); + var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name); if (userClaim == null) throw new KavitaException("User is not authenticated"); return userClaim.Value; } + + public static int GetUserId(this ClaimsPrincipal user) + { + var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); + if (userClaim == null) throw new KavitaException("User is not authenticated"); + return int.Parse(userClaim.Value); + } } diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs index efae46cd2..6cd6cb532 100644 --- a/API/Extensions/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -133,6 +133,13 @@ public static class QueryableExtensions queryable = queryable.Include(v => v.Volume); } + if (includes.HasFlag(ChapterIncludes.Files)) + { + queryable = queryable + .Include(c => c.Files); + } + + return queryable.AsSplitQuery(); } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index bef39e90d..3fd23b709 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -34,6 +34,11 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); + CreateMap() + .ForMember(dest => dest.PageNum, + opt => + opt.MapFrom( + src => src.PagesRead)); CreateMap() .ForMember(dest => dest.Writers, opt => diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 6a8d8676f..751f9a303 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -278,7 +278,7 @@ public class ArchiveService : IArchiveService /// public string CreateZipForDownload(IEnumerable files, string tempFolder) { - var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); + var dateString = DateTime.UtcNow.ToShortDateString().Replace("/", "_"); var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); var potentialExistingFile = _directoryService.FileSystem.FileInfo.FromFileName(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip")); diff --git a/API/Services/DeviceService.cs b/API/Services/DeviceService.cs index dad63ad27..ffab5a858 100644 --- a/API/Services/DeviceService.cs +++ b/API/Services/DeviceService.cs @@ -114,7 +114,7 @@ public class DeviceService : IDeviceService throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported on Kindle"); - device.LastUsed = DateTime.Now; + device.UpdateLastUsed(); _unitOfWork.DeviceRepository.Update(device); await _unitOfWork.CommitAsync(); var success = await _emailService.SendFilesToEmail(new SendToDto() diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 3eabe5b78..7ca9523e4 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -231,6 +231,7 @@ public class ReaderService : IReaderService var userProgress = await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId); + if (userProgress == null) { // Create a user object diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 1dffcd1ac..42cb77049 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -436,7 +436,7 @@ public class SeriesService : ISeriesService var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(libraryIds); foreach (var library in libraries) { - library.LastModified = DateTime.Now; + library.UpdateLastModified(); _unitOfWork.LibraryRepository.Update(library); } diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index c62e49ce2..134a82f90 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -92,7 +92,7 @@ public class BackupService : IBackupService await SendProgress(0F, "Started backup"); await SendProgress(0.1F, "Copying core files"); - var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); + var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); if (File.Exists(zipPath)) diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 65f4d427e..24075abf3 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -233,7 +233,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService private void UpdateFileAnalysis(MangaFile file) { - file.LastFileAnalysis = DateTime.Now; + file.UpdateLastFileAnalysis(); _unitOfWork.MangaFileRepository.Update(file); } diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 527cc89ec..e568a4f5b 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -164,7 +164,7 @@ public class ProcessSeries : IProcessSeries // Update series FolderPath here await UpdateSeriesFolderPath(parsedInfos, library, series); - series.LastFolderScanned = DateTime.Now; + series.UpdateLastFolderScanned(); if (_unitOfWork.HasChanges()) { @@ -562,7 +562,7 @@ public class ProcessSeries : IProcessSeries "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); chapter = DbFactory.Chapter(info); volume.Chapters.Add(chapter); - series.LastChapterAdded = DateTime.Now; + series.UpdateLastChapterAdded(); } else { diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 5eedb6734..26962183d 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -528,13 +528,13 @@ public class ScannerService : IScannerService _logger.LogInformation("[ScannerService] Finished file scan in {ScanAndUpdateTime} milliseconds. Updating database", scanElapsedTime); - var time = DateTime.Now; + var time = DateTime.UtcNow; foreach (var folderPath in library.Folders) { - folderPath.LastScanned = time; + folderPath.UpdateLastScanned(time); } - library.LastScanned = time; + library.UpdateLastScanned(time); _unitOfWork.LibraryRepository.Update(library); diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 927b15907..923c3b1d7 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -10,6 +10,7 @@ using API.Entities; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; +using static System.Security.Claims.ClaimTypes; using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; @@ -22,6 +23,7 @@ public interface ITokenService Task CreateRefreshToken(AppUser user); } + public class TokenService : ITokenService { private readonly UserManager _userManager; @@ -38,19 +40,20 @@ public class TokenService : ITokenService { var claims = new List { - new Claim(JwtRegisteredClaimNames.NameId, user.UserName) + new Claim(JwtRegisteredClaimNames.Name, user.UserName), + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), }; var roles = await _userManager.GetRolesAsync(user); - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + claims.AddRange(roles.Select(role => new Claim(Role, role))); var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); var tokenDescriptor = new SecurityTokenDescriptor() { Subject = new ClaimsIdentity(claims), - Expires = DateTime.Now.AddDays(14), + Expires = DateTime.UtcNow.AddDays(14), SigningCredentials = creds }; diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs index 5cf847c6e..0760cff8a 100644 --- a/API/SignalR/Presence/PresenceTracker.cs +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -57,7 +57,7 @@ public class PresenceTracker : IPresenceTracker } // Update the last active for the user - user.LastActive = DateTime.Now; + user.UpdateLastActive(); await _unitOfWork.CommitAsync(); } diff --git a/API/Startup.cs b/API/Startup.cs index 6989b13c9..785decd89 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -225,6 +225,7 @@ public class Startup // v0.6.8 or v0.7 await MigrateUserProgressLibraryId.Migrate(unitOfWork, logger); + await MigrateToUtcDates.Migrate(unitOfWork, dataContext, logger); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 9e21f90d9..10903da0e 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -16,6 +16,7 @@ export interface ReadingListItem { releaseDate: string; title: string; libraryType: LibraryType; + libraryName: string; } export interface ReadingList { diff --git a/openapi.json b/openapi.json index 1eb30f82b..8fe95ff3b 100644 --- a/openapi.json +++ b/openapi.json @@ -7,7 +7,7 @@ "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.6.1.34" + "version": "0.6.1.37" }, "servers": [ { @@ -4048,17 +4048,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ChapterDto" + "type": "boolean" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ChapterDto" + "type": "boolean" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ChapterDto" + "type": "boolean" } } } @@ -6152,17 +6152,17 @@ "content": { "text/plain": { "schema": { - "$ref": "#/components/schemas/ChapterDto" + "$ref": "#/components/schemas/ChapterMetadataDto" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ChapterDto" + "$ref": "#/components/schemas/ChapterMetadataDto" } }, "text/json": { "schema": { - "$ref": "#/components/schemas/ChapterDto" + "$ref": "#/components/schemas/ChapterMetadataDto" } } } @@ -9339,10 +9339,18 @@ "type": "string", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, "lastActive": { "type": "string", "format": "date-time" }, + "lastActiveUtc": { + "type": "string", + "format": "date-time" + }, "libraries": { "type": "array", "items": { @@ -9470,6 +9478,14 @@ "lastModified": { "type": "string", "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" } }, "additionalProperties": false, @@ -9633,6 +9649,14 @@ "description": "Last date this was updated", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "appUser": { "$ref": "#/components/schemas/AppUser" }, @@ -9888,6 +9912,14 @@ "type": "string", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "coverImage": { "type": "string", "description": "Relative path to the (managed) image file representing the cover image", @@ -9988,6 +10020,13 @@ }, "nullable": true }, + "userProgress": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppUserProgress" + }, + "nullable": true + }, "volume": { "$ref": "#/components/schemas/Volume" }, @@ -10042,6 +10081,11 @@ "description": "Calculated at API time. Number of pages read for this Chapter for logged in user.", "format": "int32" }, + "lastReadingProgressUtc": { + "type": "string", + "description": "The last time a chapter was read by current authenticated user", + "format": "date-time" + }, "coverImageLocked": { "type": "boolean", "description": "If the Cover Image is locked for this entity" @@ -10056,6 +10100,18 @@ "description": "When chapter was created", "format": "date-time" }, + "lastModified": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "releaseDate": { "type": "string", "description": "When the chapter was released.", @@ -10189,6 +10245,145 @@ "additionalProperties": false, "description": "Information about the Chapter for the Reader to render" }, + "ChapterMetadataDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "chapterId": { + "type": "integer", + "format": "int32" + }, + "title": { + "type": "string", + "nullable": true + }, + "writers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "coverArtists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "publishers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "characters": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "pencillers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "inkers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "colorists": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "letterers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "editors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "translators": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + }, + "nullable": true + }, + "genres": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GenreTagDto" + }, + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagDto" + }, + "description": "Collection of all Tags from underlying chapters for a Series", + "nullable": true + }, + "ageRating": { + "$ref": "#/components/schemas/AgeRating" + }, + "releaseDate": { + "type": "string", + "nullable": true + }, + "publicationStatus": { + "$ref": "#/components/schemas/PublicationStatus" + }, + "summary": { + "type": "string", + "description": "Summary for the Chapter/Issue", + "nullable": true + }, + "language": { + "type": "string", + "description": "Language for the Chapter/Issue", + "nullable": true + }, + "count": { + "type": "integer", + "description": "Number in the TotalCount of issues", + "format": "int32" + }, + "totalCount": { + "type": "integer", + "description": "Total number of issues for the series", + "format": "int32" + }, + "wordCount": { + "type": "integer", + "description": "Number of Words for this chapter. Only applies to Epub", + "format": "int64" + } + }, + "additionalProperties": false, + "description": "Exclusively metadata about a given chapter" + }, "CollectionTag": { "type": "object", "properties": { @@ -10533,6 +10728,10 @@ "description": "Last time this device was used to send a file", "format": "date-time" }, + "lastUsedUtc": { + "type": "string", + "format": "date-time" + }, "created": { "type": "string", "format": "date-time" @@ -10540,6 +10739,14 @@ "lastModified": { "type": "string", "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" } }, "additionalProperties": false, @@ -10571,6 +10778,11 @@ "type": "string", "description": "Last time this device was used to send a file", "format": "date-time" + }, + "lastUsedUtc": { + "type": "string", + "description": "Last time this device was used to send a file", + "format": "date-time" } }, "additionalProperties": false, @@ -11075,6 +11287,18 @@ "format": "date-time", "nullable": true }, + "createdAtUtc": { + "type": "string", + "description": "When the job was created", + "format": "date-time", + "nullable": true + }, + "lastExecutionUtc": { + "type": "string", + "description": "Last time the job was run", + "format": "date-time", + "nullable": true + }, "cron": { "type": "string", "nullable": true @@ -11173,6 +11397,14 @@ "type": "string", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "lastScanned": { "type": "string", "description": "Last time Library was scanned", @@ -11331,11 +11563,23 @@ "description": "Last time underlying file was modified", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "lastFileAnalysis": { "type": "string", "description": "Last time file analysis ran on this file", "format": "date-time" }, + "lastFileAnalysisUtc": { + "type": "string", + "format": "date-time" + }, "chapter": { "$ref": "#/components/schemas/Chapter" }, @@ -11652,13 +11896,8 @@ }, "bookScrollId": { "type": "string", - "description": "For Book reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".", + "description": "For EPUB reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".", "nullable": true - }, - "lastModified": { - "type": "string", - "description": "Last time the progress was synced from UI or external app", - "format": "date-time" } }, "additionalProperties": false @@ -11809,6 +12048,14 @@ "type": "string", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "appUserId": { "type": "integer", "format": "int32" @@ -11950,6 +12197,10 @@ "libraryType": { "$ref": "#/components/schemas/LibraryType" }, + "libraryName": { + "type": "string", + "nullable": true + }, "title": { "type": "string", "nullable": true @@ -11963,6 +12214,16 @@ "type": "integer", "description": "Used internally only", "format": "int32" + }, + "lastReadingProgressUtc": { + "type": "string", + "description": "The last time a reading list item (underlying chapter) was read by current authenticated user", + "format": "date-time" + }, + "fileSize": { + "type": "integer", + "description": "File size of underlying item", + "format": "int64" } }, "additionalProperties": false @@ -12418,6 +12679,14 @@ "description": "Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "coverImage": { "type": "string", "description": "Absolute path to the (managed) image file", @@ -12442,6 +12711,11 @@ "description": "Last time the folder was scanned", "format": "date-time" }, + "lastFolderScannedUtc": { + "type": "string", + "description": "Last time the folder was scanned in Utc", + "format": "date-time" + }, "format": { "$ref": "#/components/schemas/MangaFormat" }, @@ -12459,6 +12733,10 @@ "description": "When a Chapter was last added onto the Series", "format": "date-time" }, + "lastChapterAddedUtc": { + "type": "string", + "format": "date-time" + }, "wordCount": { "type": "integer", "description": "Total Word count of all chapters in this chapter.", @@ -13473,6 +13751,14 @@ "lastModified": { "type": "string", "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" } }, "additionalProperties": false, @@ -13510,6 +13796,14 @@ "type": "string", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "selector": { "type": "string", "nullable": true, @@ -14488,6 +14782,14 @@ "type": "string", "format": "date-time" }, + "createdUtc": { + "type": "string", + "format": "date-time" + }, + "lastModifiedUtc": { + "type": "string", + "format": "date-time" + }, "coverImage": { "type": "string", "description": "Absolute path to the (managed) image file",