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) => {