mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-31 04:04:19 -04:00
* Added parser case for "The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz" * Removed a file that is created and modified every test run. * Fixed a bad parser case for "Batman Beyond 02 (of 6) (1999)" which was consuming too many characters * Removed a lot of "Volume" parsing for Comics that don't make sense. This is prep work for the upcoming Comic Rework release. * Reworked a lot of parsing cases for comics based on naming conventions observed from releases found online. * Added a way for external scripts to use a user api key to authenticate * Fixed an issue if the manga only had one page, the bottom menu would be missing page and chapter controls. * Fixed a bug where on small phones, nav bar could overflow due to scroll to top * Tweaked a lot of regex for manga parsing to handle some cases where poorly named files, like "Vol. 03 Ch. 21" would end up parsing as Series "Vol. 03". * Even more handling of parser cases. Manga parser should be as it was but more robust to handle bad naming. * Fixed: Don't force metadata refresh on Scan Series, only on refresh metadata * Implemented the ability to automatically refresh after a series scan based on when server finishes. Remove a duplicate API call from series detail. * Removed another API call for series metadata that isn't needed. * Refactored Message creation to a factory, hardcoded strings are centralized, and RefreshSeriesMetadata sends an event and is refactored to be async. * Fixed a bug when really poorly named files are within a folder that contains the series name, fallback couldn't occur due to it being taken as root folder. Now we detect said condition and will go one level higher, resulting in potentially more I/O, but the series will not be deleted. * Added the Read in Incognito context item for Chapter cards * Skip an additional check for series summary for series that aren't EPUB or Archive formats. * Fixed an issue where cover image generation could occur due to a bad check on LastWriteTime on the underlying file. * Added some extra comic parser tests * Added a ScanLibrary event (not hooked up in UI) * Performance improvement on metadata service. Now when we scan for cover image changes, we emit when a change occurs and only then do we update parent entities (array copy). * Removed an hr from series detail and ensure we update the cover image for series when scan series finishes. * Updated the infinite scroller to use a Flags pattern for the debug mode. Updated a few logical conditions for mobile. * Removed the concurrency check on row progress as if too many calls hit the DB, it will throw, but it doesn't matter. Fixed a bad logic code which could cause scrolling after hitting the bottom of the chapter. * Ensure prefetching uses totalPages + 1 since we pass in totalPages as - 1 from manga reader * Fixed issue where last page of webtoon wouldn't be prefetched due to a < instead of <= on prefetching code * Implemented ability to send images from archives to the UI without incurring any extra memory pressure. * Dropdown menus now have a darker background * Webtoon reader now works on mobile. * Fixed how keyboard presses for up/down/left/right work with MANGA_UD reading mode. See issue #579 * Fixed cont reader for webtoons on mobile * Fixed a small issue where top spacer would too quickly switch to prev chapter * Updated user preferences to use same slider style. Removed some css that is not used. * Added comic parser case for "Saga 001 (2012) (Digital) (Empire-Zone)" * Added accessibility toggle to reading list order and aligned sliders to all use the same style. * Removed a todo for checking on new image serving code. It works great. * Fixed a missing await * Auth guard will now check if an existing toast is present giving same message before poping the toast. * Fixed alignment on phones for reading lists * Moved sorters so they aren't resused between multiple threads. Slightly higher memory footprint. * Fixed a broken unit test * Code smells * More unit test fixing
530 lines
20 KiB
C#
530 lines
20 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using API.Interfaces.Services;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace API.Services
|
|
{
|
|
public class DirectoryService : IDirectoryService
|
|
{
|
|
private readonly ILogger<DirectoryService> _logger;
|
|
private static readonly Regex ExcludeDirectories = new Regex(
|
|
@"@eaDir|\.DS_Store",
|
|
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
|
public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
|
|
public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "logs");
|
|
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "cache");
|
|
|
|
public DirectoryService(ILogger<DirectoryService> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Given a set of regex search criteria, get files in the given path.
|
|
/// </summary>
|
|
/// <remarks>This will always exclude <see cref="Parser.Parser.MacOsMetadataFileStartsWith"/> patterns</remarks>
|
|
/// <param name="path">Directory to search</param>
|
|
/// <param name="searchPatternExpression">Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files.</param>
|
|
/// <param name="searchOption">SearchOption to use, defaults to TopDirectoryOnly</param>
|
|
/// <returns>List of file paths</returns>
|
|
private static IEnumerable<string> GetFilesWithCertainExtensions(string path,
|
|
string searchPatternExpression = "",
|
|
SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
|
{
|
|
if (!Directory.Exists(path)) return ImmutableList<string>.Empty;
|
|
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
|
|
|
|
return Directory.EnumerateFiles(path, "*", searchOption)
|
|
.Where(file =>
|
|
reSearchPattern.IsMatch(Path.GetExtension(file)) && !Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Returns a list of folders from end of fullPath to rootPath. If a file is passed at the end of the fullPath, it will be ignored.
|
|
///
|
|
/// Example) (C:/Manga/, C:/Manga/Love Hina/Specials/Omake/) returns [Omake, Specials, Love Hina]
|
|
/// </summary>
|
|
/// <param name="rootPath"></param>
|
|
/// <param name="fullPath"></param>
|
|
/// <returns></returns>
|
|
public static IEnumerable<string> GetFoldersTillRoot(string rootPath, string fullPath)
|
|
{
|
|
var separator = Path.AltDirectorySeparatorChar;
|
|
if (fullPath.Contains(Path.DirectorySeparatorChar))
|
|
{
|
|
fullPath = fullPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
}
|
|
|
|
if (rootPath.Contains(Path.DirectorySeparatorChar))
|
|
{
|
|
rootPath = rootPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
}
|
|
|
|
|
|
|
|
var path = fullPath.EndsWith(separator) ? fullPath.Substring(0, fullPath.Length - 1) : fullPath;
|
|
var root = rootPath.EndsWith(separator) ? rootPath.Substring(0, rootPath.Length - 1) : rootPath;
|
|
var paths = new List<string>();
|
|
// If a file is at the end of the path, remove it before we start processing folders
|
|
if (Path.GetExtension(path) != string.Empty)
|
|
{
|
|
path = path.Substring(0, path.LastIndexOf(separator));
|
|
}
|
|
|
|
while (Path.GetDirectoryName(path) != Path.GetDirectoryName(root))
|
|
{
|
|
var folder = new DirectoryInfo(path).Name;
|
|
paths.Add(folder);
|
|
path = path.Substring(0, path.LastIndexOf(separator));
|
|
}
|
|
|
|
return paths;
|
|
}
|
|
|
|
public bool Exists(string directory)
|
|
{
|
|
var di = new DirectoryInfo(directory);
|
|
return di.Exists;
|
|
}
|
|
|
|
public IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",
|
|
SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
|
{
|
|
if (searchPatternExpression != string.Empty)
|
|
{
|
|
if (!Directory.Exists(path)) return ImmutableList<string>.Empty;
|
|
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
|
|
return Directory.EnumerateFiles(path, "*", searchOption)
|
|
.Where(file =>
|
|
reSearchPattern.IsMatch(file) && !file.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
|
|
}
|
|
|
|
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
|
|
}
|
|
|
|
public void CopyFileToDirectory(string fullFilePath, string targetDirectory)
|
|
{
|
|
try
|
|
{
|
|
var fileInfo = new FileInfo(fullFilePath);
|
|
if (fileInfo.Exists)
|
|
{
|
|
fileInfo.CopyTo(Path.Join(targetDirectory, fileInfo.Name), true);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "There was a critical error when copying {File} to {Directory}", fullFilePath, targetDirectory);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies a Directory with all files and subdirectories to a target location
|
|
/// </summary>
|
|
/// <param name="sourceDirName"></param>
|
|
/// <param name="destDirName"></param>
|
|
/// <param name="searchPattern">Defaults to *, meaning all files</param>
|
|
/// <returns></returns>
|
|
/// <exception cref="DirectoryNotFoundException"></exception>
|
|
public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "*")
|
|
{
|
|
if (string.IsNullOrEmpty(sourceDirName)) return false;
|
|
|
|
var di = new DirectoryInfo(sourceDirName);
|
|
if (!di.Exists) return false;
|
|
|
|
// Get the subdirectories for the specified directory.
|
|
var dir = new DirectoryInfo(sourceDirName);
|
|
|
|
if (!dir.Exists)
|
|
{
|
|
throw new DirectoryNotFoundException(
|
|
"Source directory does not exist or could not be found: "
|
|
+ sourceDirName);
|
|
}
|
|
|
|
var dirs = dir.GetDirectories();
|
|
|
|
// If the destination directory doesn't exist, create it.
|
|
Directory.CreateDirectory(destDirName);
|
|
|
|
// Get the files in the directory and copy them to the new location.
|
|
var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => new FileInfo(n));
|
|
foreach (var file in files)
|
|
{
|
|
var tempPath = Path.Combine(destDirName, file.Name);
|
|
file.CopyTo(tempPath, false);
|
|
}
|
|
|
|
// If copying subdirectories, copy them and their contents to new location.
|
|
foreach (var subDir in dirs)
|
|
{
|
|
var tempPath = Path.Combine(destDirName, subDir.Name);
|
|
CopyDirectoryToDirectory(subDir.FullName, tempPath);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
|
|
public string[] GetFilesWithExtension(string path, string searchPatternExpression = "")
|
|
{
|
|
if (searchPatternExpression != string.Empty)
|
|
{
|
|
return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray();
|
|
}
|
|
|
|
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the total number of bytes for a given set of full file paths
|
|
/// </summary>
|
|
/// <param name="paths"></param>
|
|
/// <returns>Total bytes</returns>
|
|
public static long GetTotalSize(IEnumerable<string> paths)
|
|
{
|
|
return paths.Sum(path => new FileInfo(path).Length);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the path exists and is a directory. If path does not exist, this will create it. Returns false in all fail cases.
|
|
/// </summary>
|
|
/// <param name="directoryPath"></param>
|
|
/// <returns></returns>
|
|
public static bool ExistOrCreate(string directoryPath)
|
|
{
|
|
var di = new DirectoryInfo(directoryPath);
|
|
if (di.Exists) return true;
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directoryPath);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes all files within the directory, then the directory itself.
|
|
/// </summary>
|
|
/// <param name="directoryPath"></param>
|
|
public static void ClearAndDeleteDirectory(string directoryPath)
|
|
{
|
|
if (!Directory.Exists(directoryPath)) return;
|
|
|
|
DirectoryInfo di = new DirectoryInfo(directoryPath);
|
|
|
|
ClearDirectory(directoryPath);
|
|
|
|
di.Delete(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes all files within the directory.
|
|
/// </summary>
|
|
/// <param name="directoryPath"></param>
|
|
/// <returns></returns>
|
|
public static void ClearDirectory(string directoryPath)
|
|
{
|
|
var di = new DirectoryInfo(directoryPath);
|
|
if (!di.Exists) return;
|
|
|
|
foreach (var file in di.EnumerateFiles())
|
|
{
|
|
file.Delete();
|
|
}
|
|
foreach (var dir in di.EnumerateDirectories())
|
|
{
|
|
dir.Delete(true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies files to a destination directory. If the destination directory doesn't exist, this will create it.
|
|
/// </summary>
|
|
/// <param name="filePaths"></param>
|
|
/// <param name="directoryPath"></param>
|
|
/// <param name="prepend">An optional string to prepend to the target file's name</param>
|
|
/// <returns></returns>
|
|
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
|
|
{
|
|
ExistOrCreate(directoryPath);
|
|
string currentFile = null;
|
|
try
|
|
{
|
|
foreach (var file in filePaths)
|
|
{
|
|
currentFile = file;
|
|
var fileInfo = new FileInfo(file);
|
|
if (fileInfo.Exists)
|
|
{
|
|
fileInfo.CopyTo(Path.Join(directoryPath, prepend + fileInfo.Name));
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public IEnumerable<string> ListDirectory(string rootPath)
|
|
{
|
|
if (!Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
|
|
|
|
var di = new DirectoryInfo(rootPath);
|
|
var dirs = di.GetDirectories()
|
|
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
|
|
.Select(d => d.Name).ToImmutableList();
|
|
|
|
return dirs;
|
|
}
|
|
|
|
public async Task<byte[]> ReadFileAsync(string path)
|
|
{
|
|
if (!File.Exists(path)) return Array.Empty<byte>();
|
|
return await File.ReadAllBytesAsync(path);
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Finds the highest directories from a set of MangaFiles
|
|
/// </summary>
|
|
/// <param name="libraryFolders">List of top level folders which files belong to</param>
|
|
/// <param name="filePaths">List of file paths that belong to libraryFolders</param>
|
|
/// <returns></returns>
|
|
public static Dictionary<string, string> FindHighestDirectoriesFromFiles(IEnumerable<string> libraryFolders, IList<string> filePaths)
|
|
{
|
|
var stopLookingForDirectories = false;
|
|
var dirs = new Dictionary<string, string>();
|
|
foreach (var folder in libraryFolders)
|
|
{
|
|
if (stopLookingForDirectories) break;
|
|
foreach (var file in filePaths)
|
|
{
|
|
if (!file.Contains(folder)) continue;
|
|
|
|
var parts = GetFoldersTillRoot(folder, file).ToList();
|
|
if (parts.Count == 0)
|
|
{
|
|
// Break from all loops, we done, just scan folder.Path (library root)
|
|
dirs.Add(folder, string.Empty);
|
|
stopLookingForDirectories = true;
|
|
break;
|
|
}
|
|
|
|
var fullPath = Path.Join(folder, parts.Last());
|
|
if (!dirs.ContainsKey(fullPath))
|
|
{
|
|
dirs.Add(fullPath, string.Empty);
|
|
}
|
|
}
|
|
}
|
|
|
|
return dirs;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Recursively scans files and applies an action on them. This uses as many cores the underlying PC has to speed
|
|
/// up processing.
|
|
/// </summary>
|
|
/// <param name="root">Directory to scan</param>
|
|
/// <param name="action">Action to apply on file path</param>
|
|
/// <param name="searchPattern">Regex pattern to search against</param>
|
|
/// <param name="logger"></param>
|
|
/// <exception cref="ArgumentException"></exception>
|
|
public static int TraverseTreeParallelForEach(string root, Action<string> action, string searchPattern, ILogger logger)
|
|
{
|
|
//Count of files traversed and timer for diagnostic output
|
|
var fileCount = 0;
|
|
|
|
// Determine whether to parallelize file processing on each folder based on processor count.
|
|
//var procCount = Environment.ProcessorCount;
|
|
|
|
// Data structure to hold names of subfolders to be examined for files.
|
|
var dirs = new Stack<string>();
|
|
|
|
if (!Directory.Exists(root)) {
|
|
throw new ArgumentException("The directory doesn't exist");
|
|
}
|
|
dirs.Push(root);
|
|
|
|
while (dirs.Count > 0) {
|
|
var currentDir = dirs.Pop();
|
|
IEnumerable<string> subDirs;
|
|
string[] files;
|
|
|
|
try {
|
|
subDirs = Directory.GetDirectories(currentDir).Where(path => ExcludeDirectories.Matches(path).Count == 0);
|
|
}
|
|
// Thrown if we do not have discovery permission on the directory.
|
|
catch (UnauthorizedAccessException e) {
|
|
Console.WriteLine(e.Message);
|
|
logger.LogError(e, "Unauthorized access on {Directory}", currentDir);
|
|
continue;
|
|
}
|
|
// Thrown if another process has deleted the directory after we retrieved its name.
|
|
catch (DirectoryNotFoundException e) {
|
|
Console.WriteLine(e.Message);
|
|
logger.LogError(e, "Directory not found on {Directory}", currentDir);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
files = GetFilesWithCertainExtensions(currentDir, searchPattern)
|
|
.ToArray();
|
|
}
|
|
catch (UnauthorizedAccessException e) {
|
|
Console.WriteLine(e.Message);
|
|
continue;
|
|
}
|
|
catch (DirectoryNotFoundException e) {
|
|
Console.WriteLine(e.Message);
|
|
continue;
|
|
}
|
|
catch (IOException e) {
|
|
Console.WriteLine(e.Message);
|
|
continue;
|
|
}
|
|
|
|
// Execute in parallel if there are enough files in the directory.
|
|
// Otherwise, execute sequentially. Files are opened and processed
|
|
// synchronously but this could be modified to perform async I/O.
|
|
try {
|
|
// if (files.Length < procCount) {
|
|
// foreach (var file in files) {
|
|
// action(file);
|
|
// fileCount++;
|
|
// }
|
|
// }
|
|
// else {
|
|
// Parallel.ForEach(files, () => 0, (file, _, localCount) =>
|
|
// { action(file);
|
|
// return ++localCount;
|
|
// },
|
|
// (c) => {
|
|
// Interlocked.Add(ref fileCount, c);
|
|
// });
|
|
// }
|
|
foreach (var file in files) {
|
|
action(file);
|
|
fileCount++;
|
|
}
|
|
}
|
|
catch (AggregateException ae) {
|
|
ae.Handle((ex) => {
|
|
if (ex is UnauthorizedAccessException) {
|
|
// Here we just output a message and go on.
|
|
Console.WriteLine(ex.Message);
|
|
return true;
|
|
}
|
|
// Handle other exceptions here if necessary...
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// Push the subdirectories onto the stack for traversal.
|
|
// This could also be done before handing the files.
|
|
foreach (var str in subDirs)
|
|
dirs.Push(str);
|
|
}
|
|
|
|
return fileCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to delete the files passed to it. Swallows exceptions.
|
|
/// </summary>
|
|
/// <param name="files">Full path of files to delete</param>
|
|
public static void DeleteFiles(IEnumerable<string> files)
|
|
{
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
new FileInfo(file).Delete();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
/* Swallow exception */
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the human-readable file size for an arbitrary, 64-bit file size
|
|
/// <remarks>The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB"</remarks>
|
|
/// </summary>
|
|
/// https://www.somacon.com/p576.php
|
|
/// <param name="bytes"></param>
|
|
/// <returns></returns>
|
|
public static string GetHumanReadableBytes(long bytes)
|
|
{
|
|
// Get absolute value
|
|
var absoluteBytes = (bytes < 0 ? -bytes : bytes);
|
|
// Determine the suffix and readable value
|
|
string suffix;
|
|
double readable;
|
|
switch (absoluteBytes)
|
|
{
|
|
// Exabyte
|
|
case >= 0x1000000000000000:
|
|
suffix = "EB";
|
|
readable = (bytes >> 50);
|
|
break;
|
|
// Petabyte
|
|
case >= 0x4000000000000:
|
|
suffix = "PB";
|
|
readable = (bytes >> 40);
|
|
break;
|
|
// Terabyte
|
|
case >= 0x10000000000:
|
|
suffix = "TB";
|
|
readable = (bytes >> 30);
|
|
break;
|
|
// Gigabyte
|
|
case >= 0x40000000:
|
|
suffix = "GB";
|
|
readable = (bytes >> 20);
|
|
break;
|
|
// Megabyte
|
|
case >= 0x100000:
|
|
suffix = "MB";
|
|
readable = (bytes >> 10);
|
|
break;
|
|
// Kilobyte
|
|
case >= 0x400:
|
|
suffix = "KB";
|
|
readable = bytes;
|
|
break;
|
|
default:
|
|
return bytes.ToString("0 B"); // Byte
|
|
}
|
|
// Divide by 1024 to get fractional value
|
|
readable = (readable / 1024);
|
|
// Return formatted number with suffix
|
|
return readable.ToString("0.## ") + suffix;
|
|
}
|
|
}
|
|
}
|