From af4f35da5b6466308e933d3f863895d8f5f36170 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Wed, 13 Jul 2022 10:45:14 -0400 Subject: [PATCH] Better Caching & Global Downloads (#1372) * Fixed a bug where cache TTL was using a field which always was 0. * Updated Scan Series task (from UI) to always re-calculate what's on file and not rely on last update. This leads to more reliable results, despite extra overhead. * Added image range processing on images for the reader, for slower networks or large files * On manga (single) try to use prefetched image, rather than re-requesting an image on pagination * Reduced some more latency when rendering first page of next chapter via continuous reading mode * Fixed a bug where metadata filter, after updating a typeahead, collapsing filter area then re-opening, the filter would still be applied, but the typeahead wouldn't show the modification. * Coded an idea around download reporting, commiting for history, might not go with it. * Refactored the download indicator into it's own component. Cleaning up some code for download within card component * Another throw away commit. Put in some temp code, not working but not sure if I'm ditching entirely. * Updated download service to enable range processing (so downloads can resume) and to reduce re-zipping if we've just downloaded something. * Refactored events widget download indicator to the correct design. I will be moving forward with this new functionality. * Added Required fields to ProgressDTO * Cleaned up the event widget and updated existing download progress to indicate preparing the download, rather than the download itself. * Updated dependencies for security alerts * Refactored all download code to be streamlined and globally handled * Updated ScanSeries to find the highest folder path before library, not just within the files. This could lead to scan series missing files due to nested folders on same parent level. * Updated the caching code to use a builtin annotation. Images are now caching correctly. * Fixed a bad redirect on an auth guard * Tweaked how long we allow cache for, as the cover update now doesn't work well. * Fixed a bug on downloading bookmarks from multiple series, where it would just choose the first series id for the temp file. * Added an extra check for downloading bookmarks * UI Security updates, Fixed a bug on bookmark reader, the reader on last page would throw some errors and not show No Next Chapter toast. * After scan, clear temp * Code smells --- API.Benchmark/API.Benchmark.csproj | 2 +- API.Tests/API.Tests.csproj | 6 +- API.Tests/Helpers/SeriesHelperTests.cs | 21 + API/API.csproj | 12 +- API/Controllers/BookController.cs | 2 +- API/Controllers/DownloadController.cs | 33 +- API/Controllers/ImageController.cs | 16 +- API/Controllers/OPDSController.cs | 8 +- API/Controllers/ReaderController.cs | 8 +- API/Controllers/SeriesController.cs | 57 +- API/Controllers/ServerController.cs | 6 +- API/Controllers/UsersController.cs | 1 + API/DTOs/Downloads/DownloadBookmarkDto.cs | 2 + API/DTOs/ProgressDto.cs | 8 +- API/DTOs/RefreshSeriesDto.cs | 20 +- API/DTOs/UserPreferencesDto.cs | 33 +- ...romptForDownloadSizeUserOption.Designer.cs | 1576 +++++++++++++++++ ...2161611_PromptForDownloadSizeUserOption.cs | 26 + .../Migrations/DataContextModelSnapshot.cs | 5 +- API/Entities/AppUserPreferences.cs | 4 + API/Extensions/HttpExtensions.cs | 26 +- API/Helpers/SeriesHelper.cs | 6 +- API/Services/ArchiveService.cs | 32 +- API/Services/DownloadService.cs | 13 +- API/Services/Tasks/ScannerService.cs | 12 +- API/SignalR/MessageFactory.cs | 3 +- Kavita.Common/Kavita.Common.csproj | 2 +- UI/Web/package-lock.json | 292 +-- UI/Web/package.json | 2 +- UI/Web/src/app/_guards/auth.guard.ts | 2 +- .../app/_models/preferences/preferences.ts | 1 + .../src/app/_services/message-hub.service.ts | 3 +- .../manage-tasks-settings.component.ts | 7 +- .../bookmark/bookmarks/bookmarks.component.ts | 26 +- .../card-detail-drawer.component.ts | 20 +- .../cards/card-item/card-item.component.html | 8 +- .../cards/card-item/card-item.component.ts | 90 +- UI/Web/src/app/cards/cards.module.ts | 2 + .../download-indicator.component.html | 6 + .../download-indicator.component.scss | 4 + .../download-indicator.component.ts | 24 + .../cards/list-item/list-item.component.html | 7 +- .../cards/list-item/list-item.component.ts | 67 +- .../manga-reader/manga-reader.component.ts | 44 +- .../metadata-filter.component.html | 6 +- .../metadata-filter.component.ts | 15 +- .../events-widget.component.html | 42 +- .../events-widget.component.scss | 5 + .../events-widget/events-widget.component.ts | 3 +- .../series-detail/series-detail.component.ts | 23 +- UI/Web/src/app/shared/_models/download.ts | 13 +- .../app/shared/_services/download.service.ts | 237 ++- .../circular-loader.component.html | 6 +- .../circular-loader.component.scss | 5 +- .../circular-loader.component.ts | 14 + .../src/app/typeahead/typeahead.component.ts | 3 +- .../user-preferences.component.html | 13 +- .../user-preferences.component.ts | 3 + 58 files changed, 2309 insertions(+), 624 deletions(-) create mode 100644 API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.Designer.cs create mode 100644 API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.cs create mode 100644 UI/Web/src/app/cards/download-indicator/download-indicator.component.html create mode 100644 UI/Web/src/app/cards/download-indicator/download-indicator.component.scss create mode 100644 UI/Web/src/app/cards/download-indicator/download-indicator.component.ts diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index bd78c1a8d..31af4f2c6 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -12,7 +12,7 @@ - + diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 5b1cacac2..85350e96d 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -7,10 +7,10 @@ - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Helpers/SeriesHelperTests.cs b/API.Tests/Helpers/SeriesHelperTests.cs index 8acd4bb85..a8ffd95c3 100644 --- a/API.Tests/Helpers/SeriesHelperTests.cs +++ b/API.Tests/Helpers/SeriesHelperTests.cs @@ -137,6 +137,27 @@ public class SeriesHelperTests NormalizedName = API.Parser.Parser.Normalize("SomethingRandom") })); } + + [Fact] + public void FindSeries_ShouldFind_UsingLocalizedName_2() + { + var series = DbFactory.Series("My Dress-Up Darling"); + series.LocalizedName = "Sono Bisque Doll wa Koi wo Suru"; + series.Format = MangaFormat.Archive; + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "My Dress-Up Darling", + NormalizedName = API.Parser.Parser.Normalize("My Dress-Up Darling") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Sono Bisque Doll wa Koi wo Suru".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Sono Bisque Doll wa Koi wo Suru") + })); + } #endregion [Fact] diff --git a/API/API.csproj b/API/API.csproj index a66d03dd6..a16a45180 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -50,11 +50,11 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,13 +66,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 958582338..e18bdb03f 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -94,6 +94,7 @@ namespace API.Controllers /// /// [HttpGet("{chapterId}/book-resources")] + [ResponseCache(Duration = 60 * 1, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetBookPageResources(int chapterId, [FromQuery] string file) { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); @@ -105,7 +106,6 @@ namespace API.Controllers var bookFile = book.Content.AllFiles[key]; var content = await bookFile.ReadContentAsBytesAsync(); - Response.AddCacheHeader(content); var contentType = BookService.GetContentType(bookFile.ContentType); return File(content, contentType, $"{chapterId}-{file}"); } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index d10478c49..8753202f8 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -83,7 +83,7 @@ namespace API.Controllers /// - /// Downloads all chapters within a volume. + /// Downloads all chapters within a volume. If the chapters are multiple zips, they will all be zipped up. /// /// /// @@ -112,12 +112,17 @@ namespace API.Controllers return await _downloadService.HasDownloadPermission(user); } - private async Task GetFirstFileDownload(IEnumerable files) + private ActionResult GetFirstFileDownload(IEnumerable files) { - var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files); - return File(bytes, contentType, fileDownloadName); + var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files); + return PhysicalFile(zipFile, contentType, fileDownloadName, true); } + /// + /// Returns the zip for a single chapter. If the chapter contains multiple files, they will be zipped. + /// + /// + /// [HttpGet("chapter")] public async Task DownloadChapter(int chapterId) { @@ -148,15 +153,14 @@ namespace API.Controllers await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); - return await GetFirstFileDownload(files); + return GetFirstFileDownload(files); } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - tempFolder); + var filePath = _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), tempFolder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); - return File(fileBytes, DefaultContentType, downloadName); + return PhysicalFile(filePath, DefaultContentType, downloadName, true); } catch (Exception ex) { @@ -184,10 +188,16 @@ namespace API.Controllers } } + /// + /// Downloads all bookmarks in a zip for + /// + /// + /// [HttpPost("bookmarks")] public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) { if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + 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()); @@ -198,13 +208,14 @@ namespace API.Controllers var filename = $"{series.Name} - Bookmarks.zip"; await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F)); - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files, - $"download_{user.Id}_{series.Id}_bookmarks"); + var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct()); + var filePath = _archiveService.CreateZipForDownload(files, + $"download_{user.Id}_{seriesIds}_bookmarks"); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F)); - return File(fileBytes, DefaultContentType, filename); + return PhysicalFile(filePath, DefaultContentType, filename, true); } } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index b34b9f6b2..17ce2c215 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -2,7 +2,6 @@ using System.Threading.Tasks; using API.Data; using API.Entities.Enums; -using API.Extensions; using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,6 +15,7 @@ namespace API.Controllers { private readonly IUnitOfWork _unitOfWork; private readonly IDirectoryService _directoryService; + private const int ImageCacheSeconds = 1 * 60; /// public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService) @@ -30,13 +30,13 @@ namespace API.Controllers /// /// [HttpGet("chapter-cover")] + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetChapterCoverImage(int chapterId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } @@ -46,13 +46,13 @@ namespace API.Controllers /// /// [HttpGet("volume-cover")] + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetVolumeCoverImage(int volumeId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } @@ -61,6 +61,7 @@ namespace API.Controllers /// /// Id of Series /// + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId) { @@ -68,7 +69,6 @@ namespace API.Controllers if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } @@ -78,13 +78,13 @@ namespace API.Controllers /// /// [HttpGet("collection-cover")] + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetCollectionCoverImage(int collectionTagId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } @@ -94,13 +94,13 @@ namespace API.Controllers /// /// [HttpGet("readinglist-cover")] + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetReadingListCoverImage(int readingListId) { var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } @@ -113,6 +113,7 @@ namespace API.Controllers /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -124,7 +125,6 @@ namespace API.Controllers var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName)); var format = Path.GetExtension(file.FullName).Replace(".", ""); - Response.AddCacheHeader(file.FullName); return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName)); } @@ -135,13 +135,13 @@ namespace API.Controllers /// [AllowAnonymous] [HttpGet("cover-upload")] + [ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)] public ActionResult GetCoverUploadImage(string filename) { var path = Path.Join(_directoryService.TempDirectory, filename); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist"); var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 4dee326be..168ce974c 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -622,8 +622,8 @@ public class OpdsController : BaseApiController } var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files); - return File(bytes, contentType, fileDownloadName); + var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files); + return PhysicalFile(zipFile, contentType, fileDownloadName, true); } private static ContentResult CreateXmlResult(string xml) @@ -830,6 +830,7 @@ public class OpdsController : BaseApiController } [HttpGet("{apiKey}/favicon")] + [ResponseCache(Duration = 60 * 60, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetFavicon(string apiKey) { var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico"); @@ -838,9 +839,6 @@ public class OpdsController : BaseApiController var content = await _directoryService.ReadFileAsync(path); var format = Path.GetExtension(path).Replace(".", ""); - // Calculates SHA1 Hash for byte[] - Response.AddCacheHeader(content); - return File(content, "image/" + format); } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 34cb411bd..6cf02ffec 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -47,6 +47,7 @@ namespace API.Controllers /// /// [HttpGet("pdf")] + [ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetPdf(int chapterId) { var chapter = await _cacheService.Ensure(chapterId); @@ -57,7 +58,6 @@ namespace API.Controllers var path = _cacheService.GetCachedFile(chapter); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should."); - Response.AddCacheHeader(path, TimeSpan.FromMinutes(60).Seconds); return PhysicalFile(path, "application/pdf", Path.GetFileName(path), true); } catch (Exception) @@ -74,6 +74,7 @@ namespace API.Controllers /// /// [HttpGet("image")] + [ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetImage(int chapterId, int page) { if (page < 0) page = 0; @@ -86,8 +87,7 @@ namespace API.Controllers if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); var format = Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path, TimeSpan.FromMinutes(10).Seconds); - return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); + return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true); } catch (Exception) { @@ -105,6 +105,7 @@ namespace API.Controllers /// We must use api key as bookmarks could be leaked to other users via the API /// [HttpGet("bookmark-image")] + [ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)] public async Task GetBookmarkImage(int seriesId, string apiKey, int page) { if (page < 0) page = 0; @@ -121,7 +122,6 @@ namespace API.Controllers if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); var format = Path.GetExtension(path).Replace(".", ""); - Response.AddCacheHeader(path, TimeSpan.FromMinutes(10).Seconds); return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); } catch (Exception) diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 76fa78fef..bc3acb1b8 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -253,30 +253,50 @@ namespace API.Controllers return Ok(pagedList); } + /// + /// Runs a Cover Image Generation task + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("refresh-metadata")] public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) { - _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, true); + _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } + /// + /// Scan a series and force each file to be updated. This should be invoked via the User, hence why we force. + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] public ActionResult ScanSeries(RefreshSeriesDto refreshSeriesDto) { - _taskScheduler.ScanSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId); + _taskScheduler.ScanSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } + /// + /// Run a file analysis on the series. + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("analyze")] public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto) { - _taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, true); + _taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); return Ok(); } + /// + /// Returns metadata for a given series + /// + /// + /// [HttpGet("metadata")] public async Task> GetSeriesMetadata(int seriesId) { @@ -284,6 +304,11 @@ namespace API.Controllers return Ok(metadata); } + /// + /// Update series metadata + /// + /// + /// [HttpPost("metadata")] public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) { @@ -331,6 +356,11 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); } + /// + /// Get the age rating for the enum value + /// + /// + /// [HttpGet("age-rating")] public ActionResult GetAgeRating(int ageRating) { @@ -339,6 +369,12 @@ namespace API.Controllers return Ok(val.ToDescription()); } + /// + /// Get a special DTO for Series Detail page. + /// + /// + /// + /// Do not rely on this API externally. May change without hesitation. [HttpGet("series-detail")] public async Task> GetSeriesDetailBreakdown(int seriesId) { @@ -386,16 +422,24 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation)); } + /// + /// Returns all related series against the passed series Id + /// + /// + /// [HttpGet("all-related")] public async Task> GetAllRelatedSeries(int seriesId) { - // Send back a custom DTO with each type or maybe sorted in some way var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId)); } - + /// + /// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series. + /// + /// + /// [Authorize(Policy="RequireAdminRole")] [HttpPost("update-related")] public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) @@ -421,7 +465,8 @@ namespace API.Controllers return BadRequest("There was an issue updating relationships"); } - private void UpdateRelationForKind(IList dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) + // TODO: Move this to a Service and Unit Test it + private void UpdateRelationForKind(ICollection dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) { foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId))) { diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index f49a7e092..161541a24 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -112,13 +112,13 @@ namespace API.Controllers } [HttpGet("logs")] - public async Task GetLogs() + public ActionResult GetLogs() { var files = _backupService.GetLogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName()); try { - var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files, "logs"); - return File(fileBytes, "application/zip", Path.GetFileName(zipPath), true); + var zipPath = _archiveService.CreateZipForDownload(files, "logs"); + return PhysicalFile(zipPath, "application/zip", Path.GetFileName(zipPath), true); } catch (KavitaException ex) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 7b7cf492d..ce8753157 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -98,6 +98,7 @@ namespace API.Controllers existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); existingPreferences.LayoutMode = preferencesDto.LayoutMode; + existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; _unitOfWork.UserRepository.Update(existingPreferences); diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs index b7ccf9569..b1158ff23 100644 --- a/API/DTOs/Downloads/DownloadBookmarkDto.cs +++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs @@ -1,10 +1,12 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using API.DTOs.Reader; namespace API.DTOs.Downloads { public class DownloadBookmarkDto { + [Required] public IEnumerable Bookmarks { get; set; } } } diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs index 4810a40a9..021a5f243 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/ProgressDto.cs @@ -1,10 +1,16 @@ -namespace API.DTOs +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs { public class ProgressDto { + [Required] public int VolumeId { get; set; } + [Required] public int ChapterId { get; set; } + [Required] public int PageNum { get; set; } + [Required] public int SeriesId { get; set; } /// /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position diff --git a/API/DTOs/RefreshSeriesDto.cs b/API/DTOs/RefreshSeriesDto.cs index bc6344ea2..db1264bd3 100644 --- a/API/DTOs/RefreshSeriesDto.cs +++ b/API/DTOs/RefreshSeriesDto.cs @@ -1,8 +1,22 @@ namespace API.DTOs { + /// + /// Used for running some task against a Series. + /// public class RefreshSeriesDto { - public int LibraryId { get; set; } - public int SeriesId { get; set; } + /// + /// Library Id series belongs to + /// + public int LibraryId { get; init; } + /// + /// Series Id + /// + public int SeriesId { get; init; } + /// + /// Should the task force opening/re-calculation. + /// + /// This is expensive if true. Defaults to true. + public bool ForceUpdate { get; init; } = true; } -} \ No newline at end of file +} diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 063b07726..255c21c1f 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,6 @@ -using API.DTOs.Theme; +using System; +using System.ComponentModel.DataAnnotations; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; @@ -10,14 +12,17 @@ namespace API.DTOs /// /// Manga Reader Option: What direction should the next/prev page buttons go /// + [Required] public ReadingDirection ReadingDirection { get; set; } /// /// Manga Reader Option: How should the image be scaled to screen /// + [Required] public ScalingOption ScalingOption { get; set; } /// /// Manga Reader Option: Which side of a split image should we show first /// + [Required] public PageSplitOption PageSplitOption { get; set; } /// /// Manga Reader Option: How the manga reader should perform paging or reading of the file @@ -26,72 +31,90 @@ namespace API.DTOs /// by clicking top/bottom sides of reader. /// /// + [Required] public ReaderMode ReaderMode { get; set; } /// /// Manga Reader Option: How many pages to display in the reader at once /// + [Required] public LayoutMode LayoutMode { get; set; } /// /// Manga Reader Option: Background color of the reader /// + [Required] public string BackgroundColor { get; set; } = "#000000"; /// /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// + [Required] public bool AutoCloseMenu { get; set; } /// /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change /// + [Required] public bool ShowScreenHints { get; set; } = true; /// - /// Book Reader Option: Should the background color be dark - /// - public bool BookReaderDarkMode { get; set; } = false; - /// /// Book Reader Option: Override extra Margin /// + [Required] public int BookReaderMargin { get; set; } /// /// Book Reader Option: Override line-height /// + [Required] public int BookReaderLineSpacing { get; set; } /// /// Book Reader Option: Override font size /// + [Required] public int BookReaderFontSize { get; set; } /// /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override /// + [Required] public string BookReaderFontFamily { get; set; } /// /// Book Reader Option: Allows tapping on side of screens to paginate /// + [Required] public bool BookReaderTapToPaginate { get; set; } /// /// Book Reader Option: What direction should the next/prev page buttons go /// + [Required] public ReadingDirection BookReaderReadingDirection { get; set; } /// /// UI Site Global Setting: The UI theme the user should use. /// /// Should default to Dark + [Required] public SiteTheme Theme { get; set; } + [Required] public string BookReaderThemeName { get; set; } + [Required] public BookPageLayoutMode BookReaderLayoutMode { get; set; } /// /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. /// /// Defaults to false + [Required] public bool BookReaderImmersiveMode { get; set; } = false; /// /// Global Site Option: If the UI should layout items as Cards or List items /// /// Defaults to Cards + [Required] public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; /// /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already /// /// Defaults to false + [Required] public bool BlurUnreadSummaries { get; set; } = false; + /// + /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. + /// + [Required] + public bool PromptForDownloadSize { get; set; } = true; } } diff --git a/API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.Designer.cs b/API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.Designer.cs new file mode 100644 index 000000000..a2eb08e68 --- /dev/null +++ b/API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.Designer.cs @@ -0,0 +1,1576 @@ +// +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("20220712161611_PromptForDownloadSizeUserOption")] + partial class PromptForDownloadSizeUserOption + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.7"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("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("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.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.ClientCascade) + .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.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("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.cs b/API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.cs new file mode 100644 index 000000000..9bd994ed7 --- /dev/null +++ b/API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class PromptForDownloadSizeUserOption : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PromptForDownloadSize", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PromptForDownloadSize", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 6872d2bfb..122b395b0 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.6"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.7"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -213,6 +213,9 @@ namespace API.Data.Migrations b.Property("PageSplitOption") .HasColumnType("INTEGER"); + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + b.Property("ReaderMode") .HasColumnType("INTEGER"); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 56361f8c8..477f37999 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -98,6 +98,10 @@ namespace API.Entities /// /// Defaults to false public bool BlurUnreadSummaries { get; set; } = false; + /// + /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. + /// + public bool PromptForDownloadSize { get; set; } = true; public AppUser AppUser { get; set; } public int AppUserId { get; set; } diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index c468ef7ce..778d48bf8 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; @@ -6,6 +7,7 @@ using System.Text; using System.Text.Json; using API.Helpers; using Microsoft.AspNetCore.Http; +using Microsoft.Net.Http.Headers; namespace API.Extensions { @@ -31,29 +33,11 @@ namespace API.Extensions /// If byte[] is null or empty, will only add cache-control public static void AddCacheHeader(this HttpResponse response, byte[] content) { - if (content == null || content.Length <= 0) return; + if (content is not {Length: > 0}) return; using var sha1 = SHA256.Create(); - response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2")))); + response.Headers.Add(HeaderNames.ETag, string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2")))); + response.Headers.CacheControl = $"private,max-age=100"; } - - /// - /// Calculates SHA256 hash for a cover image filename and sets as ETag. Ensures Cache-Control: private header is added. - /// - /// - /// - /// Maximum amount of seconds to set for Cache-Control - public static void AddCacheHeader(this HttpResponse response, string filename, int maxAge = 10) - { - if (filename is not {Length: > 0}) return; - var hashContent = filename + File.GetLastWriteTimeUtc(filename); - using var sha1 = SHA256.Create(); - response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")))); - if (maxAge != 10) - { - response.Headers.CacheControl = $"max-age={maxAge}"; - } - } - } } diff --git a/API/Helpers/SeriesHelper.cs b/API/Helpers/SeriesHelper.cs index b03ebad18..f92039a24 100644 --- a/API/Helpers/SeriesHelper.cs +++ b/API/Helpers/SeriesHelper.cs @@ -16,9 +16,9 @@ public static class SeriesHelper /// public static bool FindSeries(Series series, ParsedSeries parsedInfoKey) { - return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) - || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName) - || Parser.Parser.Normalize(series.LocalizedName).Equals(parsedInfoKey.NormalizedName)) + return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || + Parser.Parser.Normalize(series.LocalizedName).Equals(parsedInfoKey.NormalizedName) || + Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName)) && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index f9b85fb59..f9f5b7588 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -4,8 +4,6 @@ using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; -using System.Text; -using System.Threading.Tasks; using System.Xml.Serialization; using API.Archive; using API.Data.Metadata; @@ -27,7 +25,14 @@ namespace API.Services ComicInfo GetComicInfo(string archivePath); ArchiveLibrary CanOpen(string archivePath); bool ArchiveNeedsFlattening(ZipArchive archive); - Task> CreateZipForDownload(IEnumerable files, string tempFolder); + /// + /// Creates a zip file form the listed files and outputs to the temp folder. + /// + /// List of files to be zipped up. Should be full file paths. + /// Temp folder name to use for preparing the files. Will be created and deleted + /// Path to the temp zip + /// + string CreateZipForDownload(IEnumerable files, string tempFolder); } /// @@ -267,15 +272,22 @@ namespace API.Services /// /// List of files to be zipped up. Should be full file paths. /// Temp folder name to use for preparing the files. Will be created and deleted - /// All bytes for the given file in a Tuple + /// Path to the temp zip /// - public async Task> CreateZipForDownload(IEnumerable files, string tempFolder) + public string CreateZipForDownload(IEnumerable files, string tempFolder) { - // TODO: Refactor CreateZipForDownload to return the temp file so we can stream it from temp var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); + var potentialExistingFile = _directoryService.FileSystem.FileInfo.FromFileName(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip")); + if (potentialExistingFile.Exists) + { + // A previous download exists, just return it immediately + return potentialExistingFile.FullName; + } + _directoryService.ExistOrCreate(tempLocation); + if (!_directoryService.CopyFilesToDirectory(files, tempLocation)) { throw new KavitaException("Unable to copy files to temp directory archive download."); @@ -292,13 +304,7 @@ namespace API.Services throw new KavitaException("There was an issue creating temp archive"); } - - var fileBytes = await _directoryService.ReadFileAsync(zipPath); - - _directoryService.ClearAndDeleteDirectory(tempLocation); // NOTE: For sending back just zip, just schedule this to be called after the file is returned or let next temp storage cleanup take care of it - (new FileInfo(zipPath)).Delete(); - - return Tuple.Create(fileBytes, zipPath); + return zipPath; } diff --git a/API/Services/DownloadService.cs b/API/Services/DownloadService.cs index 76365d3d3..c1591056a 100644 --- a/API/Services/DownloadService.cs +++ b/API/Services/DownloadService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -11,19 +12,17 @@ namespace API.Services; public interface IDownloadService { - Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable files); + Tuple GetFirstFileDownload(IEnumerable files); string GetContentTypeFromFile(string filepath); Task HasDownloadPermission(AppUser user); } public class DownloadService : IDownloadService { - private readonly IDirectoryService _directoryService; private readonly UserManager _userManager; private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider(); - public DownloadService(IDirectoryService directoryService, UserManager userManager) + public DownloadService(UserManager userManager) { - _directoryService = directoryService; _userManager = userManager; } @@ -32,10 +31,10 @@ public class DownloadService : IDownloadService /// /// /// - public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable files) + public Tuple GetFirstFileDownload(IEnumerable files) { var firstFile = files.Select(c => c.FilePath).First(); - return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile)); + return Tuple.Create(firstFile, GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile)); } public string GetContentTypeFromFile(string filepath) diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 33341f8e5..31f07fff8 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using API.Comparators; using API.Data; using API.Data.Metadata; using API.Data.Repositories; @@ -83,6 +82,7 @@ public class ScannerService : IScannerService var seriesFolderPaths = (await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId)) .Select(f => _directoryService.FileSystem.FileInfo.FromFileName(f.FilePath).Directory.FullName) .ToList(); + var libraryPaths = library.Folders.Select(f => f.Path).ToList(); if (!await CheckMounts(library.Name, seriesFolderPaths)) { @@ -90,7 +90,7 @@ public class ScannerService : IScannerService return; } - if (!await CheckMounts(library.Name, library.Folders.Select(f => f.Path).ToList())) + if (!await CheckMounts(library.Name, libraryPaths)) { _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); return; @@ -100,7 +100,8 @@ public class ScannerService : IScannerService var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(seriesFolderPaths, files.Select(f => f.FilePath).ToList()); + // Shouldn't this be libraryPath? + var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, files.Select(f => f.FilePath).ToList()); if (seriesDirs.Keys.Count == 0) { _logger.LogDebug("Scan Series has files spread outside a main series folder. Defaulting to library folder"); @@ -190,6 +191,7 @@ public class ScannerService : IScannerService MessageFactory.ScanSeriesEvent(libraryId, seriesId, series.Name)); await CleanupDbEntities(); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); + BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, series.Id, false)); } @@ -197,8 +199,7 @@ public class ScannerService : IScannerService private static void RemoveParsedInfosNotForSeries(Dictionary> parsedSeries, Series series) { var keys = parsedSeries.Keys; - foreach (var key in keys.Where(key => - series.Format != key.Format || !SeriesHelper.FindSeries(series, key))) + foreach (var key in keys.Where(key => !SeriesHelper.FindSeries(series, key))) // series.Format != key.Format || { parsedSeries.Remove(key); } @@ -328,6 +329,7 @@ public class ScannerService : IScannerService BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, false)); + BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)); } private async Task>>> ScanFiles(Library library, IEnumerable dirs) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 952b16736..47aa07f02 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -4,6 +4,7 @@ using System.IO; using System.Threading; using API.DTOs.Update; using API.Entities; +using API.Extensions; namespace API.SignalR { @@ -302,7 +303,7 @@ namespace API.SignalR { Name = DownloadProgress, Title = $"Downloading {downloadName}", - SubTitle = $"{username} is downloading {downloadName}", + SubTitle = $"Preparing {username.SentenceCase()} the download of {downloadName}", EventType = eventType, Progress = ProgressType.Determinate, Body = new diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 76f68034f..d7a1b3df6 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -12,7 +12,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 7944d68d4..18b8666b8 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -1512,17 +1512,6 @@ "@babel/helper-plugin-utils": "^7.16.7" } }, - "@babel/plugin-transform-typescript": { - "version": "7.16.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz", - "integrity": "sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==", - "dev": true, - "requires": { - "@babel/helper-create-class-features-plugin": "^7.16.7", - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/plugin-syntax-typescript": "^7.16.7" - } - }, "@babel/plugin-transform-unicode-escapes": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz", @@ -1640,17 +1629,6 @@ "esutils": "^2.0.2" } }, - "@babel/preset-typescript": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz", - "integrity": "sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.16.7", - "@babel/helper-validator-option": "^7.16.7", - "@babel/plugin-transform-typescript": "^7.16.7" - } - }, "@babel/runtime": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz", @@ -3055,273 +3033,19 @@ } }, "@playwright/test": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.20.2.tgz", - "integrity": "sha512-unkLa+xe/lP7MVC0qpgadc9iSG1+LEyGBzlXhGS/vLGAJaSFs8DNfI89hNd5shHjWfNzb34JgPVnkRKCSNo5iw==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.23.2.tgz", + "integrity": "sha512-umaEAIwQGfbezixg3raSOraqbQGSqZP988sOaMdpA2wj3Dr6ykOscrMukyK3U6edxhpS0N8kguAFZoHwCEfTig==", "dev": true, "requires": { - "@babel/code-frame": "7.16.7", - "@babel/core": "7.16.12", - "@babel/helper-plugin-utils": "7.16.7", - "@babel/plugin-proposal-class-properties": "7.16.7", - "@babel/plugin-proposal-dynamic-import": "7.16.7", - "@babel/plugin-proposal-export-namespace-from": "7.16.7", - "@babel/plugin-proposal-logical-assignment-operators": "7.16.7", - "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.7", - "@babel/plugin-proposal-numeric-separator": "7.16.7", - "@babel/plugin-proposal-optional-chaining": "7.16.7", - "@babel/plugin-proposal-private-methods": "7.16.11", - "@babel/plugin-proposal-private-property-in-object": "7.16.7", - "@babel/plugin-syntax-async-generators": "7.8.4", - "@babel/plugin-syntax-json-strings": "7.8.3", - "@babel/plugin-syntax-object-rest-spread": "7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "7.8.3", - "@babel/plugin-transform-modules-commonjs": "7.16.8", - "@babel/preset-typescript": "7.16.7", - "colors": "1.4.0", - "commander": "8.3.0", - "debug": "4.3.3", - "expect": "27.2.5", - "jest-matcher-utils": "27.2.5", - "json5": "2.2.1", - "mime": "3.0.0", - "minimatch": "3.0.4", - "ms": "2.1.3", - "open": "8.4.0", - "pirates": "4.0.4", - "playwright-core": "1.20.2", - "rimraf": "3.0.2", - "source-map-support": "0.4.18", - "stack-utils": "2.0.5", - "yazl": "2.5.1" + "@types/node": "*", + "playwright-core": "1.23.2" }, "dependencies": { - "@babel/code-frame": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", - "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", - "dev": true, - "requires": { - "@babel/highlight": "^7.16.7" - } - }, - "@babel/core": { - "version": "7.16.12", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.16.12.tgz", - "integrity": "sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.16.7", - "@babel/generator": "^7.16.8", - "@babel/helper-compilation-targets": "^7.16.7", - "@babel/helper-module-transforms": "^7.16.7", - "@babel/helpers": "^7.16.7", - "@babel/parser": "^7.16.12", - "@babel/template": "^7.16.7", - "@babel/traverse": "^7.16.10", - "@babel/types": "^7.16.8", - "convert-source-map": "^1.7.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.1.2", - "semver": "^6.3.0", - "source-map": "^0.5.0" - } - }, - "@babel/helper-validator-identifier": { - "version": "7.16.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", - "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", - "dev": true - }, - "@babel/highlight": { - "version": "7.17.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", - "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.16.7", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "requires": { - "debug": "4" - } - }, - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true - }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "requires": { - "color-name": "~1.1.4" - } - }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true - }, - "expect": { - "version": "27.2.5", - "resolved": "https://registry.npmjs.org/expect/-/expect-27.2.5.tgz", - "integrity": "sha512-ZrO0w7bo8BgGoP/bLz+HDCI+0Hfei9jUSZs5yI/Wyn9VkG9w8oJ7rHRgYj+MA7yqqFa0IwHA3flJzZtYugShJA==", - "dev": true, - "requires": { - "@jest/types": "^27.2.5", - "ansi-styles": "^5.0.0", - "jest-get-type": "^27.0.6", - "jest-matcher-utils": "^27.2.5", - "jest-message-util": "^27.2.5", - "jest-regex-util": "^27.0.6" - } - }, - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "https-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", - "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", - "dev": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "jest-matcher-utils": { - "version": "27.2.5", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.2.5.tgz", - "integrity": "sha512-qNR/kh6bz0Dyv3m68Ck2g1fLW5KlSOUNcFQh87VXHZwWc/gY6XwnKofx76Qytz3x5LDWT09/2+yXndTkaG4aWg==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^27.2.5", - "jest-get-type": "^27.0.6", - "pretty-format": "^27.2.5" - }, - "dependencies": { - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "requires": { - "color-convert": "^2.0.1" - } - }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - } - } - } - }, - "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", - "dev": true - }, - "mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "dev": true - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "pirates": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.4.tgz", - "integrity": "sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==", - "dev": true - }, "playwright-core": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.20.2.tgz", - "integrity": "sha512-iV6+HftSPalynkq0CYJala1vaTOq7+gU9BRfKCdM9bAxNq/lFLrwbluug2Wt5OoUwbMABcnTThIEm3/qUhCdJQ==", - "dev": true, - "requires": { - "colors": "1.4.0", - "commander": "8.3.0", - "debug": "4.3.3", - "extract-zip": "2.0.1", - "https-proxy-agent": "5.0.0", - "jpeg-js": "0.4.3", - "mime": "3.0.0", - "pixelmatch": "5.2.1", - "pngjs": "6.0.0", - "progress": "2.0.3", - "proper-lockfile": "4.1.2", - "proxy-from-env": "1.1.0", - "rimraf": "3.0.2", - "socks-proxy-agent": "6.1.1", - "stack-utils": "2.0.5", - "ws": "8.4.2", - "yauzl": "2.10.0", - "yazl": "2.5.1" - } - }, - "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", - "dev": true - }, - "source-map-support": { - "version": "0.4.18", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", - "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", - "dev": true, - "requires": { - "source-map": "^0.5.6" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "ws": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.4.2.tgz", - "integrity": "sha512-Kbk4Nxyq7/ZWqr/tarI9yIt/+iNNFOjBXEWgTb4ydaNHBNGgvf2QHbS9fdfsndfjFlFwEd4Al+mw83YkaD10ZA==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.23.2.tgz", + "integrity": "sha512-UGbutIr0nBALDHWW/HcXfyK6ZdmefC99Moo4qyTr89VNIkYZuDrW8Sw554FyFUamcFSdKOgDPk6ECSkofGIZjQ==", "dev": true } } diff --git a/UI/Web/package.json b/UI/Web/package.json index a089bf6f4..965f267d2 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -54,7 +54,7 @@ "@angular-devkit/build-angular": "~13.2.3", "@angular/cli": "^13.2.3", "@angular/compiler-cli": "~13.2.2", - "@playwright/test": "^1.20.2", + "@playwright/test": "^1.23.2", "@types/jest": "^27.4.0", "@types/node": "^17.0.17", "codelyzer": "^6.0.2", diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index 2ed39c00d..c9b773c65 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -22,7 +22,7 @@ export class AuthGuard implements CanActivate { this.toastr.error('You are not authorized to view this page.'); } localStorage.setItem(this.urlKey, window.location.pathname); - this.router.navigateByUrl('/libraries'); + this.router.navigateByUrl('/login'); return false; }) ); diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index da9d3022f..1fd31856d 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -34,6 +34,7 @@ export interface Preferences { theme: SiteTheme; globalPageLayoutMode: PageLayoutMode; blurUnreadSummaries: boolean; + promptForDownloadSize: boolean; } export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 5b89d51e6..5ceb31e50 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -28,7 +28,8 @@ export enum EVENTS { */ CleanupProgress = 'CleanupProgress', /** - * A subtype of NotificationProgress that represnts a user downloading a file or group of files + * A subtype of NotificationProgress that represnts a user downloading a file or group of files. + * Note: In v0.5.5, this is being replaced by an inbrowser experience. The message is changed and this will be moved to dashboard view once built */ DownloadProgress = 'DownloadProgress', /** diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index 56c3edca4..227fa1b88 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -5,7 +5,7 @@ import { ConfirmService } from 'src/app/shared/confirm.service'; import { SettingsService } from '../settings.service'; import { ServerSettings } from '../_models/server-settings'; import { catchError, finalize, shareReplay, take, takeWhile } from 'rxjs/operators'; -import { forkJoin, Observable, of } from 'rxjs'; +import { defer, forkJoin, Observable, of } from 'rxjs'; import { ServerService } from 'src/app/_services/server.service'; import { Job } from 'src/app/_models/job/job'; import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component'; @@ -55,10 +55,7 @@ export class ManageTasksSettingsComponent implements OnInit { { name: 'Download Logs', description: 'Compiles all log files into a zip and downloads it', - api: this.downloadService.downloadLogs().pipe( - takeWhile(val => { - return val.state != 'DONE'; - })), + api: defer(() => of(this.downloadService.download('logs', undefined))), successMessage: '' }, { diff --git a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts index 951224919..34587b685 100644 --- a/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts +++ b/UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts @@ -86,12 +86,11 @@ export class BookmarksComponent implements OnInit, OnDestroy { switch (action) { case Action.DownloadBookmark: - this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId))).pipe( - takeWhile(val => { - return val.state != 'DONE'; - })).subscribe(() => { + this.downloadService.download('bookmark', this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId)), (d) => { + if (!d) { this.bulkSelectionService.deselectAll(); - }); + } + }); break; case Action.Delete: if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for multiple series? This cannot be undone.')) { @@ -158,13 +157,18 @@ export class BookmarksComponent implements OnInit, OnDestroy { downloadBookmarks(series: Series) { this.downloadingSeries[series.id] = true; - this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { + this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id), (d) => { + if (!d) { this.downloadingSeries[series.id] = false; - })).subscribe(() => {/* No Operation */}); + } + }); + // this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe( + // takeWhile(val => { + // return val.state != 'DONE'; + // }), + // finalize(() => { + // this.downloadingSeries[series.id] = false; + // })).subscribe(() => {/* No Operation */}); } } diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index f71359f1b..708f3c61b 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -236,22 +236,12 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { this.toastr.info('Download is already in progress. Please wait.'); return; } - - this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'chapter'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; - this.download$ = this.downloadService.downloadChapter(chapter).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.download$ = null; - this.downloadInProgress = false; - this.cdRef.markForCheck(); - })); - this.download$.subscribe(() => {}); + this.downloadInProgress = true; + this.cdRef.markForCheck(); + this.downloadService.download('chapter', chapter, (d) => { + if (d) return; + this.downloadInProgress = false; this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 674b2b28e..09d97f48f 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -9,12 +9,8 @@

- - - - - {{download.progress}}% downloaded - + +
diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 4130abb46..1fc4f4c1a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -3,7 +3,7 @@ import { ToastrService } from 'ngx-toastr'; import { Observable, Subject } from 'rxjs'; import { filter, finalize, map, take, takeUntil, takeWhile } from 'rxjs/operators'; import { Download } from 'src/app/shared/_models/download'; -import { DownloadService } from 'src/app/shared/_services/download.service'; +import { DownloadEntityType, DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; @@ -101,9 +101,10 @@ export class CardItemComponent implements OnInit, OnDestroy { format: MangaFormat = MangaFormat.UNKNOWN; chapterTitle: string = ''; - - download$: Observable | null = null; - downloadInProgress: boolean = false; + /** + * This is the download we get from download service. + */ + download$: Observable | null = null; /** * Handles touch events for selection on mobile devices @@ -133,24 +134,24 @@ export class CardItemComponent implements OnInit, OnDestroy { public utilityService: UtilityService, private downloadService: DownloadService, private toastr: ToastrService, public bulkSelectionService: BulkSelectionService, private messageHub: MessageHubService, private accountService: AccountService, - private scrollService: ScrollService, private readonly changeDetectionRef: ChangeDetectorRef) {} + private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef) {} ngOnInit(): void { if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { this.suppressArchiveWarning = true; - this.changeDetectionRef.markForCheck(); + this.cdRef.markForCheck(); } if (this.suppressLibraryLink === false) { if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) { this.libraryId = (this.entity as Series).libraryId; - this.changeDetectionRef.markForCheck(); + this.cdRef.markForCheck(); } if (this.libraryId !== undefined && this.libraryId > 0) { this.libraryService.getLibraryName(this.libraryId).pipe(takeUntil(this.onDestroy)).subscribe(name => { this.libraryName = name; - this.changeDetectionRef.markForCheck(); + this.cdRef.markForCheck(); }); } } @@ -177,8 +178,18 @@ export class CardItemComponent implements OnInit, OnDestroy { if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return; this.read = updateEvent.pagesRead; - this.changeDetectionRef.detectChanges(); + this.cdRef.detectChanges(); }); + + this.download$ = this.downloadService.activeDownloads$.pipe(takeUntil(this.onDestroy), map((events) => { + if(this.utilityService.isSeries(this.entity)) return events.find(e => e.entityType === 'series' && e.subTitle === this.downloadService.downloadSubtitle('series', (this.entity as Series))) || null; + if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null; + if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null; + // Is PageBookmark[] + if(this.entity.hasOwnProperty('length')) return events.find(e => e.entityType === 'bookmark' && e.subTitle === this.downloadService.downloadSubtitle('bookmark', [(this.entity as PageBookmark)])) || null; + return null; + })); + } ngOnDestroy() { @@ -191,7 +202,7 @@ export class CardItemComponent implements OnInit, OnDestroy { if (!this.allowSelection) return; this.selectionInProgress = false; - this.changeDetectionRef.markForCheck(); + this.cdRef.markForCheck(); } @HostListener('touchstart', ['$event']) @@ -230,62 +241,21 @@ export class CardItemComponent implements OnInit, OnDestroy { performAction(action: ActionItem) { if (action.action == Action.Download) { - if (this.downloadInProgress === true) { - this.toastr.info('Download is already in progress. Please wait.'); - return; - } + + // if (this.download$ !== null) { + // this.toastr.info('Download is already in progress. Please wait.'); + // return; + // } if (this.utilityService.isVolume(this.entity)) { const volume = this.utilityService.asVolume(this.entity); - this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'volume'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; - this.changeDetectionRef.markForCheck(); - this.download$ = this.downloadService.downloadVolume(volume).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.download$ = null; - this.downloadInProgress = false; - this.changeDetectionRef.markForCheck(); - })); - }); + this.downloadService.download('volume', volume); } else if (this.utilityService.isChapter(this.entity)) { const chapter = this.utilityService.asChapter(this.entity); - this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'chapter'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; - this.changeDetectionRef.markForCheck(); - this.download$ = this.downloadService.downloadChapter(chapter).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.download$ = null; - this.downloadInProgress = false; - this.changeDetectionRef.markForCheck(); - })); - }); + this.downloadService.download('chapter', chapter); } else if (this.utilityService.isSeries(this.entity)) { const series = this.utilityService.asSeries(this.entity); - this.downloadService.downloadSeriesSize(series.id).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'series'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; - this.changeDetectionRef.markForCheck(); - this.download$ = this.downloadService.downloadSeries(series).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.download$ = null; - this.downloadInProgress = false; - this.changeDetectionRef.markForCheck(); - })); - }); + this.downloadService.download('series', series); } return; // Don't propagate the download from a card } @@ -307,6 +277,6 @@ export class CardItemComponent implements OnInit, OnDestroy { event.stopPropagation(); } this.selection.emit(this.selected); - this.changeDetectionRef.detectChanges(); + this.cdRef.detectChanges(); } } diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index 856f3fb6b..87c4f36ff 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -25,6 +25,7 @@ import { EntityInfoCardsComponent } from './entity-info-cards/entity-info-cards. import { ListItemComponent } from './list-item/list-item.component'; import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component'; +import { DownloadIndicatorComponent } from './download-indicator/download-indicator.component'; @@ -47,6 +48,7 @@ import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards. EntityInfoCardsComponent, ListItemComponent, SeriesInfoCardsComponent, + DownloadIndicatorComponent, ], imports: [ CommonModule, diff --git a/UI/Web/src/app/cards/download-indicator/download-indicator.component.html b/UI/Web/src/app/cards/download-indicator/download-indicator.component.html new file mode 100644 index 000000000..f6fda2baa --- /dev/null +++ b/UI/Web/src/app/cards/download-indicator/download-indicator.component.html @@ -0,0 +1,6 @@ + + + + {{download.progress}}% downloaded + + \ No newline at end of file diff --git a/UI/Web/src/app/cards/download-indicator/download-indicator.component.scss b/UI/Web/src/app/cards/download-indicator/download-indicator.component.scss new file mode 100644 index 000000000..705938b9e --- /dev/null +++ b/UI/Web/src/app/cards/download-indicator/download-indicator.component.scss @@ -0,0 +1,4 @@ +.download { + width: 80px; + height: 80px; +} \ No newline at end of file diff --git a/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts b/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts new file mode 100644 index 000000000..6af60643d --- /dev/null +++ b/UI/Web/src/app/cards/download-indicator/download-indicator.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Download } from 'src/app/shared/_models/download'; +import { DownloadEvent } from 'src/app/shared/_services/download.service'; + +@Component({ + selector: 'app-download-indicator', + templateUrl: './download-indicator.component.html', + styleUrls: ['./download-indicator.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DownloadIndicatorComponent implements OnInit { + + /** + * Observable that represents when the download completes + */ + @Input() download$!: Observable | null; + + constructor(private readonly cdRef: ChangeDetectorRef) { } + + ngOnInit(): void { + } + +} diff --git a/UI/Web/src/app/cards/list-item/list-item.component.html b/UI/Web/src/app/cards/list-item/list-item.component.html index f9b393030..bba132ad4 100644 --- a/UI/Web/src/app/cards/list-item/list-item.component.html +++ b/UI/Web/src/app/cards/list-item/list-item.component.html @@ -2,11 +2,8 @@
- - - - {{download.progress}}% downloaded - + +

diff --git a/UI/Web/src/app/cards/list-item/list-item.component.ts b/UI/Web/src/app/cards/list-item/list-item.component.ts index 734464ffa..72f3294d5 100644 --- a/UI/Web/src/app/cards/list-item/list-item.component.ts +++ b/UI/Web/src/app/cards/list-item/list-item.component.ts @@ -1,11 +1,12 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; -import { finalize, Observable, take, takeWhile } from 'rxjs'; +import { finalize, map, Observable, Subject, take, takeWhile, takeUntil } from 'rxjs'; import { Download } from 'src/app/shared/_models/download'; -import { DownloadService } from 'src/app/shared/_services/download.service'; +import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { LibraryType } from 'src/app/_models/library'; +import { Series } from 'src/app/_models/series'; import { RelationKind } from 'src/app/_models/series-detail/relation-kind'; import { Volume } from 'src/app/_models/volume'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; @@ -16,7 +17,7 @@ import { Action, ActionItem } from 'src/app/_services/action-factory.service'; styleUrls: ['./list-item.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class ListItemComponent implements OnInit { +export class ListItemComponent implements OnInit, OnDestroy { /** * Volume or Chapter to render @@ -74,9 +75,11 @@ export class ListItemComponent implements OnInit { isChapter: boolean = false; - download$: Observable | null = null; + download$: Observable | null = null; downloadInProgress: boolean = false; + private readonly onDestroy = new Subject(); + get Title() { if (this.isChapter) return (this.entity as Chapter).titleName; return ''; @@ -93,7 +96,20 @@ export class ListItemComponent implements OnInit { } else { this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || ''; } + this.cdRef.markForCheck(); + + + this.download$ = this.downloadService.activeDownloads$.pipe(takeUntil(this.onDestroy), map((events) => { + if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null; + if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null; + return null; + })); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); } performAction(action: ActionItem) { @@ -102,43 +118,18 @@ export class ListItemComponent implements OnInit { this.toastr.info('Download is already in progress. Please wait.'); return; } - + + const statusUpdate = (d: Download | undefined) => { + if (d) return; + this.downloadInProgress = false; + }; + if (this.utilityService.isVolume(this.entity)) { const volume = this.utilityService.asVolume(this.entity); - this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'volume'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; - this.cdRef.markForCheck(); - this.download$ = this.downloadService.downloadVolume(volume).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.download$ = null; - this.downloadInProgress = false; - this.cdRef.markForCheck(); - })); - this.cdRef.markForCheck(); - }); + this.downloadService.download('volume', volume, statusUpdate); } else if (this.utilityService.isChapter(this.entity)) { const chapter = this.utilityService.asChapter(this.entity); - this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'chapter'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; - this.cdRef.markForCheck(); - this.download$ = this.downloadService.downloadChapter(chapter).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.download$ = null; - this.downloadInProgress = false; - this.cdRef.markForCheck(); - })); - this.cdRef.markForCheck(); - }); + this.downloadService.download('chapter', chapter, statusUpdate); } return; // Don't propagate the download from a card } diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 98dc9a306..e86aff9c0 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -668,7 +668,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.bookmarkMode) { this.readerService.getBookmarkInfo(this.seriesId).subscribe(bookmarkInfo => { this.setPageNum(0); - this.title = bookmarkInfo.seriesName + ' Bookmarks'; + this.title = bookmarkInfo.seriesName; + this.subtitle = 'Bookmarks'; this.libraryType = bookmarkInfo.libraryType; this.maxPages = bookmarkInfo.pages; @@ -677,6 +678,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything. this.pageOptions = newOptions; this.inSetup = false; + this.cdRef.markForCheck(); const images = []; for (let i = 0; i < PREFETCH_PAGES + 2; i++) { @@ -684,6 +686,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.cachedImages = new CircularArray(images, 0); + this.goToPageEvent = new BehaviorSubject(this.pageNum); this.render(); }); @@ -1013,7 +1016,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pageAmount = pageAmount; if (this.readerMode !== ReaderMode.Webtoon) { - this.canvasImage.src = this.getPageUrl(this.pageNum); + this.setCanvasImage(); } } @@ -1058,7 +1061,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.isNoSplit() || notInSplit) { this.setPageNum(this.pageNum - pageAmount); if (this.readerMode !== ReaderMode.Webtoon) { - this.canvasImage.src = this.getPageUrl(this.pageNum); + this.setCanvasImage(); } } @@ -1067,15 +1070,25 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + /** + * Sets canvasImage's src to current page, but first attempts to use a pre-fetched image + */ + setCanvasImage() { + const img = this.cachedImages.arr.find(img => this.readerService.imageUrlToPageNum(img.src) === this.pageNum); + if (img) { + this.canvasImage = img; + } else { + this.canvasImage.src = this.getPageUrl(this.pageNum); + } + this.cdRef.markForCheck(); + } + loadNextChapter() { - if (this.nextPageDisabled) { return; } - if (this.nextChapterDisabled) { + if (this.nextPageDisabled || this.nextChapterDisabled || this.bookmarkMode) { this.toastr.info('No Next Chapter'); return; } - this.isLoading = true; - this.cdRef.markForCheck(); if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) { this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.nextChapterId = chapterId; @@ -1087,13 +1100,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } loadPrevChapter() { - if (this.prevPageDisabled) { return; } - if (this.prevChapterDisabled) { + if (this.prevPageDisabled || this.prevChapterDisabled || this.bookmarkMode) { this.toastr.info('No Previous Chapter'); return; } - this.isLoading = true; - this.cdRef.markForCheck(); this.continuousChaptersStack.pop(); const prevChapter = this.continuousChaptersStack.peek(); if (prevChapter != this.chapterId) { @@ -1104,6 +1114,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + console.log('prevChapterId', this.prevChapterId); + if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) { this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.prevChapterId = chapterId; @@ -1115,7 +1127,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } loadChapter(chapterId: number, direction: 'Next' | 'Prev') { - if (chapterId >= 0) { + console.log('chapterId: ', chapterId); + if (chapterId > 0) { + this.isLoading = true; + this.cdRef.markForCheck(); + this.chapterId = chapterId; this.continuousChaptersStack.push(chapterId); // Load chapter Id onto route but don't reload @@ -1238,7 +1254,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.firstPageRendered = true; this.generalSettingsForm.get('fittingOption')?.setValue(newScale, {emitEvent: false}); - //this.cdRef.markForCheck(); } /** @@ -1315,7 +1330,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.canvasImageAheadBy2.src = ''; this.isLoose = (this.pageAmount === 1 ? true : false); - this.canvasImage.src = this.getPageUrl(this.pageNum); + this.setCanvasImage(); + if (this.layoutMode !== LayoutMode.Single) { this.canvasImageNext.src = this.getPageUrl(this.pageNum + 1); // This needs to be capped at maxPages !this.isLastImage() diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index dfa4b3a00..8138e8152 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -1,8 +1,8 @@
-
- -
+
+ +
diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index b586493e6..0ebeade4e 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -104,7 +104,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); }); } - + this.filter = this.seriesService.createSeriesFilter(); this.readProgressGroup = new FormGroup({ read: new FormControl({value: this.filter.readStatus.read, disabled: this.filterSettings.readProgressDisabled}, []), @@ -222,10 +222,6 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { ]).subscribe(results => { this.fullyLoaded = true; this.resetTypeaheads.next(false); // Pass false to ensure we reset to the preset and not to an empty typeahead - if (this.filterSettings.openByDefault) { - this.filteringCollapsed = false; - this.toggleService.set(!this.filteringCollapsed); - } this.cdRef.markForCheck(); this.apply(); }); @@ -502,21 +498,26 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { updateFormatFilters(formats: FilterItem[]) { this.filter.formats = formats.map(item => item.value) || []; + this.formatSettings.savedData = formats; } updateLibraryFilters(libraries: Library[]) { this.filter.libraries = libraries.map(item => item.id) || []; + this.librarySettings.savedData = libraries; } updateGenreFilters(genres: Genre[]) { this.filter.genres = genres.map(item => item.id) || []; + this.genreSettings.savedData = genres; } updateTagFilters(tags: Tag[]) { this.filter.tags = tags.map(item => item.id) || []; + this.tagsSettings.savedData = tags; } updatePersonFilters(persons: Person[], role: PersonRole) { + this.peopleSettings[role].savedData = persons; switch (role) { case PersonRole.CoverArtist: this.filter.coverArtist = persons.map(p => p.id); @@ -553,6 +554,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { updateCollectionFilters(tags: CollectionTag[]) { this.filter.collectionTags = tags.map(item => item.id) || []; + this.collectionSettings.savedData = tags; } updateRating(rating: any) { @@ -562,14 +564,17 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { updateAgeRating(ratingDtos: AgeRatingDto[]) { this.filter.ageRating = ratingDtos.map(item => item.value) || []; + this.ageRatingSettings.savedData = ratingDtos; } updatePublicationStatus(dtos: PublicationStatusDto[]) { this.filter.publicationStatus = dtos.map(item => item.value) || []; + this.publicationStatusSettings.savedData = dtos; } updateLanguages(languages: Language[]) { this.filter.languages = languages.map(item => item.isoCode) || []; + this.languageSettings.savedData = languages; } updateReadStatus(status: string) { diff --git a/UI/Web/src/app/nav/events-widget/events-widget.component.html b/UI/Web/src/app/nav/events-widget/events-widget.component.html index e554623d2..5675eba01 100644 --- a/UI/Web/src/app/nav/events-widget/events-widget.component.html +++ b/UI/Web/src/app/nav/events-widget/events-widget.component.html @@ -1,10 +1,11 @@ - - - + + + + @@ -45,6 +46,18 @@
+
  • +
    + + + + 10% downloaded + + + Downloading {{'series' | sentenceCase}} +
    +
    PDFs
    +
  • @@ -86,6 +99,23 @@ + + + +
  • +
    Downloading {{download.entityType | sentenceCase}}
    +
    {{download.subTitle}}
    +
    +
    {{download.progress}}%
    +
    +
    +
    +
    +
  • +
    +
    + + diff --git a/UI/Web/src/app/nav/events-widget/events-widget.component.scss b/UI/Web/src/app/nav/events-widget/events-widget.component.scss index 07cb2a975..95a7cc214 100644 --- a/UI/Web/src/app/nav/events-widget/events-widget.component.scss +++ b/UI/Web/src/app/nav/events-widget/events-widget.component.scss @@ -55,6 +55,11 @@ } +// .download { +// width: 80px; +// height: 80px; +// } + .btn-icon { diff --git a/UI/Web/src/app/nav/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/events-widget/events-widget.component.ts index 80787bb9a..4c6a76148 100644 --- a/UI/Web/src/app/nav/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/events-widget/events-widget.component.ts @@ -5,6 +5,7 @@ import { map, shareReplay, takeUntil } from 'rxjs/operators'; import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component'; +import { DownloadService } from 'src/app/shared/_services/download.service'; import { ErrorEvent } from 'src/app/_models/events/error-event'; import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event'; import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event'; @@ -50,7 +51,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { constructor(public messageHub: MessageHubService, private modalService: NgbModal, private accountService: AccountService, private confirmService: ConfirmService, - private readonly cdRef: ChangeDetectorRef) { } + private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) { } ngOnDestroy(): void { this.onDestroy.next(); diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index bb31c98cd..a9a301ec9 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -3,8 +3,8 @@ import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { forkJoin, Subject } from 'rxjs'; -import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators'; +import { forkJoin, Subject, tap } from 'rxjs'; +import { filter, finalize, switchMap, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component'; import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config'; @@ -39,6 +39,7 @@ import { FormControl, FormGroup } from '@angular/forms'; import { PageLayoutMode } from '../_models/page-layout-mode'; import { DOCUMENT } from '@angular/common'; import { User } from '../_models/user'; +import { Download } from '../shared/_models/download'; interface RelatedSeris { series: Series; @@ -725,19 +726,13 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { } downloadSeries() { - this.downloadService.downloadSeriesSize(this.seriesId).pipe(take(1)).subscribe(async (size) => { - const wantToDownload = await this.downloadService.confirmSize(size, 'series'); - if (!wantToDownload) { return; } - this.downloadInProgress = true; + this.downloadService.download('series', this.series, (d) => { + if (d) { + this.downloadInProgress = true; + } else { + this.downloadInProgress = false; + } this.changeDetectionRef.markForCheck(); - this.downloadService.downloadSeries(this.series).pipe( - takeWhile(val => { - return val.state != 'DONE'; - }), - finalize(() => { - this.downloadInProgress = false; - this.changeDetectionRef.markForCheck(); - })).subscribe(() => {/* No Operation */});; }); } diff --git a/UI/Web/src/app/shared/_models/download.ts b/UI/Web/src/app/shared/_models/download.ts index 7c26eafc9..3eb3eeff0 100644 --- a/UI/Web/src/app/shared/_models/download.ts +++ b/UI/Web/src/app/shared/_models/download.ts @@ -20,12 +20,17 @@ import { event.type === HttpEventType.UploadProgress ); } - + +/** + * Encapsulates an inprogress download of a Blob with progress reporting activated + */ export interface Download { content: Blob | null; progress: number; state: "PENDING" | "IN_PROGRESS" | "DONE"; filename?: string; + loaded?: number; + total?: number } export function download(saver?: (b: Blob, filename: string) => void): (source: Observable>) => Observable { @@ -38,7 +43,9 @@ export function download(saver?: (b: Blob, filename: string) => void): (source: ? Math.round((100 * event.loaded) / event.total) : previous.progress, state: 'IN_PROGRESS', - content: null + content: null, + loaded: event.loaded, + total: event.total } } if (isHttpResponse(event)) { @@ -49,7 +56,7 @@ export function download(saver?: (b: Blob, filename: string) => void): (source: progress: 100, state: 'DONE', content: event.body, - filename: getFilename(event.headers, '') + filename: getFilename(event.headers, ''), } } return previous; diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 7bb10b697..f0d6ffcd1 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse, HttpEventType } from '@angular/common/http'; import { Inject, Injectable } from '@angular/core'; import { Series } from 'src/app/_models/series'; import { environment } from 'src/environments/environment'; @@ -6,14 +6,39 @@ import { ConfirmService } from '../confirm.service'; import { Chapter } from 'src/app/_models/chapter'; import { Volume } from 'src/app/_models/volume'; import { ToastrService } from 'ngx-toastr'; -import { asyncScheduler, Observable } from 'rxjs'; +import { asyncScheduler, BehaviorSubject, Observable, tap, finalize, of, filter } from 'rxjs'; import { SAVER, Saver } from '../_providers/saver.provider'; import { download, Download } from '../_models/download'; import { PageBookmark } from 'src/app/_models/page-bookmark'; -import { catchError, throttleTime } from 'rxjs/operators'; +import { switchMap, takeWhile, throttleTime } from 'rxjs/operators'; +import { AccountService } from 'src/app/_services/account.service'; export const DEBOUNCE_TIME = 100; +export interface DownloadEvent { + /** + * Type of entity being downloaded + */ + entityType: DownloadEntityType; + /** + * What to show user. For example, for Series, we might show series name. + */ + subTitle: string; + /** + * Progress of the download itself + */ + progress: number; +} + +/** + * Valid entity types for downloading + */ +export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' | 'logs'; +/** + * Valid entities for downloading. Undefined exclusively for logs. + */ +export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined; + @Injectable({ providedIn: 'root' }) @@ -25,67 +50,207 @@ export class DownloadService { */ public SIZE_WARNING = 104_857_600; - constructor(private httpClient: HttpClient, private confirmService: ConfirmService, private toastr: ToastrService, @Inject(SAVER) private save: Saver) { } + private downloadsSource: BehaviorSubject = new BehaviorSubject([]); + public activeDownloads$ = this.downloadsSource.asObservable(); + + constructor(private httpClient: HttpClient, private confirmService: ConfirmService, + private toastr: ToastrService, @Inject(SAVER) private save: Saver, + private accountService: AccountService) { } + + /** + * Returns the entity subtitle (for the event widget) for a given entity + * @param downloadEntityType + * @param downloadEntity + * @returns + */ + downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) { + switch (downloadEntityType) { + case 'series': + return (downloadEntity as Series).name; + case 'volume': + return (downloadEntity as Volume).number + ''; + case 'chapter': + return (downloadEntity as Chapter).number; + case 'bookmark': + return ''; + case 'logs': + return ''; + } + } + + /** + * Downloads the entity to the user's system. This handles everything around downloads. This will prompt the user based on size checks and UserPreferences.PromptForDownload. + * This will perform the download at a global level, if you need a handle to the download in question, use downloadService.activeDownloads$ and perform a filter on it. + * @param entityType + * @param entity + * @param callback Optional callback. Returns the download or undefined (if the download is complete). + */ + download(entityType: DownloadEntityType, entity: DownloadEntity, callback?: (d: Download | undefined) => void) { + let sizeCheckCall: Observable; + let downloadCall: Observable; + switch (entityType) { + case 'series': + sizeCheckCall = this.downloadSeriesSize((entity as Series).id); + downloadCall = this.downloadSeries(entity as Series); + break; + case 'volume': + sizeCheckCall = this.downloadVolumeSize((entity as Volume).id); + downloadCall = this.downloadVolume(entity as Volume); + break; + case 'chapter': + sizeCheckCall = this.downloadChapterSize((entity as Chapter).id); + downloadCall = this.downloadChapter(entity as Chapter); + break; + case 'bookmark': + sizeCheckCall = of(0); + downloadCall = this.downloadBookmarks(entity as PageBookmark[]); + break; + case 'logs': + sizeCheckCall = of(0); + downloadCall = this.downloadLogs(); + break; + default: + return; + } - public downloadSeriesSize(seriesId: number) { + this.accountService.currentUser$.pipe(switchMap(user => { + if (user && user.preferences.promptForDownloadSize) { + return sizeCheckCall; + } + return of(0); + }), switchMap(async (size) => { + return await this.confirmSize(size, entityType); + }) + ).pipe(filter(wantsToDownload => wantsToDownload), switchMap(() => { + return downloadCall.pipe( + tap((d) => { + if (callback) callback(d); + }), + takeWhile((val: Download) => { + return val.state != 'DONE'; + }), + finalize(() => { + if (callback) callback(undefined); + })) + })).subscribe(() => {}); + } + + private downloadSeriesSize(seriesId: number) { return this.httpClient.get(this.baseUrl + 'download/series-size?seriesId=' + seriesId); } - public downloadVolumeSize(volumeId: number) { + private downloadVolumeSize(volumeId: number) { return this.httpClient.get(this.baseUrl + 'download/volume-size?volumeId=' + volumeId); } - public downloadChapterSize(chapterId: number) { + private downloadChapterSize(chapterId: number) { return this.httpClient.get(this.baseUrl + 'download/chapter-size?chapterId=' + chapterId); } - downloadLogs() { - return this.httpClient.get(this.baseUrl + 'server/logs', - {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { - this.save(blob, filename) - })); - + private downloadLogs() { + const downloadType = 'logs'; + const subtitle = this.downloadSubtitle(downloadType, undefined); + return this.httpClient.get(this.baseUrl + 'server/logs', + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, filename); + }), + tap((d) => this.updateDownloadState(d, downloadType, subtitle)), + finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); } - downloadSeries(series: Series) { + private downloadSeries(series: Series) { + const downloadType = 'series'; + const subtitle = this.downloadSubtitle(downloadType, series); return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id, {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { - this.save(blob, filename) - })); + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, filename); + }), + tap((d) => this.updateDownloadState(d, downloadType, subtitle)), + finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); } - downloadChapter(chapter: Chapter) { + private finalizeDownloadState(entityType: DownloadEntityType, entitySubtitle: string) { + let values = this.downloadsSource.getValue(); + values = values.filter(v => !(v.entityType === entityType && v.subTitle === entitySubtitle)); + this.downloadsSource.next(values); + } + + private updateDownloadState(d: Download, entityType: DownloadEntityType, entitySubtitle: string) { + let values = this.downloadsSource.getValue(); + if (d.state === 'PENDING') { + const index = values.findIndex(v => v.entityType === entityType && v.subTitle === entitySubtitle); + if (index >= 0) return; // Don't let us duplicate add + values.push({entityType: entityType, subTitle: entitySubtitle, progress: 0}); + } else if (d.state === 'IN_PROGRESS') { + const index = values.findIndex(v => v.entityType === entityType && v.subTitle === entitySubtitle); + if (index >= 0) { + values[index].progress = d.progress; + } + } else if (d.state === 'DONE') { + values = values.filter(v => !(v.entityType === entityType && v.subTitle === entitySubtitle)); + } + this.downloadsSource.next(values); + + } + + private downloadChapter(chapter: Chapter) { + const downloadType = 'chapter'; + const subtitle = this.downloadSubtitle(downloadType, chapter); return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id, - {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { - this.save(blob, filename) - })); + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, filename); + }), + tap((d) => this.updateDownloadState(d, downloadType, subtitle)), + finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); } - downloadVolume(volume: Volume): Observable { + private downloadVolume(volume: Volume): Observable { + const downloadType = 'volume'; + const subtitle = this.downloadSubtitle(downloadType, volume); return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id, {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { - this.save(blob, filename) - })); + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, filename); + }), + tap((d) => this.updateDownloadState(d, downloadType, subtitle)), + finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); } - async confirmSize(size: number, entityType: 'volume' | 'chapter' | 'series' | 'reading list') { + private async confirmSize(size: number, entityType: DownloadEntityType) { return (size < this.SIZE_WARNING || await this.confirmService.confirm('The ' + entityType + ' is ' + this.humanFileSize(size) + '. Are you sure you want to continue?')); } - downloadBookmarks(bookmarks: PageBookmark[]) { - return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, - {observe: 'events', responseType: 'blob', reportProgress: true} - ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { - this.save(blob, filename) - })); - } + private downloadBookmarks(bookmarks: PageBookmark[]) { + const downloadType = 'bookmark'; + const subtitle = this.downloadSubtitle(downloadType, bookmarks); - + return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, filename); + }), + tap((d) => this.updateDownloadState(d, downloadType, subtitle)), + finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); + } /** * Format bytes as human-readable text. diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html index f175e20bd..b79c748e7 100644 --- a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html @@ -1,8 +1,8 @@ -
    - +
    +
    -
    +
    - +
    @@ -45,6 +45,17 @@ Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers)
    +
    +
    +
    + + +
    + + Prompt when a download exceedes 100MB in size + Prompt when a download exceedes 100MB in size +
    +
    diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index cc5a78aa4..4d7879933 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -132,6 +132,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, [])); this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, [])); this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, [])); + this.settingsForm.addControl('promptForDownloadSize', new FormControl(this.user.preferences.promptForDownloadSize, [])); this.cdRef.markForCheck(); }); @@ -181,6 +182,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode); this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode); this.settingsForm.get('blurUnreadSummaries')?.setValue(this.user.preferences.blurUnreadSummaries); + this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize); this.cdRef.markForCheck(); } @@ -215,6 +217,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode, globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10), blurUnreadSummaries: modelSettings.blurUnreadSummaries, + promptForDownloadSize: modelSettings.promptForDownloadSize, }; this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {