Unit Tests & New Natural Sort (#941)

* Added a lot of tests

* More tests! Added a Parser.NormalizePath to normalize all paths within Kavita.

* Fixed a bug where MarkChaptersAsUnread implementation wasn't consistent between different files and lead to extra row generation for no reason.

* Added more unit tests

* Found a better implementation for Natural Sorting. Added tests and validate it works. Next commit will swap out natural Sort for new Extension.

* Replaced NaturalSortComparer with OrderByNatural.

* Drastically simplified and sped up FindFirstEntry for finding cover images in archives

* Initial fix for a epub bug where metadata defines key as absolute path but document uses a relative path. We now have a hack to correct for the epub.
This commit is contained in:
Joseph Milazzo 2022-01-15 07:39:34 -08:00 committed by GitHub
parent 71d42b1c8b
commit 591b574706
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1533 additions and 314 deletions

1
.gitignore vendored
View File

@ -522,3 +522,4 @@ API/config/pre-metadata/
API/config/post-metadata/ API/config/post-metadata/
API.Tests/TestResults/ API.Tests/TestResults/
UI/Web/.vscode/settings.json UI/Web/.vscode/settings.json
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using API.Comparators; using API.Comparators;
using API.DTOs; using API.DTOs;
using API.Extensions;
using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order; using BenchmarkDotNet.Order;
@ -16,9 +17,6 @@ namespace API.Benchmark
[RankColumn] [RankColumn]
public class TestBenchmark public class TestBenchmark
{ {
private readonly NaturalSortComparer _naturalSortComparer = new ();
private static IEnumerable<VolumeDto> GenerateVolumes(int max) private static IEnumerable<VolumeDto> GenerateVolumes(int max)
{ {
var random = new Random(); var random = new Random();
@ -50,11 +48,11 @@ namespace API.Benchmark
return list; return list;
} }
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes) private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{ {
foreach (var v in volumes.Where(vDto => vDto.Number == 0)) foreach (var v in volumes.Where(vDto => vDto.Number == 0))
{ {
v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList(); v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList();
} }
} }

View File

@ -0,0 +1,17 @@
using System.Linq;
using API.Comparators;
using Xunit;
namespace API.Tests.Comparers;
public class ChapterSortComparerZeroFirstTests
{
[Theory]
[InlineData(new[] {1, 2, 0}, new[] {0, 1, 2,})]
[InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})]
[InlineData(new[] {1, 0, 0}, new[] {0, 0, 1})]
public void ChapterSortComparerZeroFirstTest(int[] input, int[] expected)
{
Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray());
}
}

View File

@ -0,0 +1,26 @@
using System;
using API.Comparators;
using Xunit;
namespace API.Tests.Comparers;
public class NumericComparerTests
{
[Theory]
[InlineData(
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"}
)]
public void NumericComparerTest(string[] input, string[] expected)
{
var nc = new NumericComparer();
Array.Sort(input, nc);
var i = 0;
foreach (var s in input)
{
Assert.Equal(s, expected[i]);
i++;
}
}
}

View File

