Refactored archive code into a service so that I can write tests for it.
@ -25,7 +25,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Services\Test Data" />
|
<Folder Include="Services\Test Data\ArchiveService" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@ -109,6 +109,7 @@ namespace API.Tests
|
|||||||
[InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")]
|
[InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")]
|
||||||
[InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")]
|
[InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")]
|
||||||
[InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "11")]
|
[InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "11")]
|
||||||
|
[InlineData("Beelzebub_53[KSH].zip", "53")]
|
||||||
//[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "6")]
|
//[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "6")]
|
||||||
public void ParseChaptersTest(string filename, string expected)
|
public void ParseChaptersTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
|
37
API.Tests/Services/ArchiveServiceTests.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using API.Extensions;
|
||||||
|
using API.Interfaces;
|
||||||
|
using API.Services;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace API.Tests.Services
|
||||||
|
{
|
||||||
|
public class ArchiveServiceTests
|
||||||
|
{
|
||||||
|
private readonly IArchiveService _archiveService;
|
||||||
|
private readonly ILogger<ArchiveService> _logger = Substitute.For<ILogger<ArchiveService>>();
|
||||||
|
|
||||||
|
private readonly string _testDirectory =
|
||||||
|
Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService");
|
||||||
|
|
||||||
|
public ArchiveServiceTests()
|
||||||
|
{
|
||||||
|
_archiveService = new ArchiveService(_logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("flat file.zip", false)]
|
||||||
|
[InlineData("file in folder in folder.zip", true)]
|
||||||
|
[InlineData("file in folder.zip", true)]
|
||||||
|
[InlineData("file in folder_alt.zip", true)]
|
||||||
|
public void ArchiveNeedsFlatteningTest(string archivePath, bool expected)
|
||||||
|
{
|
||||||
|
var file = Path.Join(_testDirectory, archivePath);
|
||||||
|
using ZipArchive archive = ZipFile.OpenRead(file);
|
||||||
|
Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,6 +12,7 @@ namespace API.Tests.Services
|
|||||||
private readonly ScannerService _scannerService;
|
private readonly ScannerService _scannerService;
|
||||||
private readonly ILogger<ScannerService> _logger = Substitute.For<ILogger<ScannerService>>();
|
private readonly ILogger<ScannerService> _logger = Substitute.For<ILogger<ScannerService>>();
|
||||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||||
|
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService");
|
||||||
public ScannerServiceTests()
|
public ScannerServiceTests()
|
||||||
{
|
{
|
||||||
_scannerService = new ScannerService(_unitOfWork, _logger);
|
_scannerService = new ScannerService(_unitOfWork, _logger);
|
||||||
@ -23,9 +24,8 @@ namespace API.Tests.Services
|
|||||||
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
|
[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
|
||||||
public void GetCoverImageTest(string inputFile, string expectedOutputFile)
|
public void GetCoverImageTest(string inputFile, string expectedOutputFile)
|
||||||
{
|
{
|
||||||
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageProvider");
|
var expectedBytes = File.ReadAllBytes(Path.Join(_testDirectory, expectedOutputFile));
|
||||||
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
|
Assert.Equal(expectedBytes, _scannerService.GetCoverImage(Path.Join(_testDirectory, inputFile)));
|
||||||
Assert.Equal(expectedBytes, _scannerService.GetCoverImage(Path.Join(testDirectory, inputFile)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
2
API.Tests/Services/Test Data/ArchiveService/LICENSE.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
Files in this test are all royalty free and can be found here:
|
||||||
|
https://www.pexels.com/photo/snow-wood-light-art-6551949/
|
BIN
API.Tests/Services/Test Data/ArchiveService/file in folder.zip
Normal file
BIN
API.Tests/Services/Test Data/ArchiveService/flat file.zip
Normal file
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 395 KiB After Width: | Height: | Size: 395 KiB |
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 385 KiB |
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 344 KiB |
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 385 KiB |
@ -133,6 +133,8 @@ namespace API.Data
|
|||||||
var volumeList = new List<VolumeDto>() {volume};
|
var volumeList = new List<VolumeDto>() {volume};
|
||||||
await AddVolumeModifiers(userId, volumeList);
|
await AddVolumeModifiers(userId, volumeList);
|
||||||
|
|
||||||
|
volumeList[0].Files = volumeList[0].Files.OrderBy(f => f.Chapter).ToList();
|
||||||
|
|
||||||
return volumeList[0];
|
return volumeList[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
10
API/Interfaces/IArchiveService.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace API.Interfaces
|
||||||
|
{
|
||||||
|
public interface IArchiveService
|
||||||
|
{
|
||||||
|
bool ArchiveNeedsFlattening(ZipArchive archive);
|
||||||
|
public void ExtractArchive(string archivePath, string extractPath);
|
||||||
|
}
|
||||||
|
}
|
@ -125,7 +125,7 @@ namespace API.Parser
|
|||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
// Beelzebub_01_[Noodles].zip
|
// Beelzebub_01_[Noodles].zip
|
||||||
new Regex(
|
new Regex(
|
||||||
@"^((?!v|vo|vol|Volume).)*( |_)(?<Chapter>\.?\d+)( |_)",
|
@"^((?!v|vo|vol|Volume).)*( |_)(?<Chapter>\.?\d+)( |_|\[|\()",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||||
// Yumekui-Merry_DKThias_Chapter21.zip
|
// Yumekui-Merry_DKThias_Chapter21.zip
|
||||||
new Regex(
|
new Regex(
|
||||||
|
74
API/Services/ArchiveService.cs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using API.Extensions;
|
||||||
|
using API.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Responsible for manipulating Archive files. Used by <see cref="CacheService"/> almost exclusively.
|
||||||
|
/// </summary>
|
||||||
|
public class ArchiveService : IArchiveService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ArchiveService> _logger;
|
||||||
|
|
||||||
|
public ArchiveService(ILogger<ArchiveService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
// return archive.Entries.Count > 0 &&
|
||||||
|
// archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar));
|
||||||
|
//return archive.Entries.Count > 0 && !Path.HasExtension(archive.Entries.ElementAt(0).FullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath))
|
||||||
|
{
|
||||||
|
_logger.LogError($"Archive {archivePath} could not be found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(extractPath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stopwatch sw = Stopwatch.StartNew();
|
||||||
|
using ZipArchive archive = ZipFile.OpenRead(archivePath);
|
||||||
|
var needsFlattening = ArchiveNeedsFlattening(archive);
|
||||||
|
if (!archive.HasFiles() && !needsFlattening) return;
|
||||||
|
|
||||||
|
archive.ExtractToDirectory(extractPath);
|
||||||
|
_logger.LogDebug($"Extracted archive to {extractPath} in {sw.ElapsedMilliseconds} milliseconds.");
|
||||||
|
|
||||||
|
if (needsFlattening)
|
||||||
|
{
|
||||||
|
sw = Stopwatch.StartNew();
|
||||||
|
_logger.LogInformation("Extracted archive is nested in root folder, flattening...");
|
||||||
|
new DirectoryInfo(extractPath).Flatten();
|
||||||
|
_logger.LogInformation($"Flattened in {sw.ElapsedMilliseconds} milliseconds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Comparators;
|
using API.Comparators;
|
||||||
@ -17,14 +16,16 @@ namespace API.Services
|
|||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly ILogger<CacheService> _logger;
|
private readonly ILogger<CacheService> _logger;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly IArchiveService _archiveService;
|
||||||
private readonly NumericComparer _numericComparer;
|
private readonly NumericComparer _numericComparer;
|
||||||
public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/"));
|
public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/"));
|
||||||
|
|
||||||
public CacheService(IDirectoryService directoryService, ILogger<CacheService> logger, IUnitOfWork unitOfWork)
|
public CacheService(IDirectoryService directoryService, ILogger<CacheService> logger, IUnitOfWork unitOfWork, IArchiveService archiveService)
|
||||||
{
|
{
|
||||||
_directoryService = directoryService;
|
_directoryService = directoryService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
_archiveService = archiveService;
|
||||||
_numericComparer = new NumericComparer();
|
_numericComparer = new NumericComparer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,7 +47,7 @@ namespace API.Services
|
|||||||
foreach (var file in volume.Files)
|
foreach (var file in volume.Files)
|
||||||
{
|
{
|
||||||
var extractPath = GetVolumeCachePath(volumeId, file);
|
var extractPath = GetVolumeCachePath(volumeId, file);
|
||||||
ExtractArchive(file.FilePath, extractPath);
|
_archiveService.ExtractArchive(file.FilePath, extractPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return volume;
|
return volume;
|
||||||
@ -92,44 +93,7 @@ namespace API.Services
|
|||||||
_logger.LogInformation("Cache directory purged");
|
_logger.LogInformation("Cache directory purged");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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>
|
|
||||||
private void ExtractArchive(string archivePath, string extractPath)
|
|
||||||
{
|
|
||||||
if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath))
|
|
||||||
{
|
|
||||||
_logger.LogError($"Archive {archivePath} could not be found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Directory.Exists(extractPath))
|
|
||||||
{
|
|
||||||
_logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Stopwatch sw = Stopwatch.StartNew();
|
|
||||||
using ZipArchive archive = ZipFile.OpenRead(archivePath);
|
|
||||||
var needsFlattening = archive.Entries.Count > 0 && !Path.HasExtension(archive.Entries.ElementAt(0).FullName);
|
|
||||||
if (!archive.HasFiles() && !needsFlattening) return;
|
|
||||||
|
|
||||||
archive.ExtractToDirectory(extractPath);
|
|
||||||
_logger.LogDebug($"Extracted archive to {extractPath} in {sw.ElapsedMilliseconds} milliseconds.");
|
|
||||||
|
|
||||||
if (needsFlattening)
|
|
||||||
{
|
|
||||||
sw = Stopwatch.StartNew();
|
|
||||||
_logger.LogInformation("Extracted archive is nested in root folder, flattening...");
|
|
||||||
new DirectoryInfo(extractPath).Flatten();
|
|
||||||
_logger.LogInformation($"Flattened in {sw.ElapsedMilliseconds} milliseconds");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private string GetVolumeCachePath(int volumeId, MangaFile file)
|
private string GetVolumeCachePath(int volumeId, MangaFile file)
|
||||||
@ -142,11 +106,18 @@ namespace API.Services
|
|||||||
return extractPath;
|
return extractPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<MangaFile> GetOrderedChapters(ICollection<MangaFile> files)
|
||||||
|
{
|
||||||
|
return files.OrderBy(f => f.Chapter).Where(f => f.Chapter != 0);
|
||||||
|
}
|
||||||
|
|
||||||
public string GetCachedPagePath(Volume volume, int page)
|
public string GetCachedPagePath(Volume volume, int page)
|
||||||
{
|
{
|
||||||
// Calculate what chapter the page belongs to
|
// Calculate what chapter the page belongs to
|
||||||
var pagesSoFar = 0;
|
var pagesSoFar = 0;
|
||||||
foreach (var mangaFile in volume.Files.OrderBy(f => f.Chapter))
|
// Do not allow chapters with 0, as those are specials and break ordering for reading.
|
||||||
|
var orderedChapters = GetOrderedChapters(volume.Files);
|
||||||
|
foreach (var mangaFile in orderedChapters)
|
||||||
{
|
{
|
||||||
if (page + 1 < (mangaFile.NumberOfPages + pagesSoFar))
|
if (page + 1 < (mangaFile.NumberOfPages + pagesSoFar))
|
||||||
{
|
{
|
||||||
|