mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-30 19:54:14 -04:00
* Updated swiper and some packages for reported security issues * Fixed reading lists promotion not working * Refactor RenameFileForCopy to use iterative recursion, rather than functional. * Ensured that bookmarks are fetched and ordered by Created date. * Fixed a bug where bookmarks were coming back in the correct order, but due to filenames, would not sort correctly. * Default installs to Debug log level given errors users have and Debug not being too noisy * Added jumpbar to bookmarks page * Now added jumpbar to bookmarks * Refactored some code into pipes and added some debug messaging for prefetcher * Try loading next and prev chapter's first/last page to cache so it renders faster * Updated GetImage to do a bound check on max page. Fixed a critical bug in how manga reader updates image elements src to prefetch/load pages. I was not creating a new reference which broke Angular's ability to update DOM on changes. * Refactored the image setting code to use a single method which tries to use a cached image always. * Refactored code to use getPage which favors cache and simplifies image creation code * Started the work to split the canvas renderer into it's own component * Refactored a lot of common methods into a service for the reader to support the upcoming renderer split * Moved components to nested folder. Refactored more code to streamline image sending to child renderer. Added notes across the code to help streamline flow of data and who owns what. * Swapped out SQLite for Memory, but the one from hangfire. Added DisableConcurrentExecution on ProcessChange to avoid duplication when multiple threads execute at once. * Basic split right to left is working with canvas renderer * Left to right and right to left now work * Fixed a bug where pagesplitoption wasn't being updated when modifying menu * Canvas rendering still has a bug with switching between right to left -> left to right on the re-render, it will choose a bad state. All else works fine with it. * Updated canvas renderer to implement the ImageRenderer interface * Canvas renderer is done * Setup single renderer. Need to figure out how to share CSS between renderers and also share some global stuff, like image height. * Refactored code so that image-container is within the renderers themselves. Still broken in scaling, but working towards a solution. * Added double click to shortcut menu * Moved image containers within the renderers * Pushing up for Robbie * nothing new * Move common css to a single scss file * More css consolidation * Fixed a npe in isWideImage * Refactored page updates to renderers to include max pages. Rewrote most of renderer into observables. * Moved bookmark for second page to double renderer * Started hooking in double renderer renderPage() * Fixed height scaling, but now canvas renderer is broken again * Fixed a bug with canvas renderer not moving to next page. Streamlined the code for getting page amounts from the dfferent renderers * Added double click to bookmark for canvas * Stashing the code and taking a break * Nothing much, buffer is still broken * Got double renderer to render at least one page * Double renderer now has access to 5 images at any time, so it can make appropriate decisions on when to render double pages. * Fixed up double rendererer moving backward page calc * Forward logic seems to be working * Cleaned up dead code after testing * Moved a few loggers in folder watching to trace * Everything seems to work fine, time to do double manga renderer * Moved some css around and added the reverse double component * Only execute renderer's pipes when in the correct mode * Still working on double renderer * Fixed scaling issues on double * Updating double logic - Fixed: Fixed an issue where a second page would render when current page was wide. * Hooked up double renderer * Made changes but not sure if im making progress * double manga fixes * Claned some of robbies code * Fixing last page bug * Library Settings Modal + New Library Settings (#1660) * Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump versions by dotnet-bump-version. * UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. * Fixed GA (#1664) * Bump versions by dotnet-bump-version. * Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666) * Fixed typeahead and updated manga reader to new layout structure * Fixed book reader fonts lookups * Fixed up some build issues * Fixed a bad import of css image * Some cleanup and rewrote how we log out data. * Renderer can be null on first load when performing some work. * Library Settings Modal + New Library Settings (#1660) * Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4. - [Release notes](https://github.com/webpack/loader-utils/releases) - [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md) - [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4) --- updated-dependencies: - dependency-name: loader-utils dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> * Fixed want to read button on series detail not performing the correct action * Started the library settings. Added ability to update a cover image for a library. Updated backup db to also copy reading list (and now library) cover images. * Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library. * Fixed a missing update event in backend when updating a library. * Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid. * Trim library names before you check anything * General code cleanup * Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries. Refactored some code to streamline perf in some flows. * Removed old components replaced with new modal * Code smells Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. * Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666) * Post merge cleanup * Again moving the file * Fixed an issue with switching to double renderer and the image not loading for cover image. * Fixed double manga last page repeating twice * Added ability to quickly save a few settings to user preferences from manga reader * Fixed up some success messaging * Single image and canvas could stack, last page on double wouldn't render. * Stashing code, want to work on something else * Suppress a concurrency issue when opening a fresh chapter to read. * Refactored a function into a pipe * Took care of one TODO * Tightened up the logic around single renderer handling fit to screen images. * Added some code to see how long api takes on average. * First pass integration of page dimensions into single renderer and base code * Canvas renderer pass for new page dimensions * On time left, don't use the word left again * Moved the page dimension code into manga service to make it seemless * Hooked in a replacement for image based isWide * Canvas renderer is working again * Double renderer now follows how Komga does it to keep it simple. * Double renderer is working really well so far. * don't use nbsp * Added response caching to file-dimensions and chapter info api * Allow chapter info to send back file dimensions optionally * Fixed an issue with dimensions api locking files on Windows * Refactored all code to use isWidePage * More fixes and cleanup * More double reverse logic * Recently Read stats page will allow you to click the items. Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Robbie Davis <robbie@therobbiedavis.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
494 lines
22 KiB
C#
494 lines
22 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Xml.Serialization;
|
|
using API.Archive;
|
|
using API.Data.Metadata;
|
|
using API.Extensions;
|
|
using API.Services.Tasks;
|
|
using Kavita.Common;
|
|
using Microsoft.Extensions.Logging;
|
|
using SharpCompress.Archives;
|
|
using SharpCompress.Common;
|
|
|
|
namespace API.Services;
|
|
|
|
public interface IArchiveService
|
|
{
|
|
void ExtractArchive(string archivePath, string extractPath);
|
|
int GetNumberOfPagesFromArchive(string archivePath);
|
|
string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false);
|
|
bool IsValidArchive(string archivePath);
|
|
ComicInfo GetComicInfo(string archivePath);
|
|
ArchiveLibrary CanOpen(string archivePath);
|
|
bool ArchiveNeedsFlattening(ZipArchive archive);
|
|
/// <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>
|
|
/// Responsible for manipulating Archive files. Used by <see cref="CacheService"/> and <see cref="ScannerService"/>
|
|
/// </summary>
|
|
// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global
|
|
public class ArchiveService : IArchiveService
|
|
{
|
|
private readonly ILogger<ArchiveService> _logger;
|
|
private readonly IDirectoryService _directoryService;
|
|
private readonly IImageService _imageService;
|
|
private const string ComicInfoFilename = "ComicInfo.xml";
|
|
|
|
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService, IImageService imageService)
|
|
{
|
|
_logger = logger;
|
|
_directoryService = directoryService;
|
|
_imageService = imageService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a File can be opened. Requires up to 2 opens of the filestream.
|
|
/// </summary>
|
|
/// <param name="archivePath"></param>
|
|
/// <returns></returns>
|
|
public virtual ArchiveLibrary CanOpen(string archivePath)
|
|
{
|
|
if (string.IsNullOrEmpty(archivePath) || !(File.Exists(archivePath) && Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath))) return ArchiveLibrary.NotSupported;
|
|
|
|
var ext = _directoryService.FileSystem.Path.GetExtension(archivePath).ToUpper();
|
|
if (ext.Equals(".CBR") || ext.Equals(".RAR")) return ArchiveLibrary.SharpCompress;
|
|
|
|
try
|
|
{
|
|
using var a2 = ZipFile.OpenRead(archivePath);
|
|
return ArchiveLibrary.Default;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
try
|
|
{
|
|
using var a1 = ArchiveFactory.Open(archivePath);
|
|
return ArchiveLibrary.SharpCompress;
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return ArchiveLibrary.NotSupported;
|
|
}
|
|
}
|
|
}
|
|
|
|
public int GetNumberOfPagesFromArchive(string archivePath)
|
|
{
|
|
if (!IsValidArchive(archivePath))
|
|
{
|
|
_logger.LogError("Archive {ArchivePath} could not be found", archivePath);
|
|
return 0;
|
|
}
|
|
|
|
try
|
|
{
|
|
var libraryHandler = CanOpen(archivePath);
|
|
switch (libraryHandler)
|
|
{
|
|
case ArchiveLibrary.Default:
|
|
{
|
|
using var archive = ZipFile.OpenRead(archivePath);
|
|
return archive.Entries.Count(e => !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(e.FullName) && Tasks.Scanner.Parser.Parser.IsImage(e.FullName));
|
|
}
|
|
case ArchiveLibrary.SharpCompress:
|
|
{
|
|
using var archive = ArchiveFactory.Open(archivePath);
|
|
return archive.Entries.Count(entry => !entry.IsDirectory &&
|
|
!Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty)
|
|
&& Tasks.Scanner.Parser.Parser.IsImage(entry.Key));
|
|
}
|
|
case ArchiveLibrary.NotSupported:
|
|
_logger.LogWarning("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath);
|
|
return 0;
|
|
default:
|
|
_logger.LogWarning("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath);
|
|
return 0;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds the first instance of a folder entry and returns it
|
|
/// </summary>
|
|
/// <param name="entryFullNames"></param>
|
|
/// <returns>Entry name of match, null if no match</returns>
|
|
public static string FindFolderEntry(IEnumerable<string> entryFullNames)
|
|
{
|
|
var result = entryFullNames
|
|
.Where(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)))
|
|
.OrderByNatural(Path.GetFileNameWithoutExtension)
|
|
.FirstOrDefault(Tasks.Scanner.Parser.Parser.IsCoverImage);
|
|
|
|
return string.IsNullOrEmpty(result) ? null : result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns first entry that is an image and is not in a blacklisted folder path. Uses <see cref="EnumerableExtensions.OrderByNatural"/> for ordering files
|
|
/// </summary>
|
|
/// <param name="entryFullNames"></param>
|
|
/// <param name="archiveName"></param>
|
|
/// <returns>Entry name of match, null if no match</returns>
|
|
public static string? FirstFileEntry(IEnumerable<string> entryFullNames, string archiveName)
|
|
{
|
|
// First check if there are any files that are not in a nested folder before just comparing by filename. This is needed
|
|
// because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg.
|
|
var fullNames = entryFullNames
|
|
.Where(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)) && Tasks.Scanner.Parser.Parser.IsImage(path))
|
|
.OrderByNatural(c => c.GetFullPathWithoutExtension())
|
|
.ToList();
|
|
if (fullNames.Count == 0) return null;
|
|
|
|
var nonNestedFile = fullNames.Where(entry => (Path.GetDirectoryName(entry) ?? string.Empty).Equals(archiveName))
|
|
.OrderByNatural(c => c.GetFullPathWithoutExtension())
|
|
.FirstOrDefault();
|
|
|
|
if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile;
|
|
|
|
// Check the first folder and sort within that to see if we can find a file, else fallback to first file with basic sort.
|
|
// Get first folder, then sort within that
|
|
var firstDirectoryFile = fullNames.OrderByNatural(Path.GetDirectoryName).FirstOrDefault();
|
|
if (!string.IsNullOrEmpty(firstDirectoryFile))
|
|
{
|
|
var firstDirectory = Path.GetDirectoryName(firstDirectoryFile);
|
|
if (!string.IsNullOrEmpty(firstDirectory))
|
|
{
|
|
var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f)))
|
|
.OrderByNatural(Path.GetFileNameWithoutExtension)
|
|
.FirstOrDefault();
|
|
|
|
if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult;
|
|
}
|
|
}
|
|
|
|
var result = fullNames
|
|
.OrderByNatural(Path.GetFileNameWithoutExtension)
|
|
.FirstOrDefault();
|
|
|
|
return string.IsNullOrEmpty(result) ? null : result;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Generates byte array of cover image.
|
|
/// Given a path to a compressed file <see cref="Tasks.Scanner.Parser.Parser.ArchiveFileExtensions"/>, will ensure the first image (respects directory structure) is returned unless
|
|
/// a folder/cover.(image extension) exists in the the compressed file (if duplicate, the first is chosen)
|
|
///
|
|
/// This skips over any __MACOSX folder/file iteration.
|
|
/// </summary>
|
|
/// <remarks>This always creates a thumbnail</remarks>
|
|
/// <param name="archivePath"></param>
|
|
/// <param name="fileName">File name to use based on context of entity.</param>
|
|
/// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
|
|
/// <param name="saveAsWebP">When saving the file, use WebP encoding instead of PNG</param>
|
|
/// <returns></returns>
|
|
public string GetCoverImage(string archivePath, string fileName, string outputDirectory, bool saveAsWebP = false)
|
|
{
|
|
if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty;
|
|
try
|
|
{
|
|
var libraryHandler = CanOpen(archivePath);
|
|
switch (libraryHandler)
|
|
{
|
|
case ArchiveLibrary.Default:
|
|
{
|
|
using var archive = ZipFile.OpenRead(archivePath);
|
|
|
|
var entryName = FindCoverImageFilename(archivePath, archive.Entries.Select(e => e.FullName));
|
|
var entry = archive.Entries.Single(e => e.FullName == entryName);
|
|
|
|
using var stream = entry.Open();
|
|
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP);
|
|
}
|
|
case ArchiveLibrary.SharpCompress:
|
|
{
|
|
using var archive = ArchiveFactory.Open(archivePath);
|
|
var entryNames = archive.Entries.Where(archiveEntry => !archiveEntry.IsDirectory).Select(e => e.Key).ToList();
|
|
|
|
var entryName = FindCoverImageFilename(archivePath, entryNames);
|
|
var entry = archive.Entries.Single(e => e.Key == entryName);
|
|
|
|
using var stream = entry.OpenEntryStream();
|
|
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, saveAsWebP);
|
|
}
|
|
case ArchiveLibrary.NotSupported:
|
|
_logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath);
|
|
return string.Empty;
|
|
default:
|
|
_logger.LogWarning("[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath);
|
|
return string.Empty;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath);
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Given a list of image paths (assume within an archive), find the filename that corresponds to the cover
|
|
/// </summary>
|
|
/// <param name="archivePath"></param>
|
|
/// <param name="entryNames"></param>
|
|
/// <returns></returns>
|
|
public static string FindCoverImageFilename(string archivePath, IEnumerable<string> entryNames)
|
|
{
|
|
var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath));
|
|
return entryName;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly
|
|
/// under extract path and not nested in subfolders. See <see cref="DirectoryService"/> Flatten method.
|
|
/// </summary>
|
|
/// <param name="archive">An opened archive stream</param>
|
|
/// <returns></returns>
|
|
public bool ArchiveNeedsFlattening(ZipArchive archive)
|
|
{
|
|
// Sometimes ZipArchive will list the directory and others it will just keep it in the FullName
|
|
return archive.Entries.Count > 0 &&
|
|
!Path.HasExtension(archive.Entries.ElementAt(0).FullName) ||
|
|
archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar) && !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(e.FullName));
|
|
}
|
|
|
|
/// <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>
|
|
public string CreateZipForDownload(IEnumerable<string> files, string tempFolder)
|
|
{
|
|
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.");
|
|
}
|
|
|
|
var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
|
|
try
|
|
{
|
|
ZipFile.CreateFromDirectory(tempLocation, zipPath);
|
|
}
|
|
catch (AggregateException ex)
|
|
{
|
|
_logger.LogError(ex, "There was an issue creating temp archive");
|
|
throw new KavitaException("There was an issue creating temp archive");
|
|
}
|
|
|
|
return zipPath;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Test if the archive path exists and an archive
|
|
/// </summary>
|
|
/// <param name="archivePath"></param>
|
|
/// <returns></returns>
|
|
public bool IsValidArchive(string archivePath)
|
|
{
|
|
if (!File.Exists(archivePath))
|
|
{
|
|
_logger.LogWarning("Archive {ArchivePath} could not be found", archivePath);
|
|
return false;
|
|
}
|
|
|
|
if (Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath)) return true;
|
|
|
|
_logger.LogWarning("Archive {ArchivePath} is not a valid archive", archivePath);
|
|
return false;
|
|
}
|
|
|
|
private static bool IsComicInfoArchiveEntry(string fullName, string name)
|
|
{
|
|
return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName)
|
|
&& name.EndsWith(ComicInfoFilename, StringComparison.OrdinalIgnoreCase)
|
|
&& !name.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith);
|
|
}
|
|
|
|
/// <summary>
|
|
/// This can be null if nothing is found or any errors occur during access
|
|
/// </summary>
|
|
/// <param name="archivePath"></param>
|
|
/// <returns></returns>
|
|
public ComicInfo? GetComicInfo(string archivePath)
|
|
{
|
|
if (!IsValidArchive(archivePath)) return null;
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(archivePath)) return null;
|
|
|
|
var libraryHandler = CanOpen(archivePath);
|
|
switch (libraryHandler)
|
|
{
|
|
case ArchiveLibrary.Default:
|
|
{
|
|
using var archive = ZipFile.OpenRead(archivePath);
|
|
|
|
var entry = archive.Entries.FirstOrDefault(x => (x.FullName ?? x.Name) == ComicInfoFilename) ??
|
|
archive.Entries.FirstOrDefault(x => IsComicInfoArchiveEntry(x.FullName, x.Name));
|
|
if (entry != null)
|
|
{
|
|
using var stream = entry.Open();
|
|
var serializer = new XmlSerializer(typeof(ComicInfo));
|
|
var info = (ComicInfo) serializer.Deserialize(stream);
|
|
ComicInfo.CleanComicInfo(info);
|
|
return info;
|
|
}
|
|
|
|
break;
|
|
}
|
|
case ArchiveLibrary.SharpCompress:
|
|
{
|
|
using var archive = ArchiveFactory.Open(archivePath);
|
|
var entry = archive.Entries.FirstOrDefault(entry => entry.Key == ComicInfoFilename) ??
|
|
archive.Entries.FirstOrDefault(entry =>
|
|
IsComicInfoArchiveEntry(Path.GetDirectoryName(entry.Key), entry.Key));
|
|
|
|
if (entry != null)
|
|
{
|
|
using var stream = entry.OpenEntryStream();
|
|
var serializer = new XmlSerializer(typeof(ComicInfo));
|
|
var info = (ComicInfo) serializer.Deserialize(stream);
|
|
ComicInfo.CleanComicInfo(info);
|
|
return info;
|
|
}
|
|
|
|
break;
|
|
}
|
|
case ArchiveLibrary.NotSupported:
|
|
_logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath);
|
|
return null;
|
|
default:
|
|
_logger.LogWarning(
|
|
"[GetComicInfo] There was an exception when reading archive stream: {ArchivePath}",
|
|
archivePath);
|
|
return null;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
private void ExtractArchiveEntities(IEnumerable<IArchiveEntry> entries, string extractPath)
|
|
{
|
|
_directoryService.ExistOrCreate(extractPath);
|
|
// TODO: Look into a Parallel.ForEach
|
|
foreach (var entry in entries)
|
|
{
|
|
entry.WriteToDirectory(extractPath, new ExtractionOptions()
|
|
{
|
|
ExtractFullPath = true, // Don't flatten, let the flatterner ensure correct order of nested folders
|
|
Overwrite = false
|
|
});
|
|
}
|
|
}
|
|
|
|
private void ExtractArchiveEntries(ZipArchive archive, string extractPath)
|
|
{
|
|
var needsFlattening = ArchiveNeedsFlattening(archive);
|
|
if (!archive.HasFiles() && !needsFlattening) return;
|
|
|
|
archive.ExtractToDirectory(extractPath, true);
|
|
if (!needsFlattening) return;
|
|
|
|
_logger.LogDebug("Extracted archive is nested in root folder, flattening...");
|
|
_directoryService.Flatten(extractPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists,
|
|
/// will return that without performing an extraction. Returns empty string if there are any invalidations which would
|
|
/// prevent operations to perform correctly (missing archivePath file, empty archive, etc).
|
|
/// </summary>
|
|
/// <param name="archivePath">A valid file to an archive file.</param>
|
|
/// <param name="extractPath">Path to extract to</param>
|
|
/// <returns></returns>
|
|
public void ExtractArchive(string archivePath, string extractPath)
|
|
{
|
|
if (!IsValidArchive(archivePath)) return;
|
|
|
|
if (Directory.Exists(extractPath)) return;
|
|
|
|
if (!_directoryService.FileSystem.File.Exists(archivePath))
|
|
{
|
|
_logger.LogError("{Archive} does not exist on disk", archivePath);
|
|
throw new KavitaException($"{archivePath} does not exist on disk");
|
|
}
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
|
|
try
|
|
{
|
|
var libraryHandler = CanOpen(archivePath);
|
|
switch (libraryHandler)
|
|
{
|
|
case ArchiveLibrary.Default:
|
|
{
|
|
using var archive = ZipFile.OpenRead(archivePath);
|
|
ExtractArchiveEntries(archive, extractPath);
|
|
break;
|
|
}
|
|
case ArchiveLibrary.SharpCompress:
|
|
{
|
|
using var archive = ArchiveFactory.Open(archivePath);
|
|
ExtractArchiveEntities(archive.Entries.Where(entry => !entry.IsDirectory
|
|
&& !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty)
|
|
&& Tasks.Scanner.Parser.Parser.IsImage(entry.Key)), extractPath);
|
|
break;
|
|
}
|
|
case ArchiveLibrary.NotSupported:
|
|
_logger.LogWarning("[ExtractArchive] This archive cannot be read: {ArchivePath}", archivePath);
|
|
return;
|
|
default:
|
|
_logger.LogWarning("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}", archivePath);
|
|
return;
|
|
}
|
|
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath);
|
|
throw new KavitaException(
|
|
$"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters.");
|
|
}
|
|
_logger.LogDebug("Extracted archive to {ExtractPath} in {ElapsedMilliseconds} milliseconds", extractPath, sw.ElapsedMilliseconds);
|
|
}
|
|
}
|