@ -11,10 +11,17 @@ namespace API.Tests.Comparers
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"}
)] )]
public void TestLogicalComparer(string[] input, string[] expected) [InlineData(
new[] {"a.jpg", "aaa.jpg", "1.jpg", },
new[] {"1.jpg", "a.jpg", "aaa.jpg"}
)]
[InlineData(
new[] {"a.jpg", "aaa.jpg", "1.jpg", "!cover.png"},
new[] {"!cover.png", "1.jpg", "a.jpg", "aaa.jpg"}
)]
public void StringComparer(string[] input, string[] expected)
{ {
NumericComparer nc = new NumericComparer(); Array.Sort(input, StringLogicalComparer.Compare);
Array.Sort(input, nc);
var i = 0; var i = 0;
foreach (var s in input) foreach (var s in input)

View File

@ -9,6 +9,8 @@ namespace API.Tests.Converters
[InlineData("daily", "0 0 * * *")] [InlineData("daily", "0 0 * * *")]
[InlineData("disabled", "0 0 31 2 *")] [InlineData("disabled", "0 0 31 2 *")]
[InlineData("weekly", "0 0 * * 1")] [InlineData("weekly", "0 0 * * 1")]
[InlineData("", "0 0 31 2 *")]
[InlineData("sdfgdf", "")]
public void ConvertTest(string input, string expected) public void ConvertTest(string input, string expected)
{ {
Assert.Equal(expected, CronConverter.ConvertToCronNotation(input)); Assert.Equal(expected, CronConverter.ConvertToCronNotation(input));

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
@ -9,7 +10,7 @@ namespace API.Tests.Extensions
{ {
public class ChapterListExtensionsTests public class ChapterListExtensionsTests
{ {
private Chapter CreateChapter(string range, string number, MangaFile file, bool isSpecial) private static Chapter CreateChapter(string range, string number, MangaFile file, bool isSpecial)
{ {
return new Chapter() return new Chapter()
{ {
@ -20,7 +21,7 @@ namespace API.Tests.Extensions
}; };
} }
private MangaFile CreateFile(string file, MangaFormat format) private static MangaFile CreateFile(string file, MangaFormat format)
{ {
return new MangaFile() return new MangaFile()
{ {
@ -80,7 +81,37 @@ namespace API.Tests.Extensions
var actualChapter = chapterList.GetChapterByRange(info); var actualChapter = chapterList.GetChapterByRange(info);
Assert.Equal(chapterList[0], actualChapter); Assert.Equal(chapterList[0], actualChapter);
}
} #region GetFirstChapterWithFiles
[Fact]
public void GetFirstChapterWithFiles_ShouldReturnAllChapters()
{
var chapterList = new List<Chapter>()
{
CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true),
CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false),
};
Assert.Equal(chapterList.First(), chapterList.GetFirstChapterWithFiles());
}
[Fact]
public void GetFirstChapterWithFiles_ShouldReturnSecondChapter()
{
var chapterList = new List<Chapter>()
{
CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true),
CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false),
};
chapterList.First().Files = new List<MangaFile>();
Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles());
}
#endregion
} }
} }

View File

@ -1,14 +1,11 @@
using System; using System.Linq;
using System.Linq; using API.Extensions;
using API.Comparators;
using Xunit; using Xunit;
namespace API.Tests.Comparers namespace API.Tests.Extensions;
{
public class NaturalSortComparerTest
{
private readonly NaturalSortComparer _nc = new NaturalSortComparer();
public class EnumerableExtensionsTests
{
[Theory] [Theory]
[InlineData( [InlineData(
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
@ -40,7 +37,7 @@ namespace API.Tests.Comparers
)] )]
[InlineData( [InlineData(
new[] {"3and4.cbz", "The World God Only Knows - Oneshot.cbz", "5.cbz", "1and2.cbz"}, new[] {"3and4.cbz", "The World God Only Knows - Oneshot.cbz", "5.cbz", "1and2.cbz"},
new[] {"The World God Only Knows - Oneshot.cbz", "1and2.cbz", "3and4.cbz", "5.cbz"} new[] {"1and2.cbz", "3and4.cbz", "5.cbz", "The World God Only Knows - Oneshot.cbz"}
)] )]
[InlineData( [InlineData(
new[] {"Solo Leveling - c000 (v01) - p000 [Cover] [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p001 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p002 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p003 [dig] [Yen Press] [LuCaZ].jpg"}, new[] {"Solo Leveling - c000 (v01) - p000 [Cover] [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p001 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p002 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p003 [dig] [Yen Press] [LuCaZ].jpg"},
@ -51,12 +48,20 @@ namespace API.Tests.Comparers
new[] {"Marvel2In1-7", "Marvel2In1-7-01", "Marvel2In1-7-02"} new[] {"Marvel2In1-7", "Marvel2In1-7-01", "Marvel2In1-7-02"}
)] )]
[InlineData( [InlineData(
new[] {"!001", "001", "002"}, new[] {"001", "002", "!001"},
new[] {"!001", "001", "002"} new[] {"!001", "001", "002"}
)] )]
[InlineData( [InlineData(
new[] {"001", "", null}, new[] {"001.jpg", "002.jpg", "!001.jpg"},
new[] {"", "001", null} new[] {"!001.jpg", "001.jpg", "002.jpg"}
)]
[InlineData(
new[] {"001", "002", "!002"},
new[] {"!002", "001", "002"}
)]
[InlineData(
new[] {"001", ""},
new[] {"", "001"}
)] )]
[InlineData( [InlineData(
new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.020/001.jpg"}, new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.020/001.jpg"},
@ -66,13 +71,15 @@ namespace API.Tests.Comparers
new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}, new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"},
new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"} new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}
)] )]
public void TestNaturalSortComparer(string[] input, string[] expected) [InlineData(
new[] {"01/001.jpg", "001.jpg"},
new[] {"001.jpg", "01/001.jpg"}
)]
public void TestNaturalSort(string[] input, string[] expected)
{ {
Array.Sort(input, _nc); Assert.Equal(expected, input.OrderByNatural(x => x).ToArray());
Assert.Equal(expected, input);
} }
[Theory] [Theory]
[InlineData( [InlineData(
new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"},
@ -98,6 +105,10 @@ namespace API.Tests.Comparers
new[] {"001.jpg", "10.jpg",}, new[] {"001.jpg", "10.jpg",},
new[] {"001.jpg", "10.jpg",} new[] {"001.jpg", "10.jpg",}
)] )]
[InlineData(
new[] {"001", "002", "!001"},
new[] {"!001", "001", "002"}
)]
[InlineData( [InlineData(
new[] {"10/001.jpg", "10.jpg",}, new[] {"10/001.jpg", "10.jpg",},
new[] {"10.jpg", "10/001.jpg",} new[] {"10.jpg", "10/001.jpg",}
@ -110,9 +121,9 @@ namespace API.Tests.Comparers
new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg"}, new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg"},
new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg"} new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg"}
)] )]
public void TestNaturalSortComparerLinq(string[] input, string[] expected) public void TestNaturalSortLinq(string[] input, string[] expected)
{ {
var output = input.OrderBy(c => c, _nc); var output = input.OrderByNatural(x => x);
var i = 0; var i = 0;
foreach (var s in output) foreach (var s in output)
@ -122,4 +133,3 @@ namespace API.Tests.Comparers
} }
} }
} }
}

View File

@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using API.DTOs.Filtering;
using API.Entities.Enums;
using API.Extensions;
using Xunit;
namespace API.Tests.Extensions;
public class FilterDtoExtensionsTests
{
[Fact]
public void GetSqlFilter_ShouldReturnAllFormats()
{
var filter = new FilterDto()
{
Formats = null
};
Assert.Equal(Enum.GetValues<MangaFormat>(), filter.GetSqlFilter());
}
[Fact]
public void GetSqlFilter_ShouldReturnAllFormats2()
{
var filter = new FilterDto()
{
Formats = new List<MangaFormat>()
};
Assert.Equal(Enum.GetValues<MangaFormat>(), filter.GetSqlFilter());
}
[Fact]
public void GetSqlFilter_ShouldReturnJust2()
{
var formats = new List<MangaFormat>()
{
MangaFormat.Archive, MangaFormat.Epub
};
var filter = new FilterDto()
{
Formats = formats
};
Assert.Equal(formats, filter.GetSqlFilter());
}
}

View File

@ -1,7 +1,11 @@
using API.Entities; using System.Linq;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
using API.Extensions; using API.Extensions;
using API.Parser; using API.Parser;
using API.Services.Tasks.Scanner;
using API.Tests.Helpers;
using Xunit; using Xunit;
namespace API.Tests.Extensions namespace API.Tests.Extensions
@ -32,6 +36,38 @@ namespace API.Tests.Extensions
Assert.Equal(expected, series.NameInList(list)); Assert.Equal(expected, series.NameInList(list));
} }
[Theory]
[InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker than Black"}, MangaFormat.Archive, true)]
[InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker_than_Black"}, MangaFormat.Archive, true)]
[InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker then Black!"}, MangaFormat.Archive, false)]
[InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"Salem's Lot"}, MangaFormat.Archive, true)]
[InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salems lot"}, MangaFormat.Archive, true)]
[InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salem's lot"}, MangaFormat.Archive, true)]
// Different normalizations pass as we check normalization against an on-the-fly calculation so we don't delete series just because we change how normalization works
[InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot", "salems lot"}, new [] {"salem's lot"}, MangaFormat.Archive, true)]
[InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, new [] {"Kanojo, Okarishimasu"}, MangaFormat.Archive, true)]
public void NameInListParserInfoTest(string[] seriesInput, string[] list, MangaFormat format, bool expected)
{
var series = new Series()
{
Name = seriesInput[0],
LocalizedName = seriesInput[1],
OriginalName = seriesInput[2],
NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Parser.Parser.Normalize(seriesInput[0]),
Metadata = new SeriesMetadata(),
};
var parserInfos = list.Select(s => new ParsedSeries()
{
Name = s,
NormalizedName = API.Parser.Parser.Normalize(s),
}).ToList();
// This doesn't do any checks against format
Assert.Equal(expected, series.NameInList(parserInfos));
}
[Theory] [Theory]
[InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, "Darker than Black", true)] [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, "Darker than Black", true)]
[InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, "Kanojo, Okarishimasu", true)] [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, "Kanojo, Okarishimasu", true)]

