mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Better Caching & Global Downloads (#1372)
* Fixed a bug where cache TTL was using a field which always was 0. * Updated Scan Series task (from UI) to always re-calculate what's on file and not rely on last update. This leads to more reliable results, despite extra overhead. * Added image range processing on images for the reader, for slower networks or large files * On manga (single) try to use prefetched image, rather than re-requesting an image on pagination * Reduced some more latency when rendering first page of next chapter via continuous reading mode * Fixed a bug where metadata filter, after updating a typeahead, collapsing filter area then re-opening, the filter would still be applied, but the typeahead wouldn't show the modification. * Coded an idea around download reporting, commiting for history, might not go with it. * Refactored the download indicator into it's own component. Cleaning up some code for download within card component * Another throw away commit. Put in some temp code, not working but not sure if I'm ditching entirely. * Updated download service to enable range processing (so downloads can resume) and to reduce re-zipping if we've just downloaded something. * Refactored events widget download indicator to the correct design. I will be moving forward with this new functionality. * Added Required fields to ProgressDTO * Cleaned up the event widget and updated existing download progress to indicate preparing the download, rather than the download itself. * Updated dependencies for security alerts * Refactored all download code to be streamlined and globally handled * Updated ScanSeries to find the highest folder path before library, not just within the files. This could lead to scan series missing files due to nested folders on same parent level. * Updated the caching code to use a builtin annotation. Images are now caching correctly. * Fixed a bad redirect on an auth guard * Tweaked how long we allow cache for, as the cover update now doesn't work well. * Fixed a bug on downloading bookmarks from multiple series, where it would just choose the first series id for the temp file. * Added an extra check for downloading bookmarks * UI Security updates, Fixed a bug on bookmark reader, the reader on last page would throw some errors and not show No Next Chapter toast. * After scan, clear temp * Code smells
This commit is contained in:
parent
45bbf422be
commit
af4f35da5b
@ -12,7 +12,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
|
||||
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.1" />
|
||||
<PackageReference Include="NSubstitute" Version="4.3.0" />
|
||||
<PackageReference Include="NSubstitute" Version="4.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
@ -7,10 +7,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="NSubstitute" Version="4.3.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.0.15" />
|
||||
<PackageReference Include="NSubstitute" Version="4.4.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.0.21" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
@ -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]
|
||||
|
@ -50,11 +50,11 @@
|
||||
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@ -66,13 +66,13 @@
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.1.5" />
|
||||
<PackageReference Include="SharpCompress" Version="0.32.1" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.41.0.50478">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.20.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.21.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.18" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.1.2" />
|
||||
</ItemGroup>
|
||||
|
@ -94,6 +94,7 @@ namespace API.Controllers
|
||||
/// <param name="file"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{chapterId}/book-resources")]
|
||||
[ResponseCache(Duration = 60 * 1, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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}");
|
||||
}
|
||||
|
@ -83,7 +83,7 @@ namespace API.Controllers
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Downloads all chapters within a volume.
|
||||
/// Downloads all chapters within a volume. If the chapters are multiple zips, they will all be zipped up.
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
@ -112,12 +112,17 @@ namespace API.Controllers
|
||||
return await _downloadService.HasDownloadPermission(user);
|
||||
}
|
||||
|
||||
private async Task<ActionResult> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
private ActionResult GetFirstFileDownload(IEnumerable<MangaFile> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the zip for a single chapter. If the chapter contains multiple files, they will be zipped.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("chapter")]
|
||||
public async Task<ActionResult> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads all bookmarks in a zip for
|
||||
/// </summary>
|
||||
/// <param name="downloadBookmarkDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("bookmarks")]
|
||||
public async Task<ActionResult> 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService)
|
||||
@ -30,13 +30,13 @@ namespace API.Controllers
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("chapter-cover")]
|
||||
[ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("volume-cover")]
|
||||
[ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// </summary>
|
||||
/// <param name="seriesId">Id of Series</param>
|
||||
/// <returns></returns>
|
||||
[ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
[HttpGet("series-cover")]
|
||||
public async Task<ActionResult> 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
|
||||
/// <param name="collectionTagId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("collection-cover")]
|
||||
[ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// <param name="readingListId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("readinglist-cover")]
|
||||
[ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("bookmark")]
|
||||
[ResponseCache(Duration = ImageCacheSeconds, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// <returns></returns>
|
||||
[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));
|
||||
}
|
||||
}
|
||||
|
@ -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<ActionResult> 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);
|
||||
}
|
||||
|
||||
|
@ -47,6 +47,7 @@ namespace API.Controllers
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("pdf")]
|
||||
[ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// <param name="page"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("image")]
|
||||
[ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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
|
||||
/// <remarks>We must use api key as bookmarks could be leaked to other users via the API</remarks>
|
||||
/// <returns></returns>
|
||||
[HttpGet("bookmark-image")]
|
||||
[ResponseCache(Duration = 60 * 10, Location = ResponseCacheLocation.Client, NoStore = false)]
|
||||
public async Task<ActionResult> 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)
|
||||
|
@ -253,30 +253,50 @@ namespace API.Controllers
|
||||
return Ok(pagedList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a Cover Image Generation task
|
||||
/// </summary>
|
||||
/// <param name="refreshSeriesDto"></param>
|
||||
/// <returns></returns>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scan a series and force each file to be updated. This should be invoked via the User, hence why we force.
|
||||
/// </summary>
|
||||
/// <param name="refreshSeriesDto"></param>
|
||||
/// <returns></returns>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run a file analysis on the series.
|
||||
/// </summary>
|
||||
/// <param name="refreshSeriesDto"></param>
|
||||
/// <returns></returns>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns metadata for a given series
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("metadata")]
|
||||
public async Task<ActionResult<SeriesMetadataDto>> GetSeriesMetadata(int seriesId)
|
||||
{
|
||||
@ -284,6 +304,11 @@ namespace API.Controllers
|
||||
return Ok(metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update series metadata
|
||||
/// </summary>
|
||||
/// <param name="updateSeriesMetadataDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("metadata")]
|
||||
public async Task<ActionResult> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
|
||||
{
|
||||
@ -331,6 +356,11 @@ namespace API.Controllers
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the age rating for the <see cref="AgeRating"/> enum value
|
||||
/// </summary>
|
||||
/// <param name="ageRating"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("age-rating")]
|
||||
public ActionResult<string> GetAgeRating(int ageRating)
|
||||
{
|
||||
@ -339,6 +369,12 @@ namespace API.Controllers
|
||||
return Ok(val.ToDescription());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a special DTO for Series Detail page.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Do not rely on this API externally. May change without hesitation. </remarks>
|
||||
[HttpGet("series-detail")]
|
||||
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
|
||||
{
|
||||
@ -386,16 +422,24 @@ namespace API.Controllers
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all related series against the passed series Id
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("all-related")]
|
||||
public async Task<ActionResult<RelatedSeriesDto>> 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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy="RequireAdminRole")]
|
||||
[HttpPost("update-related")]
|
||||
public async Task<ActionResult> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)
|
||||
@ -421,7 +465,8 @@ namespace API.Controllers
|
||||
return BadRequest("There was an issue updating relationships");
|
||||
}
|
||||
|
||||
private void UpdateRelationForKind(IList<int> dtoTargetSeriesIds, IEnumerable<SeriesRelation> adaptations, Series series, RelationKind kind)
|
||||
// TODO: Move this to a Service and Unit Test it
|
||||
private void UpdateRelationForKind(ICollection<int> dtoTargetSeriesIds, IEnumerable<SeriesRelation> adaptations, Series series, RelationKind kind)
|
||||
{
|
||||
foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId)))
|
||||
{
|
||||
|
@ -112,13 +112,13 @@ namespace API.Controllers
|
||||
}
|
||||
|
||||
[HttpGet("logs")]
|
||||
public async Task<ActionResult> 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)
|
||||
{
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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<BookmarkDto> Bookmarks { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
/// <summary>
|
||||
/// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
|
||||
|
@ -1,8 +1,22 @@
|
||||
namespace API.DTOs
|
||||
{
|
||||
/// <summary>
|
||||
/// Used for running some task against a Series.
|
||||
/// </summary>
|
||||
public class RefreshSeriesDto
|
||||
{
|
||||
public int LibraryId { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
/// <summary>
|
||||
/// Library Id series belongs to
|
||||
/// </summary>
|
||||
public int LibraryId { get; init; }
|
||||
/// <summary>
|
||||
/// Series Id
|
||||
/// </summary>
|
||||
public int SeriesId { get; init; }
|
||||
/// <summary>
|
||||
/// Should the task force opening/re-calculation.
|
||||
/// </summary>
|
||||
/// <remarks>This is expensive if true. Defaults to true.</remarks>
|
||||
public bool ForceUpdate { get; init; } = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
/// <summary>
|
||||
/// Manga Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ReadingDirection ReadingDirection { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How should the image be scaled to screen
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ScalingOption ScalingOption { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Which side of a split image should we show first
|
||||
/// </summary>
|
||||
[Required]
|
||||
public PageSplitOption PageSplitOption { get; set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </example>
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ReaderMode ReaderMode { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How many pages to display in the reader at once
|
||||
/// </summary>
|
||||
[Required]
|
||||
public LayoutMode LayoutMode { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Background color of the reader
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string BackgroundColor { get; set; } = "#000000";
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool AutoCloseMenu { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Should the background color be dark
|
||||
/// </summary>
|
||||
public bool BookReaderDarkMode { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override extra Margin
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int BookReaderMargin { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override line-height
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int BookReaderLineSpacing { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override font size
|
||||
/// </summary>
|
||||
[Required]
|
||||
public int BookReaderFontSize { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string BookReaderFontFamily { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: Allows tapping on side of screens to paginate
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool BookReaderTapToPaginate { get; set; }
|
||||
/// <summary>
|
||||
/// Book Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; }
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: The UI theme the user should use.
|
||||
/// </summary>
|
||||
/// <remarks>Should default to Dark</remarks>
|
||||
[Required]
|
||||
public SiteTheme Theme { get; set; }
|
||||
[Required]
|
||||
public string BookReaderThemeName { get; set; }
|
||||
[Required]
|
||||
public BookPageLayoutMode BookReaderLayoutMode { get; set; }
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
[Required]
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Global Site Option: If the UI should layout items as Cards or List items
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Cards</remarks>
|
||||
[Required]
|
||||
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
[Required]
|
||||
public bool BlurUnreadSummaries { get; set; } = false;
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool PromptForDownloadSize { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
1576
API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.Designer.cs
generated
Normal file
1576
API/Data/Migrations/20220712161611_PromptForDownloadSizeUserOption.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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<bool>(
|
||||
name: "PromptForDownloadSize",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PromptForDownloadSize",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
@ -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<int>("PageSplitOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PromptForDownloadSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReaderMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
@ -98,6 +98,10 @@ namespace API.Entities
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
public bool BlurUnreadSummaries { get; set; } = false;
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB.
|
||||
/// </summary>
|
||||
public bool PromptForDownloadSize { get; set; } = true;
|
||||
|
||||
public AppUser AppUser { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
|
@ -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
|
||||
/// <param name="content">If byte[] is null or empty, will only add cache-control</param>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates SHA256 hash for a cover image filename and sets as ETag. Ensures Cache-Control: private header is added.
|
||||
/// </summary>
|
||||
/// <param name="response"></param>
|
||||
/// <param name="filename"></param>
|
||||
/// <param name="maxAge">Maximum amount of seconds to set for Cache-Control</param>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -16,9 +16,9 @@ public static class SeriesHelper
|
||||
/// <returns></returns>
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -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<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder);
|
||||
/// <summary>
|
||||
/// Creates a zip file form the listed files and outputs to the temp folder.
|
||||
/// </summary>
|
||||
/// <param name="files">List of files to be zipped up. Should be full file paths.</param>
|
||||
/// <param name="tempFolder">Temp folder name to use for preparing the files. Will be created and deleted</param>
|
||||
/// <returns>Path to the temp zip</returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
string CreateZipForDownload(IEnumerable<string> files, string tempFolder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -267,15 +272,22 @@ namespace API.Services
|
||||
/// </summary>
|
||||
/// <param name="files">List of files to be zipped up. Should be full file paths.</param>
|
||||
/// <param name="tempFolder">Temp folder name to use for preparing the files. Will be created and deleted</param>
|
||||
/// <returns>All bytes for the given file in a Tuple</returns>
|
||||
/// <returns>Path to the temp zip</returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
public async Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder)
|
||||
public string CreateZipForDownload(IEnumerable<string> 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;
|
||||
}
|
||||
|
||||
|
||||
|
@ -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<MangaFile> files);
|
||||
Tuple<string, string, string> GetFirstFileDownload(IEnumerable<MangaFile> files);
|
||||
string GetContentTypeFromFile(string filepath);
|
||||
Task<bool> HasDownloadPermission(AppUser user);
|
||||
}
|
||||
public class DownloadService : IDownloadService
|
||||
{
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider();
|
||||
|
||||
public DownloadService(IDirectoryService directoryService, UserManager<AppUser> userManager)
|
||||
public DownloadService(UserManager<AppUser> userManager)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
@ -32,10 +31,10 @@ public class DownloadService : IDownloadService
|
||||
/// </summary>
|
||||
/// <param name="files"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable<MangaFile> files)
|
||||
public Tuple<string, string, string> GetFirstFileDownload(IEnumerable<MangaFile> 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)
|
||||
|
@ -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, List<ParserInfo>> 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<Tuple<int, long, Dictionary<ParsedSeries, List<ParserInfo>>>> ScanFiles(Library library, IEnumerable<string> dirs)
|
||||
|
@ -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
|
||||
|
@ -12,7 +12,7 @@
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.41.0.50478">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
292
UI/Web/package-lock.json
generated
292
UI/Web/package-lock.json
generated
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
})
|
||||
);
|
||||
|
@ -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}];
|
||||
|
@ -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',
|
||||
/**
|
||||
|
@ -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: ''
|
||||
},
|
||||
{
|
||||
|
@ -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 */});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -9,12 +9,8 @@
|
||||
|
||||
<div class="progress-banner">
|
||||
<p *ngIf="read < total && total > 0 && read !== total"><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
</span>
|
||||
</div>
|
||||
<div class="error-banner" *ngIf="total === 0 && !suppressArchiveWarning">
|
||||
|
@ -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<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
*/
|
||||
download$: Observable<DownloadEvent | null> | 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<any>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -0,0 +1,6 @@
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
</span>
|
@ -0,0 +1,4 @@
|
||||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
@ -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<Download | DownloadEvent | null> | null;
|
||||
|
||||
constructor(private readonly cdRef: ChangeDetectorRef) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
}
|
@ -2,11 +2,8 @@
|
||||
<div class="pe-2">
|
||||
<app-image [imageUrl]="imageUrl" [height]="imageHeight" maxHeight="200px" [width]="imageWidth"></app-image>
|
||||
<div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div>
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
</span>
|
||||
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
|
||||
|
@ -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<Download> | null = null;
|
||||
download$: Observable<DownloadEvent | null> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
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<any>) {
|
||||
@ -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
|
||||
}
|
||||
|
@ -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<HTMLImageElement>(images, 0);
|
||||
this.goToPageEvent = new BehaviorSubject<number>(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()
|
||||
|
@ -1,8 +1,8 @@
|
||||
<ng-container *ngIf="toggleService.toggleState$ | async as isOpen">
|
||||
<div class="phone-hidden">
|
||||
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
<div #collapse="ngbCollapse" [ngbCollapse]="!isOpen" (ngbCollapseChange)="setToggle($event)">
|
||||
<ng-container [ngTemplateOutlet]="filterSection"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="not-phone-hidden">
|
||||
|
@ -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<MangaFormat>[]) {
|
||||
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) {
|
||||
|
@ -1,10 +1,11 @@
|
||||
<ng-container *ngIf="isAdmin$ | async">
|
||||
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0, 'colored-error': errors.length > 0}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0, 'colored-error': errors.length > 0}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@ -45,6 +46,18 @@
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="d-inline-flex">
|
||||
<span class="download">
|
||||
<app-circular-loader [currentValue]="25" [maxValue]="100" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
10% downloaded
|
||||
</span>
|
||||
</span>
|
||||
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
|
||||
</div>
|
||||
<div class="accent-text">PDFs</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
<!-- Progress Events-->
|
||||
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
|
||||
@ -86,6 +99,23 @@
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Active Downloads by the user-->
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngFor="let download of activeDownloads">
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">Downloading {{download.entityType | sentenceCase}}</div>
|
||||
<div class="accent-text mb-1" *ngIf="download.subTitle !== ''">{{download.subTitle}}</div>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{download.progress}}%</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': download.progress + '%'}" [attr.aria-valuenow]="download.progress" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<!-- Errors -->
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngFor="let error of errors">
|
||||
|
@ -55,6 +55,11 @@
|
||||
}
|
||||
|
||||
|
||||
// .download {
|
||||
// width: 80px;
|
||||
// height: 80px;
|
||||
// }
|
||||
|
||||
|
||||
|
||||
.btn-icon {
|
||||
|
@ -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();
|
||||
|
@ -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 */});;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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<HttpEvent<Blob>>) => Observable<Download> {
|
||||
@ -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;
|
||||
|
@ -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<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
|
||||
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<number>;
|
||||
let downloadCall: Observable<Download>;
|
||||
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<number>(this.baseUrl + 'download/series-size?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
public downloadVolumeSize(volumeId: number) {
|
||||
private downloadVolumeSize(volumeId: number) {
|
||||
return this.httpClient.get<number>(this.baseUrl + 'download/volume-size?volumeId=' + volumeId);
|
||||
}
|
||||
|
||||
public downloadChapterSize(chapterId: number) {
|
||||
private downloadChapterSize(chapterId: number) {
|
||||
return this.httpClient.get<number>(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<Download> {
|
||||
private downloadVolume(volume: Volume): Observable<Download> {
|
||||
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.
|
||||
|
@ -1,8 +1,8 @@
|
||||
<ng-container *ngIf="currentValue > 0">
|
||||
<div class="number">
|
||||
<i class="fa fa-angle-double-down" style="font-size: 36px;" aria-hidden="true"></i>
|
||||
<div [ngClass]="{'number': center}" class="indicator" *ngIf="showIcon">
|
||||
<i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div style="width: 100px; height: 100px;">
|
||||
<div [ngStyle]="{'width': width, 'height': height}">
|
||||
<circle-progress
|
||||
[percent]="currentValue"
|
||||
[radius]="100"
|
||||
|
@ -2,9 +2,12 @@
|
||||
position: absolute;
|
||||
top:50%;
|
||||
left:50%;
|
||||
z-index:10;
|
||||
font-size:18px;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
font-weight:500;
|
||||
z-index:10;
|
||||
color: var(--primary-color);
|
||||
animation: MoveUpDown 1s linear infinite;
|
||||
}
|
@ -12,4 +12,18 @@ export class CircularLoaderComponent {
|
||||
@Input() maxValue: number = 0;
|
||||
@Input() animation: boolean = true;
|
||||
@Input() innerStrokeColor: string = 'transparent';
|
||||
@Input() fontSize: string = '36px';
|
||||
@Input() showIcon: boolean = true;
|
||||
/**
|
||||
* The width in pixels of the loader
|
||||
*/
|
||||
@Input() width: string = '100px';
|
||||
/**
|
||||
* The height in pixels of the loader
|
||||
*/
|
||||
@Input() height: string = '100px';
|
||||
/**
|
||||
* Centers the icon in the middle of the loader. Best for card use.
|
||||
*/
|
||||
@Input() center: boolean = true;
|
||||
}
|
||||
|
@ -3,8 +3,9 @@ import { DOCUMENT } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, RendererStyleFlags2, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
|
||||
import { auditTime, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
|
||||
import { auditTime, distinctUntilChanged, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { ToggleService } from '../_services/toggle.service';
|
||||
import { SelectionCompareFn, TypeaheadSettings } from './typeahead-settings';
|
||||
|
||||
/**
|
||||
|
@ -37,7 +37,7 @@
|
||||
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="auto-close" role="switch" formControlName="blurUnreadSummaries" class="form-check-input" aria-describedby="blurUnreadSummaries" [value]="true" aria-labelledby="auto-close-label">
|
||||
<input type="checkbox" id="auto-close" role="switch" formControlName="blurUnreadSummaries" class="form-check-input" aria-describedby="settings-global-blurUnreadSummaries-help" [value]="true" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="auto-close">Blur Unread Summaries</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="blurUnreadSummariesTooltip" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
|
||||
@ -45,6 +45,17 @@
|
||||
<span class="visually-hidden" id="settings-global-blurUnreadSummaries-help">Blurs summary text on volumes or chapters that have no read progress (to avoid spoilers)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="prompt-download" role="switch" formControlName="promptForDownloadSize" class="form-check-input" aria-describedby="settings-global-promptForDownloadSize-help" [value]="true" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="prompt-download">Prompt on Download</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="promptForDownloadSizeTooltip" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
|
||||
<ng-template #promptForDownloadSizeTooltip>Prompt when a download exceedes 100MB in size</ng-template>
|
||||
<span class="visually-hidden" id="settings-global-promptForDownloadSize-help">Prompt when a download exceedes 100MB in size</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
|
||||
|
@ -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) => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user