View File

@ -0,0 +1,177 @@
using System.Collections.Generic;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Tests.Helpers;
using Xunit;
namespace API.Tests.Extensions;
public class VolumeListExtensionsTests
{
#region FirstWithChapters
[Fact]
public void FirstWithChapters_ReturnsVolumeWithChapters()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("2", false),
}),
};
Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(false).Number);
Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(true).Number);
}
[Fact]
public void FirstWithChapters_Book()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(true).Number);
}
[Fact]
public void FirstWithChapters_NonBook()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(false).Number);
}
#endregion
#region GetCoverImage
[Fact]
public void GetCoverImage_ArchiveFormat()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[0].Number, volumes.GetCoverImage(MangaFormat.Archive).Number);
}
[Fact]
public void GetCoverImage_EpubFormat()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Epub).Name);
}
[Fact]
public void GetCoverImage_PdfFormat()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Pdf).Name);
}
[Fact]
public void GetCoverImage_ImageFormat()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[0].Name, volumes.GetCoverImage(MangaFormat.Image).Name);
}
[Fact]
public void GetCoverImage_ImageFormat_NoSpecials()
{
var volumes = new List<Volume>()
{
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("3", false),
EntityFactory.CreateChapter("4", false),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
EntityFactory.CreateChapter("0", true),
}),
};
Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Image).Name);
}
#endregion
}

View File

@ -5,6 +5,8 @@ using System.IO.Abstractions.TestingHelpers;
using API.Entities; using API.Entities;
using API.Helpers; using API.Helpers;
using API.Services; using API.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit; using Xunit;
namespace API.Tests.Helpers; namespace API.Tests.Helpers;
@ -287,4 +289,5 @@ public class CacheHelperTests
}; };
Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file));
} }
} }

View File

@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata; using API.Entities.Metadata;
@ -28,6 +29,7 @@ namespace API.Tests.Helpers
return new Volume() return new Volume()
{ {
Name = volumeNumber, Name = volumeNumber,
Number = int.Parse(volumeNumber),
Pages = 0, Pages = 0,
Chapters = chapters ?? new List<Chapter>() Chapters = chapters ?? new List<Chapter>()
}; };

View File

@ -152,7 +152,7 @@ namespace API.Tests.Parser
[InlineData("test.jpeg", true)] [InlineData("test.jpeg", true)]
[InlineData("test.png", true)] [InlineData("test.png", true)]
[InlineData(".test.jpg", false)] [InlineData(".test.jpg", false)]
[InlineData("!test.jpg", false)] [InlineData("!test.jpg", true)]
[InlineData("test.webp", true)] [InlineData("test.webp", true)]
public void IsImageTest(string filename, bool expected) public void IsImageTest(string filename, bool expected)
{ {
@ -188,5 +188,17 @@ namespace API.Tests.Parser
{ {
Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath)); Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath));
} }
[Theory]
[InlineData("/manga/1/1/1", "/manga/1/1/1")]
[InlineData("/manga/1/1/1.jpg", "/manga/1/1/1.jpg")]
[InlineData(@"/manga/1/1\1.jpg", @"/manga/1/1/1.jpg")]
[InlineData("/manga/1/1//1", "/manga/1/1//1")]
[InlineData("/manga/1\\1\\1", "/manga/1/1/1")]
[InlineData("C:/manga/1\\1\\1.jpg", "C:/manga/1/1/1.jpg")]
public void NormalizePathTest(string inputPath, string expected)
{
Assert.Equal(expected, NormalizePath(inputPath));
}
} }
} }

View File

@ -138,7 +138,7 @@ namespace API.Tests.Services
[InlineData(new [] {"Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg", "Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg", "Akame ga KILL! ZERO - c060 (v10) - p200 [Digital] [LuCaZ].jpg", "folder.jpg"}, "folder.jpg")] [InlineData(new [] {"Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg", "Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg", "Akame ga KILL! ZERO - c060 (v10) - p200 [Digital] [LuCaZ].jpg", "folder.jpg"}, "folder.jpg")]
public void FindFolderEntry(string[] files, string expected) public void FindFolderEntry(string[] files, string expected)
{ {
var foundFile = _archiveService.FindFolderEntry(files); var foundFile = ArchiveService.FindFolderEntry(files);
Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile);
} }
@ -203,26 +203,43 @@ namespace API.Tests.Services
[InlineData("sorting.zip", "sorting.expected.jpg")] [InlineData("sorting.zip", "sorting.expected.jpg")]
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
{ {
var archiveService = Substitute.For<ArchiveService>(_logger, new DirectoryService(_directoryServiceLogger, new MockFileSystem())); var imageService = new ImageService(Substitute.For<ILogger<ImageService>>(), _directoryService);
var archiveService = Substitute.For<ArchiveService>(_logger,
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService);
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages");
var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
var outputDir = Path.Join(testDirectory, "output");
_directoryService.ClearDirectory(outputDir);
_directoryService.ExistOrCreate(outputDir);
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress); archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress);
Stopwatch sw = Stopwatch.StartNew(); var actualBytes = File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
//Assert.Equal(expectedBytes, File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), Path.GetFileNameWithoutExtension(inputFile) + "_output"))); Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir));
_testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); Assert.Equal(expectedBytes, actualBytes);
_directoryService.ClearAndDeleteDirectory(outputDir);
} }
// TODO: This is broken on GA due to DirectoryService.CoverImageDirectory [Theory]
//[Theory]
[InlineData("Archives/macos_native.zip")] [InlineData("Archives/macos_native.zip")]
[InlineData("Formats/One File with DB_Supported.zip")] [InlineData("Formats/One File with DB_Supported.zip")]
public void CanParseCoverImage(string inputFile) public void CanParseCoverImage(string inputFile)
{ {
var imageService = Substitute.For<IImageService>();
imageService.WriteCoverThumbnail(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<string>()).Returns(x => "cover.jpg");
var archiveService = new ArchiveService(_logger, _directoryService, imageService);
var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/"); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/");
//Assert.NotEmpty(File.ReadAllBytes(_archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), Path.GetFileNameWithoutExtension(inputFile) + "_output"))); var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile));
var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output");
new DirectoryInfo(outputPath).Create();
var expectedImage = archiveService.GetCoverImage(inputPath, inputFile, outputPath);
Assert.Equal("cover.jpg", expectedImage);
new DirectoryInfo(outputPath).Delete();
} }
#region ShouldHaveComicInfo
[Fact] [Fact]
public void ShouldHaveComicInfo() public void ShouldHaveComicInfo()
{ {
@ -246,6 +263,10 @@ namespace API.Tests.Services
Assert.Equal("Junya Inoue", comicInfo.Writer); Assert.Equal("Junya Inoue", comicInfo.Writer);
} }
#endregion
#region CanParseComicInfo
[Fact] [Fact]
public void CanParseComicInfo() public void CanParseComicInfo()
{ {
@ -268,5 +289,38 @@ namespace API.Tests.Services
Assert.NotStrictEqual(expected, actual); Assert.NotStrictEqual(expected, actual);
} }
#endregion
#region FindCoverImageFilename
[Theory]
[InlineData(new string[] {}, "", null)]
[InlineData(new [] {"001.jpg", "002.jpg"}, "Test.zip", "001.jpg")]
[InlineData(new [] {"001.jpg", "!002.jpg"}, "Test.zip", "!002.jpg")]
[InlineData(new [] {"001.jpg", "!001.jpg"}, "Test.zip", "!001.jpg")]
[InlineData(new [] {"001.jpg", "cover.jpg"}, "Test.zip", "cover.jpg")]
[InlineData(new [] {"001.jpg", "Chapter 20/cover.jpg", "Chapter 21/0001.jpg"}, "Test.zip", "Chapter 20/cover.jpg")]
[InlineData(new [] {"._/001.jpg", "._/cover.jpg", "010.jpg"}, "Test.zip", "010.jpg")]
[InlineData(new [] {"001.txt", "002.txt", "a.jpg"}, "Test.zip", "a.jpg")]
public void FindCoverImageFilename(string[] filenames, string archiveName, string expected)
{
Assert.Equal(expected, _archiveService.FindCoverImageFilename(archiveName, filenames));
}
#endregion
#region CreateZipForDownload
//[Fact]
public void CreateZipForDownloadTest()
{
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
//_archiveService.CreateZipForDownload(new []{}, outputDirectory)
}
#endregion
} }
} }

View File

@ -52,5 +52,16 @@ namespace API.Tests.Services
Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer); Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer);
} }
#region BookEscaping
[Fact]
public void EscapeCSSImportReferencesTest()
{
}
#endregion
} }
} }

View File

@ -5,8 +5,10 @@ using System.IO.Abstractions.TestingHelpers;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Metadata;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Parser;
using API.Services; using API.Services;
using API.SignalR; using API.SignalR;
using AutoMapper; using AutoMapper;
@ -20,6 +22,40 @@ using Xunit;
namespace API.Tests.Services namespace API.Tests.Services
{ {
internal class MockReadingItemServiceForCacheService : IReadingItemService
{
private readonly DirectoryService _directoryService;
public MockReadingItemServiceForCacheService(DirectoryService directoryService)
{
_directoryService = directoryService;
}
public ComicInfo GetComicInfo(string filePath)
{
return null;
}
public int GetNumberOfPages(string filePath, MangaFormat format)
{
return 1;
}
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format)
{
return string.Empty;
}
public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1)
{
throw new System.NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, LibraryType type)
{
throw new System.NotImplementedException();
}
}
public class CacheServiceTests public class CacheServiceTests
{ {
private readonly ILogger<CacheService> _logger = Substitute.For<ILogger<CacheService>>(); private readonly ILogger<CacheService> _logger = Substitute.For<ILogger<CacheService>>();
@ -436,5 +472,37 @@ namespace API.Tests.Services
#endregion #endregion
#region ExtractChapterFiles
// [Fact]
// public void ExtractChapterFiles_ShouldExtractOnlyImages()
// {
// const string testDirectory = "/manga/";
// var fileSystem = new MockFileSystem();
// for (var i = 0; i < 10; i++)
// {
// fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData(""));
// }
//
// fileSystem.AddDirectory(CacheDirectory);
//
// var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
// var cs = new CacheService(_logger, _unitOfWork, ds,
// new MockReadingItemServiceForCacheService(ds));
//
//
// cs.ExtractChapterFiles(CacheDirectory, new List<MangaFile>()
// {
// new MangaFile()
// {
// ChapterId = 1,
// Format = MangaFormat.Archive,
// Pages = 2,
// FilePath =
// }
// })
// }
#endregion
} }
} }

View File

@ -776,5 +776,18 @@ namespace API.Tests.Services
#endregion #endregion
#region GetHumanReadableBytes
[Theory]
[InlineData(1200, "1.17 KB")]
[InlineData(1, "1 B")]
[InlineData(10000000, "9.54 MB")]
[InlineData(10000000000, "9.31 GB")]
public void GetHumanReadableBytesTest(long bytes, string expected)
{
Assert.Equal(expected, DirectoryService.GetHumanReadableBytes(bytes));
}
#endregion
} }
} }

View File

@ -0,0 +1,814 @@
using System.Collections.Generic;
using System.Data.Common;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services;
using API.SignalR;
using API.Tests.Helpers;
using AutoMapper;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class ReaderServiceTests
{
private readonly ILogger<CacheService> _logger = Substitute.For<ILogger<CacheService>>();
private readonly IUnitOfWork _unitOfWork;
private readonly IHubContext<MessageHub> _messageHub = Substitute.For<IHubContext<MessageHub>>();
private readonly DbConnection _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string DataDirectory = "C:/data/";
public ReaderServiceTests()
{
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null);
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
public void Dispose() => _connection.Dispose();
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context,
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
_context.ServerSetting.Update(setting);
_context.Library.Add(new Library()
{
Name = "Manga", Folders = new List<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
});
return await _context.SaveChangesAsync() > 0;
}
private async Task ResetDB()
{
_context.Series.RemoveRange(_context.Series.ToList());
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(DataDirectory);
return fileSystem;
}
#endregion
#region FormatBookmarkFolderPath
[Theory]
[InlineData("/manga/", 1, 1, 1, "/manga/1/1/1")]
[InlineData("C:/manga/", 1, 1, 10001, "C:/manga/1/1/10001")]
public void FormatBookmarkFolderPathTest(string baseDir, int userId, int seriesId, int chapterId, string expected)
{
Assert.Equal(expected, ReaderService.FormatBookmarkFolderPath(baseDir, userId, seriesId, chapterId));
}
#endregion
#region CapPageToChapter
[Fact]
public async Task CapPageToChapterTest()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
Pages = 1
}
}
}
}
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
Assert.Equal(0, await readerService.CapPageToChapter(1, -1));
Assert.Equal(1, await readerService.CapPageToChapter(1, 10));
}
#endregion
#region SaveReadingProgress
[Fact]
public async Task SaveReadingProgress_ShouldCreateNewEntity()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
Pages = 1
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var successful = await readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = 1,
PageNum = 1,
SeriesId = 1,
VolumeId = 1,
BookScrollId = null
}, 1);
Assert.True(successful);
Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1));
}
[Fact]
public async Task SaveReadingProgress_ShouldUpdateExisting()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
Pages = 1
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var successful = await readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = 1,
PageNum = 1,
SeriesId = 1,
VolumeId = 1,
BookScrollId = null
}, 1);
Assert.True(successful);
Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1));
Assert.True(await readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = 1,
PageNum = 1,
SeriesId = 1,
VolumeId = 1,
BookScrollId = "/h1/"
}, 1));
Assert.Equal("/h1/", (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).BookScrollId);
}
#endregion
#region MarkChaptersAsRead
[Fact]
public async Task MarkChaptersAsReadTest()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
Pages = 1
},
new Chapter()
{
Pages = 2
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1);
readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters);
await _context.SaveChangesAsync();
Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
}
#endregion
#region MarkChapterAsUnread
[Fact]
public async Task MarkChapterAsUnreadTest()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
new Volume()
{
Chapters = new List<Chapter>()
{
new Chapter()
{
Pages = 1
},
new Chapter()
{
Pages = 2
}
}
}
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList();
readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters);
await _context.SaveChangesAsync();
Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters);
await _context.SaveChangesAsync();
var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses;
Assert.Equal(0, progresses.Max(p => p.PagesRead));
Assert.Equal(2, progresses.Count);
}
#endregion
#region GetNextChapterIdAsync
[Fact]
public async Task GetNextChapterIdAsync_ShouldGetNextVolume()
{
// V1 -> V2
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 1, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
Assert.Equal("2", actualChapter.Range);
}
[Fact]
public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
Assert.Equal("21", actualChapter.Range);
}
[Fact]
public async Task GetNextChapterIdAsync_ShouldNotMoveFromVolumeToSpecial()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
EntityFactory.CreateChapter("B.cbz", true, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1);
Assert.Equal(-1, nextChapter);
}
[Fact]
public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
EntityFactory.CreateChapter("B.cbz", true, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1);
Assert.NotEqual(-1, nextChapter);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
Assert.Equal("B.cbz", actualChapter.Range);
}
#endregion
#region GetPrevChapterIdAsync
[Fact]
public async Task GetPrevChapterIdAsync_ShouldGetPrevVolume()
{
// V1 -> V2
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 2, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
Assert.Equal("1", actualChapter.Range);
}
[Fact]
public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>()),
EntityFactory.CreateChapter("22", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
EntityFactory.CreateChapter("32", false, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
Assert.Equal("2", actualChapter.Range);
}
[Fact]
public async Task GetPrevChapterIdAsync_ShouldMoveFromVolumeToSpecial()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
EntityFactory.CreateChapter("B.cbz", true, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1);
Assert.NotEqual(-1, prevChapter);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
Assert.Equal("B.cbz", actualChapter.Range);
}
[Fact]
public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial()
{
await ResetDB();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("A.cbz", true, new List<MangaFile>()),
EntityFactory.CreateChapter("B.cbz", true, new List<MangaFile>()),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1);
Assert.NotEqual(-1, prevChapter);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
Assert.Equal("A.cbz", actualChapter.Range);
}
#endregion
// #region GetNumberOfPages
//
// [Fact]
// public void GetNumberOfPages_EPUB()
// {
// const string testDirectory = "/manga/";
// var fileSystem = new MockFileSystem();
//
// var actualFile = Path.Join(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService/EPUB"), "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub")
// fileSystem.File.WriteAllBytes("${testDirectory}test.epub", File.ReadAllBytes(actualFile));
//
// fileSystem.AddDirectory(CacheDirectory);
//
// var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
// var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds));
// var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), ds, cs);
//
//
// }
//
//
// #endregion
}

View File

@ -1,111 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using static System.GC;
using static System.String;
namespace API.Comparators
{
/// <summary>
/// Attempts to emulate Windows explorer sorting
/// </summary>
/// <remarks>This is not thread-safe</remarks>
public sealed class NaturalSortComparer : IComparer<string>, IDisposable
{
private readonly bool _isAscending;
private Dictionary<string, string[]> _table = new();
private bool _disposed;
public NaturalSortComparer(bool inAscendingOrder = true)
{
_isAscending = inAscendingOrder;
}
int IComparer<string>.Compare(string? x, string? y)
{
if (x == y) return 0;
if (x != null && y == null) return -1;
if (x == null) return 1;
if (!_table.TryGetValue(x ?? Empty, out var x1))
{
x1 = Regex.Split(x ?? Empty, "([0-9]+)");
_table.Add(x ?? Empty, x1);
}
if (!_table.TryGetValue(y ?? Empty, out var y1))
{
y1 = Regex.Split(y ?? Empty, "([0-9]+)");
_table.Add(y ?? Empty, y1);
}
int returnVal;
for (var i = 0; i < x1.Length && i < y1.Length; i++)
{
if (x1[i] == y1[i]) continue;
if (x1[i] == Empty || y1[i] == Empty) continue;
returnVal = PartCompare(x1[i], y1[i]);
return _isAscending ? returnVal : -returnVal;
}
if (y1.Length > x1.Length)
{
returnVal = -1;
}
else if (x1.Length > y1.Length)
{
returnVal = 1;
}
else
{
returnVal = 0;
}
return _isAscending ? returnVal : -returnVal;
}
private static int PartCompare(string left, string right)
{
if (!int.TryParse(left, out var x))
return Compare(left, right, StringComparison.Ordinal);
if (!int.TryParse(right, out var y))
return Compare(left, right, StringComparison.Ordinal);
return x.CompareTo(y);
}
private void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// called via myClass.Dispose().
_table.Clear();
_table = null;
}
// Release unmanaged resources.
// Set large fields to null.
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
SuppressFinalize(this);
}
~NaturalSortComparer() // the finalizer
{
Dispose(false);
}
}
}

View File

@ -139,15 +139,7 @@ namespace API.Controllers
user.Progresses ??= new List<AppUserProgress>(); user.Progresses ??= new List<AppUserProgress>();
foreach (var volume in volumes) foreach (var volume in volumes)
{ {
foreach (var chapter in volume.Chapters) _readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters);
{
var userProgress = ReaderService.GetUserProgressForChapter(user, chapter);
if (userProgress == null) continue;
userProgress.PagesRead = 0;
userProgress.SeriesId = markReadDto.SeriesId;
userProgress.VolumeId = volume.Id;
}
} }
_unitOfWork.UserRepository.Update(user); _unitOfWork.UserRepository.Update(user);

View File

@ -4,6 +4,7 @@ using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Extensions;
using AutoMapper; using AutoMapper;
using AutoMapper.QueryableExtensions; using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -195,10 +196,9 @@ public class VolumeRepository : IVolumeRepository
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes) private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{ {
var sorter = new NaturalSortComparer();
foreach (var v in volumes.Where(vDto => vDto.Number == 0)) foreach (var v in volumes.Where(vDto => vDto.Number == 0))
{ {
v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList(); v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList();
} }
} }

View File

@ -1,8 +1,30 @@
namespace API.Extensions using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace API.Extensions
{ {
public static class EnumerableExtensions public static class EnumerableExtensions
{ {
private static readonly Regex Regex = new Regex(@"\d+", RegexOptions.Compiled, TimeSpan.FromMilliseconds(500));
/// <summary>
/// A natural sort implementation
/// </summary>
/// <param name="items">IEnumerable to process</param>
/// <param name="selector">Function that produces a string. Does not support null values</param>
/// <param name="stringComparer">Defaults to CurrentCulture</param>
/// <typeparam name="T"></typeparam>
/// <returns>Sorted Enumerable</returns>
public static IEnumerable<T> OrderByNatural<T>(this IEnumerable<T> items, Func<T, string> selector, StringComparer stringComparer = null)
{
var maxDigits = items
.SelectMany(i => Regex.Matches(selector(i))
.Select(digitChunk => (int?)digitChunk.Value.Length))
.Max() ?? 0;
return items.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture);
}
} }
} }

View File

@ -31,15 +31,15 @@ namespace API.Extensions
: infos.Any(v => v.Chapters == chapter.Range); : infos.Any(v => v.Chapters == chapter.Range);
} }
/// <summary> // /// <summary>
/// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos // /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos
/// </summary> // /// </summary>
/// <param name="infos"></param> // /// <param name="infos"></param>
/// <returns></returns> // /// <returns></returns>
public static MangaFormat GetFormat(this IList<ParserInfo> infos) // public static MangaFormat GetFormat(this IList<ParserInfo> infos)
{ // {
if (infos.Count == 0) return MangaFormat.Unknown; // if (infos.Count == 0) return MangaFormat.Unknown;
return infos.DistinctBy(x => x.Format).First().Format; // return infos.DistinctBy(x => x.Format).First().Format;
} // }
} }
} }

View File

@ -8,6 +8,7 @@ public static class PathExtensions
{ {
if (string.IsNullOrEmpty(filepath)) return filepath; if (string.IsNullOrEmpty(filepath)) return filepath;
var extension = Path.GetExtension(filepath); var extension = Path.GetExtension(filepath);
if (string.IsNullOrEmpty(extension)) return filepath;
return Path.GetFullPath(filepath.Replace(extension, string.Empty)); return Path.GetFullPath(filepath.Replace(extension, string.Empty));
} }
} }

View File

@ -9,7 +9,7 @@ namespace API.Extensions
public static class SeriesExtensions public static class SeriesExtensions
{ {
/// <summary> /// <summary>
/// Checks against all the name variables of the Series if it matches anything in the list. /// Checks against all the name variables of the Series if it matches anything in the list. This does not check against format.
/// </summary> /// </summary>
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="list"></param> /// <param name="list"></param>

View File

@ -12,7 +12,8 @@ namespace API.Extensions
{ {
return inBookSeries return inBookSeries
? volumes.FirstOrDefault(v => v.Chapters.Any()) ? volumes.FirstOrDefault(v => v.Chapters.Any())
: volumes.OrderBy(v => v.Number, new ChapterSortComparer()).FirstOrDefault(v => v.Chapters.Any()); : volumes.OrderBy(v => v.Number, new ChapterSortComparer())
.FirstOrDefault(v => v.Chapters.Any());
} }
/// <summary> /// <summary>

View File

@ -26,16 +26,16 @@ namespace API.Helpers.Converters
return destination; return destination;
} }
public static string ConvertFromCronNotation(string cronNotation) // public static string ConvertFromCronNotation(string cronNotation)
{ // {
var destination = string.Empty; // var destination = string.Empty;
destination = cronNotation.ToLower() switch // destination = cronNotation.ToLower() switch
{ // {
"0 0 31 2 *" => "disabled", // "0 0 31 2 *" => "disabled",
_ => destination // _ => destination
}; // };
//
return destination; // return destination;
} // }
} }
} }

View File

@ -916,10 +916,9 @@ namespace API.Parser
return BookFileRegex.IsMatch(Path.GetExtension(filePath)); return BookFileRegex.IsMatch(Path.GetExtension(filePath));
} }
public static bool IsImage(string filePath, bool suppressExtraChecks = false) public static bool IsImage(string filePath)
{ {
if (filePath.StartsWith(".") || (!suppressExtraChecks && filePath.StartsWith("!"))) return false; return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath));
return ImageRegex.IsMatch(Path.GetExtension(filePath));
} }
public static bool IsXml(string filePath) public static bool IsXml(string filePath)
@ -959,7 +958,7 @@ namespace API.Parser
/// <returns></returns> /// <returns></returns>
public static bool IsCoverImage(string filename) public static bool IsCoverImage(string filename)
{ {
return IsImage(filename, true) && CoverImageRegex.IsMatch(filename); return IsImage(filename) && CoverImageRegex.IsMatch(filename);
} }
public static bool HasBlacklistedFolderInPath(string path) public static bool HasBlacklistedFolderInPath(string path)
@ -989,5 +988,16 @@ namespace API.Parser
if (string.IsNullOrEmpty(author)) return string.Empty; if (string.IsNullOrEmpty(author)) return string.Empty;
return author.Trim(); return author.Trim();
} }
/// <summary>
/// Normalizes the slashes in a path to be <see cref="Path.AltDirectorySeparatorChar"/>
/// </summary>
/// <example>/manga/1\1 -> /manga/1/1</example>
/// <param name="path"></param>
/// <returns></returns>
public static string NormalizePath(string path)
{
return path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
} }
} }

View File

@ -28,6 +28,7 @@ namespace API.Services
ArchiveLibrary CanOpen(string archivePath); ArchiveLibrary CanOpen(string archivePath);
bool ArchiveNeedsFlattening(ZipArchive archive); bool ArchiveNeedsFlattening(ZipArchive archive);
Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder); Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder);
string FindCoverImageFilename(string archivePath, IList<string> entryNames);
} }
/// <summary> /// <summary>
@ -124,55 +125,27 @@ namespace API.Services
/// </summary> /// </summary>
/// <param name="entryFullNames"></param> /// <param name="entryFullNames"></param>
/// <returns>Entry name of match, null if no match</returns> /// <returns>Entry name of match, null if no match</returns>
public string FindFolderEntry(IEnumerable<string> entryFullNames) public static string FindFolderEntry(IEnumerable<string> entryFullNames)
{ {
var result = entryFullNames var result = entryFullNames
.FirstOrDefault(x => !Path.EndsInDirectorySeparator(x) && !Parser.Parser.HasBlacklistedFolderInPath(x) .OrderByNatural(Path.GetFileNameWithoutExtension)
&& Parser.Parser.IsCoverImage(x) .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)))
&& !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); .FirstOrDefault(Parser.Parser.IsCoverImage);
return string.IsNullOrEmpty(result) ? null : result; return string.IsNullOrEmpty(result) ? null : result;
} }
/// <summary> /// <summary>
/// Returns first entry that is an image and is not in a blacklisted folder path. Uses <see cref="NaturalSortComparer"/> for ordering files /// Returns first entry that is an image and is not in a blacklisted folder path. Uses <see cref="OrderByNatural"/> for ordering files
/// </summary> /// </summary>
/// <param name="entryFullNames"></param> /// <param name="entryFullNames"></param>
/// <returns>Entry name of match, null if no match</returns> /// <returns>Entry name of match, null if no match</returns>
public static string FirstFileEntry(IEnumerable<string> entryFullNames, string archiveName) 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 var result = entryFullNames
// because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg. .OrderByNatural(c => c.GetFullPathWithoutExtension())
var fullNames = entryFullNames.Where(x =>!Parser.Parser.HasBlacklistedFolderInPath(x) .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)))
&& Parser.Parser.IsImage(x) .FirstOrDefault(path => Parser.Parser.IsImage(path));
&& !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)).ToList();
if (fullNames.Count == 0) return null;
using var nc = new NaturalSortComparer();
var nonNestedFile = fullNames.Where(entry => (Path.GetDirectoryName(entry) ?? string.Empty).Equals(archiveName))
.OrderBy(f => f.GetFullPathWithoutExtension(), nc) // BUG: This shouldn't take into account extension
.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.OrderBy(Path.GetDirectoryName, nc).FirstOrDefault();
if (!string.IsNullOrEmpty(firstDirectoryFile))
{
var firstDirectory = Path.GetDirectoryName(firstDirectoryFile);
if (!string.IsNullOrEmpty(firstDirectory))
{
var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f)))
.OrderBy(Path.GetFileNameWithoutExtension, nc)
.FirstOrDefault();
if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult;
}
}
var result = fullNames
.OrderBy(Path.GetFileNameWithoutExtension, nc)
.FirstOrDefault();
return string.IsNullOrEmpty(result) ? null : result; return string.IsNullOrEmpty(result) ? null : result;
} }
@ -200,25 +173,24 @@ namespace API.Services
case ArchiveLibrary.Default: case ArchiveLibrary.Default:
{ {
using var archive = ZipFile.OpenRead(archivePath); using var archive = ZipFile.OpenRead(archivePath);
var entryNames = archive.Entries.Select(e => e.FullName).ToArray(); var entryNames = archive.Entries.Select(e => e.FullName).ToList();
var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); var entryName = FindCoverImageFilename(archivePath, entryNames);
var entry = archive.Entries.Single(e => e.FullName == entryName); var entry = archive.Entries.Single(e => e.FullName == entryName);
using var stream = entry.Open();
return CreateThumbnail(archivePath + " - " + entry.FullName, stream, fileName, outputDirectory); using var stream = entry.Open();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory);
} }
case ArchiveLibrary.SharpCompress: case ArchiveLibrary.SharpCompress:
{ {
using var archive = ArchiveFactory.Open(archivePath); using var archive = ArchiveFactory.Open(archivePath);
var entryNames = archive.Entries.Where(archiveEntry => !archiveEntry.IsDirectory).Select(e => e.Key).ToList(); var entryNames = archive.Entries.Where(archiveEntry => !archiveEntry.IsDirectory).Select(e => e.Key).ToList();
var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); var entryName = FindCoverImageFilename(archivePath, entryNames);
var entry = archive.Entries.Single(e => e.Key == entryName); var entry = archive.Entries.Single(e => e.Key == entryName);
using var stream = entry.OpenEntryStream(); using var stream = entry.OpenEntryStream();
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory);
return CreateThumbnail(archivePath + " - " + entry.Key, stream, fileName, outputDirectory);
} }
case ArchiveLibrary.NotSupported: case ArchiveLibrary.NotSupported:
_logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath);
@ -236,6 +208,18 @@ namespace API.Services
return string.Empty; 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 string FindCoverImageFilename(string archivePath, IList<string> entryNames)
{
var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath));
return entryName;
}
/// <summary> /// <summary>
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly /// 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="DirectoryInfoExtensions"/> Flatten method. /// under extract path and not nested in subfolders. See <see cref="DirectoryInfoExtensions"/> Flatten method.
@ -282,20 +266,6 @@ namespace API.Services
return Tuple.Create(fileBytes, zipPath); return Tuple.Create(fileBytes, zipPath);
} }
private string CreateThumbnail(string entryName, Stream stream, string fileName, string outputDirectory)
{
try
{
return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory);
}
catch (Exception ex)
{
// NOTE: I can just let this bubble up
_logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {EntryName}. Defaulting to no cover image", entryName);
}
return string.Empty;
}
/// <summary> /// <summary>
/// Test if the archive path exists and an archive /// Test if the archive path exists and an archive

View File

@ -250,13 +250,25 @@ namespace API.Services
var imageFile = image.Attributes["src"].Value; var imageFile = image.Attributes["src"].Value;
if (!book.Content.Images.ContainsKey(imageFile)) if (!book.Content.Images.ContainsKey(imageFile))
{ {
// TODO: Refactor the Key code to a method to allow the hacks to be tested
var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile));
if (correctedKey != null) if (correctedKey != null)
{
imageFile = correctedKey;
} else if (imageFile.StartsWith(".."))
{
// There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg
correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty)));
if (correctedKey != null)
{ {
imageFile = correctedKey; imageFile = correctedKey;
} }
} }
}
image.Attributes.Remove("src"); image.Attributes.Remove("src");
image.Attributes.Add("src", $"{apiBase}" + imageFile); image.Attributes.Add("src", $"{apiBase}" + imageFile);
} }

View File

@ -170,13 +170,11 @@ namespace API.Services
// Calculate what chapter the page belongs to // Calculate what chapter the page belongs to
var path = GetCachePath(chapter.Id); var path = GetCachePath(chapter.Id);
var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions); var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
using var nc = new NaturalSortComparer();
files = files files = files
.AsEnumerable() .AsEnumerable()
.OrderBy(Path.GetFileNameWithoutExtension, nc) .OrderByNatural(Path.GetFileNameWithoutExtension)
.ToArray(); .ToArray();
if (files.Length == 0) if (files.Length == 0)
{ {
return string.Empty; return string.Empty;

View File

@ -8,6 +8,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.Extensions;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services namespace API.Services
@ -698,8 +699,7 @@ namespace API.Services
{ {
var fileIndex = 1; var fileIndex = 1;
using var nc = new NaturalSortComparer(); foreach (var file in directory.EnumerateFiles().OrderByNatural(file => file.FullName))
foreach (var file in directory.EnumerateFiles().OrderBy(file => file.FullName, nc))
{ {
if (file.Directory == null) continue; if (file.Directory == null) continue;
var paddedIndex = Parser.Parser.PadZeros(directoryIndex + ""); var paddedIndex = Parser.Parser.PadZeros(directoryIndex + "");
@ -713,8 +713,7 @@ namespace API.Services
directoryIndex++; directoryIndex++;
} }
var sort = new NaturalSortComparer(); foreach (var subDirectory in directory.EnumerateDirectories().OrderByNatural(d => d.FullName))
foreach (var subDirectory in directory.EnumerateDirectories().OrderBy(d => d.FullName, sort))
{ {
FlattenDirectory(root, subDirectory, ref directoryIndex); FlattenDirectory(root, subDirectory, ref directoryIndex);
} }

View File

@ -8,6 +8,8 @@ using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.Entities; using API.Entities;
using API.Extensions;
using Kavita.Common;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services; namespace API.Services;
@ -41,7 +43,7 @@ public class ReaderService : IReaderService
public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId)
{ {
return Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}"); return Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}"));
} }
/// <summary> /// <summary>
@ -87,34 +89,28 @@ public class ReaderService : IReaderService
{ {
var userProgress = GetUserProgressForChapter(user, chapter); var userProgress = GetUserProgressForChapter(user, chapter);
if (userProgress == null) if (userProgress == null) continue;
{
user.Progresses.Add(new AppUserProgress
{
PagesRead = 0,
VolumeId = chapter.VolumeId,
SeriesId = seriesId,
ChapterId = chapter.Id
});
}
else
{
userProgress.PagesRead = 0; userProgress.PagesRead = 0;
userProgress.SeriesId = seriesId; userProgress.SeriesId = seriesId;
userProgress.VolumeId = chapter.VolumeId; userProgress.VolumeId = chapter.VolumeId;
} }
} }
}
/// <summary> /// <summary>
/// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit. /// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit.
/// </summary> /// </summary>
/// <param name="user"></param> /// <param name="user">Must have Progresses populated</param>
/// <param name="chapter"></param> /// <param name="chapter"></param>
/// <returns></returns> /// <returns></returns>
public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
{ {
AppUserProgress userProgress = null; AppUserProgress userProgress = null;
if (user.Progresses == null)
{
throw new KavitaException("Progresses must exist on user");
}
try try
{ {
userProgress = userProgress =
@ -236,7 +232,7 @@ public class ReaderService : IReaderService
if (currentVolume.Number == 0) if (currentVolume.Number == 0)
{ {
// Handle specials by sorting on their Filename aka Range // Handle specials by sorting on their Filename aka Range
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()), currentChapter.Number); var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Number);
if (chapterId > 0) return chapterId; if (chapterId > 0) return chapterId;
} }
@ -287,7 +283,7 @@ public class ReaderService : IReaderService
if (currentVolume.Number == 0) if (currentVolume.Number == 0)
{ {
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()).Reverse(), currentChapter.Number); var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Number);
if (chapterId > 0) return chapterId; if (chapterId > 0) return chapterId;
} }

View File

@ -177,9 +177,9 @@ namespace API.Services.Tasks
// Search all files in bookmarks/ except bookmark files and delete those // Search all files in bookmarks/ except bookmark files and delete those
var bookmarkDirectory = var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories).Select(f => _directoryService.FileSystem.Path.GetFullPath(f)); var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories).Select(Parser.Parser.NormalizePath);
var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()) var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync())
.Select(b => _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory,
b.FileName))); b.FileName)));

View File

@ -44,7 +44,6 @@ public class ScannerService : IScannerService
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly IReadingItemService _readingItemService; private readonly IReadingItemService _readingItemService;
private readonly ICacheHelper _cacheHelper; private readonly ICacheHelper _cacheHelper;
private readonly NaturalSortComparer _naturalSort = new ();
public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger, public ScannerService(IUnitOfWork unitOfWork, ILogger<ScannerService> logger,
IMetadataService metadataService, ICacheService cacheService, IHubContext<MessageHub> messageHub, IMetadataService metadataService, ICacheService cacheService, IHubContext<MessageHub> messageHub,
@ -709,7 +708,7 @@ public class ScannerService : IScannerService
// Ensure we remove any files that no longer exist AND order // Ensure we remove any files that no longer exist AND order
existingChapter.Files = existingChapter.Files existingChapter.Files = existingChapter.Files
.Where(f => parsedInfos.Any(p => p.FullFilePath == f.FilePath)) .Where(f => parsedInfos.Any(p => p.FullFilePath == f.FilePath))
.OrderBy(f => f.FilePath, _naturalSort).ToList(); .OrderByNatural(f => f.FilePath).ToList();
existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages);
} }
} }