From 591b57470643a93b68494603674c05a7d80008ad Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 15 Jan 2022 07:39:34 -0800 Subject: [PATCH] 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. --- .gitignore | 1 + API.Benchmark/TestBenchmark.cs | 8 +- .../ChapterSortComparerZeroFirstTests.cs | 17 + API.Tests/Comparers/NumericComparerTests.cs | 26 + .../Comparers/StringLogicalComparerTest.cs | 19 +- API.Tests/Converters/CronConverterTests.cs | 4 +- .../Extensions/ChapterListExtensionsTests.cs | 49 +- .../EnumerableExtensionsTests.cs} | 50 +- .../Extensions/FilterDtoExtensionsTests.cs | 48 ++ API.Tests/Extensions/SeriesExtensionsTests.cs | 38 +- .../Extensions/VolumeListExtensionsTests.cs | 177 ++++ API.Tests/Helpers/CacheHelperTests.cs | 3 + API.Tests/Helpers/EntityFactory.cs | 6 +- API.Tests/Parser/ParserTest.cs | 14 +- API.Tests/Services/ArchiveServiceTests.cs | 108 ++- API.Tests/Services/BookServiceTests.cs | 11 + API.Tests/Services/CacheServiceTests.cs | 68 ++ API.Tests/Services/DirectoryServiceTests.cs | 13 + API.Tests/Services/ReaderServiceTests.cs | 814 ++++++++++++++++++ API/Comparators/NaturalSortComparer.cs | 111 --- API/Controllers/ReaderController.cs | 10 +- API/Data/Repositories/VolumeRepository.cs | 4 +- API/Extensions/EnumerableExtensions.cs | 24 +- API/Extensions/ParserInfoListExtensions.cs | 20 +- API/Extensions/PathExtensions.cs | 1 + API/Extensions/SeriesExtensions.cs | 2 +- API/Extensions/VolumeListExtensions.cs | 3 +- API/Helpers/Converters/CronConverter.cs | 24 +- API/Parser/Parser.cs | 18 +- API/Services/ArchiveService.cs | 88 +- API/Services/BookService.cs | 12 + API/Services/CacheService.cs | 4 +- API/Services/DirectoryService.cs | 7 +- API/Services/ReaderService.cs | 38 +- API/Services/Tasks/CleanupService.cs | 4 +- API/Services/Tasks/ScannerService.cs | 3 +- 36 files changed, 1533 insertions(+), 314 deletions(-) create mode 100644 API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs create mode 100644 API.Tests/Comparers/NumericComparerTests.cs rename API.Tests/{Comparers/NaturalSortComparerTest.cs => Extensions/EnumerableExtensionsTests.cs} (83%) create mode 100644 API.Tests/Extensions/FilterDtoExtensionsTests.cs create mode 100644 API.Tests/Extensions/VolumeListExtensionsTests.cs create mode 100644 API.Tests/Services/ReaderServiceTests.cs delete mode 100644 API/Comparators/NaturalSortComparer.cs diff --git a/.gitignore b/.gitignore index f2ae835bf..1ee566816 100644 --- a/.gitignore +++ b/.gitignore @@ -522,3 +522,4 @@ API/config/pre-metadata/ API/config/post-metadata/ API.Tests/TestResults/ UI/Web/.vscode/settings.json +/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/* diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs index a2aabdd8a..618a8b93c 100644 --- a/API.Benchmark/TestBenchmark.cs +++ b/API.Benchmark/TestBenchmark.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using API.Comparators; using API.DTOs; +using API.Extensions; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; @@ -16,9 +17,6 @@ namespace API.Benchmark [RankColumn] public class TestBenchmark { - private readonly NaturalSortComparer _naturalSortComparer = new (); - - private static IEnumerable GenerateVolumes(int max) { var random = new Random(); @@ -50,11 +48,11 @@ namespace API.Benchmark return list; } - private void SortSpecialChapters(IEnumerable volumes) + private static void SortSpecialChapters(IEnumerable volumes) { 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(); } } diff --git a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs new file mode 100644 index 000000000..72d6908c6 --- /dev/null +++ b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs @@ -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()); + } +} diff --git a/API.Tests/Comparers/NumericComparerTests.cs b/API.Tests/Comparers/NumericComparerTests.cs new file mode 100644 index 000000000..9a66e7666 --- /dev/null +++ b/API.Tests/Comparers/NumericComparerTests.cs @@ -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++; + } + } +} diff --git a/API.Tests/Comparers/StringLogicalComparerTest.cs b/API.Tests/Comparers/StringLogicalComparerTest.cs index ae93b3b46..3d13e43ac 100644 --- a/API.Tests/Comparers/StringLogicalComparerTest.cs +++ b/API.Tests/Comparers/StringLogicalComparerTest.cs @@ -8,13 +8,20 @@ namespace API.Tests.Comparers { [Theory] [InlineData( - 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", "x10.jpg", "x3.jpg", "x4.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, nc); + Array.Sort(input, StringLogicalComparer.Compare); var i = 0; foreach (var s in input) @@ -24,4 +31,4 @@ namespace API.Tests.Comparers } } } -} \ No newline at end of file +} diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs index 34efbd59e..813d82426 100644 --- a/API.Tests/Converters/CronConverterTests.cs +++ b/API.Tests/Converters/CronConverterTests.cs @@ -9,9 +9,11 @@ namespace API.Tests.Converters [InlineData("daily", "0 0 * * *")] [InlineData("disabled", "0 0 31 2 *")] [InlineData("weekly", "0 0 * * 1")] + [InlineData("", "0 0 31 2 *")] + [InlineData("sdfgdf", "")] public void ConvertTest(string input, string expected) { Assert.Equal(expected, CronConverter.ConvertToCronNotation(input)); } } -} \ No newline at end of file +} diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index 2251c660b..845b1387b 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -9,7 +10,7 @@ namespace API.Tests.Extensions { 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() { @@ -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() { @@ -28,7 +29,7 @@ namespace API.Tests.Extensions Format = format }; } - + [Fact] public void GetAnyChapterByRange_Test_ShouldBeNull() { @@ -51,11 +52,11 @@ namespace API.Tests.Extensions }; var actualChapter = chapterList.GetChapterByRange(info); - + Assert.NotEqual(chapterList[0], actualChapter); - + } - + [Fact] public void GetAnyChapterByRange_Test_ShouldBeNotNull() { @@ -78,9 +79,39 @@ namespace API.Tests.Extensions }; var actualChapter = chapterList.GetChapterByRange(info); - + Assert.Equal(chapterList[0], actualChapter); - } + + #region GetFirstChapterWithFiles + + [Fact] + public void GetFirstChapterWithFiles_ShouldReturnAllChapters() + { + var chapterList = new List() + { + 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() + { + 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(); + + Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles()); + } + + + #endregion } -} \ No newline at end of file +} diff --git a/API.Tests/Comparers/NaturalSortComparerTest.cs b/API.Tests/Extensions/EnumerableExtensionsTests.cs similarity index 83% rename from API.Tests/Comparers/NaturalSortComparerTest.cs rename to API.Tests/Extensions/EnumerableExtensionsTests.cs index 477ba867f..0f04ac9d7 100644 --- a/API.Tests/Comparers/NaturalSortComparerTest.cs +++ b/API.Tests/Extensions/EnumerableExtensionsTests.cs @@ -1,15 +1,12 @@ -using System; -using System.Linq; -using API.Comparators; +using System.Linq; +using API.Extensions; using Xunit; -namespace API.Tests.Comparers -{ - public class NaturalSortComparerTest - { - private readonly NaturalSortComparer _nc = new NaturalSortComparer(); +namespace API.Tests.Extensions; - [Theory] +public class EnumerableExtensionsTests +{ + [Theory] [InlineData( new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} @@ -40,7 +37,7 @@ namespace API.Tests.Comparers )] [InlineData( 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( 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"} )] [InlineData( - new[] {"!001", "001", "002"}, + new[] {"001", "002", "!001"}, new[] {"!001", "001", "002"} )] [InlineData( - new[] {"001", "", null}, - new[] {"", "001", null} + new[] {"001.jpg", "002.jpg", "!001.jpg"}, + new[] {"!001.jpg", "001.jpg", "002.jpg"} + )] + [InlineData( + new[] {"001", "002", "!002"}, + new[] {"!002", "001", "002"} + )] + [InlineData( + new[] {"001", ""}, + new[] {"", "001"} )] [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"}, @@ -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"} )] - 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); + Assert.Equal(expected, input.OrderByNatural(x => x).ToArray()); } - [Theory] [InlineData( 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",} )] + [InlineData( + new[] {"001", "002", "!001"}, + new[] {"!001", "001", "002"} + )] [InlineData( new[] {"10/001.jpg", "10.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/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; foreach (var s in output) @@ -121,5 +132,4 @@ namespace API.Tests.Comparers i++; } } - } } diff --git a/API.Tests/Extensions/FilterDtoExtensionsTests.cs b/API.Tests/Extensions/FilterDtoExtensionsTests.cs new file mode 100644 index 000000000..c9985f509 --- /dev/null +++ b/API.Tests/Extensions/FilterDtoExtensionsTests.cs @@ -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(), filter.GetSqlFilter()); + } + + [Fact] + public void GetSqlFilter_ShouldReturnAllFormats2() + { + var filter = new FilterDto() + { + Formats = new List() + }; + + Assert.Equal(Enum.GetValues(), filter.GetSqlFilter()); + } + + [Fact] + public void GetSqlFilter_ShouldReturnJust2() + { + var formats = new List() + { + MangaFormat.Archive, MangaFormat.Epub + }; + var filter = new FilterDto() + { + Formats = formats + }; + + Assert.Equal(formats, filter.GetSqlFilter()); + } +} diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index ef6e3f25f..fc5b5b8ca 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -1,7 +1,11 @@ -using API.Entities; +using System.Linq; +using API.Entities; +using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; using API.Parser; +using API.Services.Tasks.Scanner; +using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions @@ -32,6 +36,38 @@ namespace API.Tests.Extensions 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] [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)] diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs new file mode 100644 index 000000000..48d39aa24 --- /dev/null +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -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() + { + EntityFactory.CreateVolume("0", new List()), + EntityFactory.CreateVolume("1", new List() + { + 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + 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() + { + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Image).Name); + } + + + #endregion +} diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 55580eb49..9d1024ff0 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -5,6 +5,8 @@ using System.IO.Abstractions.TestingHelpers; using API.Entities; using API.Helpers; using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Helpers; @@ -287,4 +289,5 @@ public class CacheHelperTests }; Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); } + } diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index a312417bd..7b55df108 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -28,6 +29,7 @@ namespace API.Tests.Helpers return new Volume() { Name = volumeNumber, + Number = int.Parse(volumeNumber), Pages = 0, Chapters = chapters ?? new List() }; @@ -76,4 +78,4 @@ namespace API.Tests.Helpers }; } } -} \ No newline at end of file +} diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index ba98d4874..5068b39b3 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -152,7 +152,7 @@ namespace API.Tests.Parser [InlineData("test.jpeg", true)] [InlineData("test.png", true)] [InlineData(".test.jpg", false)] - [InlineData("!test.jpg", false)] + [InlineData("!test.jpg", true)] [InlineData("test.webp", true)] public void IsImageTest(string filename, bool expected) { @@ -188,5 +188,17 @@ namespace API.Tests.Parser { 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)); + } } } diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index fedfec11d..000a2f917 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -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")] public void FindFolderEntry(string[] files, string expected) { - var foundFile = _archiveService.FindFolderEntry(files); + var foundFile = ArchiveService.FindFolderEntry(files); Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); } @@ -203,48 +203,69 @@ namespace API.Tests.Services [InlineData("sorting.zip", "sorting.expected.jpg")] public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) { - var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger, new MockFileSystem())); + var imageService = new ImageService(Substitute.For>(), _directoryService); + var archiveService = Substitute.For(_logger, + new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); 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); - Stopwatch sw = Stopwatch.StartNew(); - //Assert.Equal(expectedBytes, File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), Path.GetFileNameWithoutExtension(inputFile) + "_output"))); - _testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); + var actualBytes = File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), + Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir)); + 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("Formats/One File with DB_Supported.zip")] public void CanParseCoverImage(string inputFile) { + var imageService = Substitute.For(); + imageService.WriteCoverThumbnail(Arg.Any(), Arg.Any(), Arg.Any()).Returns(x => "cover.jpg"); + var archiveService = new ArchiveService(_logger, _directoryService, imageService); 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(); } - [Fact] - public void ShouldHaveComicInfo() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); - var archive = Path.Join(testDirectory, "ComicInfo.zip"); - const string summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; + #region ShouldHaveComicInfo - var comicInfo = _archiveService.GetComicInfo(archive); - Assert.NotNull(comicInfo); - Assert.Equal(summaryInfo, comicInfo.Summary); - } + [Fact] + public void ShouldHaveComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo.zip"); + const string summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; - [Fact] - public void ShouldHaveComicInfo_WithAuthors() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); - var archive = Path.Join(testDirectory, "ComicInfo_authors.zip"); + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal(summaryInfo, comicInfo.Summary); + } - var comicInfo = _archiveService.GetComicInfo(archive); - Assert.NotNull(comicInfo); - Assert.Equal("Junya Inoue", comicInfo.Writer); - } + [Fact] + public void ShouldHaveComicInfo_WithAuthors() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo_authors.zip"); + + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("Junya Inoue", comicInfo.Writer); + } + + #endregion + + #region CanParseComicInfo [Fact] public void CanParseComicInfo() @@ -268,5 +289,38 @@ namespace API.Tests.Services 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>(), fileSystem); + //_archiveService.CreateZipForDownload(new []{}, outputDirectory) + } + + #endregion } } diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index a2f498139..07fa2936d 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -52,5 +52,16 @@ namespace API.Tests.Services Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer); } + + #region BookEscaping + + [Fact] + public void EscapeCSSImportReferencesTest() + { + + } + + #endregion + } } diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index 29d8ece91..7a2cbada8 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -5,8 +5,10 @@ using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Metadata; using API.Entities; using API.Entities.Enums; +using API.Parser; using API.Services; using API.SignalR; using AutoMapper; @@ -20,6 +22,40 @@ using Xunit; 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 { private readonly ILogger _logger = Substitute.For>(); @@ -436,5 +472,37 @@ namespace API.Tests.Services #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>(), fileSystem); + // var cs = new CacheService(_logger, _unitOfWork, ds, + // new MockReadingItemServiceForCacheService(ds)); + // + // + // cs.ExtractChapterFiles(CacheDirectory, new List() + // { + // new MangaFile() + // { + // ChapterId = 1, + // Format = MangaFormat.Archive, + // Pages = 2, + // FilePath = + // } + // }) + // } + + #endregion } } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 62f9310c9..bdbb7a238 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -776,5 +776,18 @@ namespace API.Tests.Services #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 } } diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs new file mode 100644 index 000000000..940bc2ebe --- /dev/null +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -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 _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub = Substitute.For>(); + + 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()); + 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 SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, + new DirectoryService(Substitute.For>(), 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() {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() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + } + } + } + } + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + new Volume() + { + Chapters = new List() + { + 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>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + new Volume() + { + Chapters = new List() + { + 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>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), 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>(), fileSystem); + // var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + // var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + // + // + // } + // + // + // #endregion + +} diff --git a/API/Comparators/NaturalSortComparer.cs b/API/Comparators/NaturalSortComparer.cs deleted file mode 100644 index b65d06e95..000000000 --- a/API/Comparators/NaturalSortComparer.cs +++ /dev/null @@ -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 -{ - /// - /// Attempts to emulate Windows explorer sorting - /// - /// This is not thread-safe - public sealed class NaturalSortComparer : IComparer, IDisposable - { - private readonly bool _isAscending; - private Dictionary _table = new(); - - private bool _disposed; - - - public NaturalSortComparer(bool inAscendingOrder = true) - { - _isAscending = inAscendingOrder; - } - - int IComparer.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); - } - } -} diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 928d05e17..3028d1fee 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -139,15 +139,7 @@ namespace API.Controllers user.Progresses ??= new List(); foreach (var volume in volumes) { - foreach (var chapter in volume.Chapters) - { - var userProgress = ReaderService.GetUserProgressForChapter(user, chapter); - - if (userProgress == null) continue; - userProgress.PagesRead = 0; - userProgress.SeriesId = markReadDto.SeriesId; - userProgress.VolumeId = volume.Id; - } + _readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 7ad025561..e63b469e6 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using API.Comparators; using API.DTOs; using API.Entities; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -195,10 +196,9 @@ public class VolumeRepository : IVolumeRepository private static void SortSpecialChapters(IEnumerable volumes) { - var sorter = new NaturalSortComparer(); 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(); } } diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 7bf24b639..c1dd412e2 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -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 { + private static readonly Regex Regex = new Regex(@"\d+", RegexOptions.Compiled, TimeSpan.FromMilliseconds(500)); + /// + /// A natural sort implementation + /// + /// IEnumerable to process + /// Function that produces a string. Does not support null values + /// Defaults to CurrentCulture + /// + /// Sorted Enumerable + public static IEnumerable OrderByNatural(this IEnumerable items, Func 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); + } } } diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 0ea098b20..31a65c819 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -31,15 +31,15 @@ namespace API.Extensions : infos.Any(v => v.Chapters == chapter.Range); } - /// - /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos - /// - /// - /// - public static MangaFormat GetFormat(this IList infos) - { - if (infos.Count == 0) return MangaFormat.Unknown; - return infos.DistinctBy(x => x.Format).First().Format; - } + // /// + // /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos + // /// + // /// + // /// + // public static MangaFormat GetFormat(this IList infos) + // { + // if (infos.Count == 0) return MangaFormat.Unknown; + // return infos.DistinctBy(x => x.Format).First().Format; + // } } } diff --git a/API/Extensions/PathExtensions.cs b/API/Extensions/PathExtensions.cs index 31677f76e..f45787d1a 100644 --- a/API/Extensions/PathExtensions.cs +++ b/API/Extensions/PathExtensions.cs @@ -8,6 +8,7 @@ public static class PathExtensions { if (string.IsNullOrEmpty(filepath)) return filepath; var extension = Path.GetExtension(filepath); + if (string.IsNullOrEmpty(extension)) return filepath; return Path.GetFullPath(filepath.Replace(extension, string.Empty)); } } diff --git a/API/Extensions/SeriesExtensions.cs b/API/Extensions/SeriesExtensions.cs index 30a7b1b5b..cd3254e34 100644 --- a/API/Extensions/SeriesExtensions.cs +++ b/API/Extensions/SeriesExtensions.cs @@ -9,7 +9,7 @@ namespace API.Extensions public static class SeriesExtensions { /// - /// 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. /// /// /// diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index 4969e9d0a..97126e28f 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -12,7 +12,8 @@ namespace API.Extensions { return inBookSeries ? 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()); } /// diff --git a/API/Helpers/Converters/CronConverter.cs b/API/Helpers/Converters/CronConverter.cs index cacf018b1..32df46753 100644 --- a/API/Helpers/Converters/CronConverter.cs +++ b/API/Helpers/Converters/CronConverter.cs @@ -26,16 +26,16 @@ namespace API.Helpers.Converters return destination; } - public static string ConvertFromCronNotation(string cronNotation) - { - var destination = string.Empty; - destination = cronNotation.ToLower() switch - { - "0 0 31 2 *" => "disabled", - _ => destination - }; - - return destination; - } + // public static string ConvertFromCronNotation(string cronNotation) + // { + // var destination = string.Empty; + // destination = cronNotation.ToLower() switch + // { + // "0 0 31 2 *" => "disabled", + // _ => destination + // }; + // + // return destination; + // } } -} \ No newline at end of file +} diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 1f9562038..c17290c5b 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -916,10 +916,9 @@ namespace API.Parser 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 ImageRegex.IsMatch(Path.GetExtension(filePath)); + return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath)); } public static bool IsXml(string filePath) @@ -959,7 +958,7 @@ namespace API.Parser /// 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) @@ -989,5 +988,16 @@ namespace API.Parser if (string.IsNullOrEmpty(author)) return string.Empty; return author.Trim(); } + + /// + /// Normalizes the slashes in a path to be + /// + /// /manga/1\1 -> /manga/1/1 + /// + /// + public static string NormalizePath(string path) + { + return path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } } } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 616f4e9f6..6209c3563 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -28,6 +28,7 @@ namespace API.Services ArchiveLibrary CanOpen(string archivePath); bool ArchiveNeedsFlattening(ZipArchive archive); Task> CreateZipForDownload(IEnumerable files, string tempFolder); + string FindCoverImageFilename(string archivePath, IList entryNames); } /// @@ -124,55 +125,27 @@ namespace API.Services /// /// /// Entry name of match, null if no match - public string FindFolderEntry(IEnumerable entryFullNames) + public static string FindFolderEntry(IEnumerable entryFullNames) { var result = entryFullNames - .FirstOrDefault(x => !Path.EndsInDirectorySeparator(x) && !Parser.Parser.HasBlacklistedFolderInPath(x) - && Parser.Parser.IsCoverImage(x) - && !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); + .OrderByNatural(Path.GetFileNameWithoutExtension) + .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith))) + .FirstOrDefault(Parser.Parser.IsCoverImage); return string.IsNullOrEmpty(result) ? null : result; } /// - /// Returns first entry that is an image and is not in a blacklisted folder path. Uses for ordering files + /// Returns first entry that is an image and is not in a blacklisted folder path. Uses for ordering files /// /// /// Entry name of match, null if no match - public static string FirstFileEntry(IEnumerable entryFullNames, string archiveName) + public static string? FirstFileEntry(IEnumerable entryFullNames, string archiveName) { - // First check if there are any files that are not in a nested folder before just comparing by filename. This is needed - // because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg. - var fullNames = entryFullNames.Where(x =>!Parser.Parser.HasBlacklistedFolderInPath(x) - && Parser.Parser.IsImage(x) - && !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(); + var result = entryFullNames + .OrderByNatural(c => c.GetFullPathWithoutExtension()) + .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith))) + .FirstOrDefault(path => Parser.Parser.IsImage(path)); return string.IsNullOrEmpty(result) ? null : result; } @@ -200,25 +173,24 @@ namespace API.Services case ArchiveLibrary.Default: { 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); - 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: { using var archive = ArchiveFactory.Open(archivePath); 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); using var stream = entry.OpenEntryStream(); - - return CreateThumbnail(archivePath + " - " + entry.Key, stream, fileName, outputDirectory); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); } case ArchiveLibrary.NotSupported: _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; } + /// + /// Given a list of image paths (assume within an archive), find the filename that corresponds to the cover + /// + /// + /// + /// + public string FindCoverImageFilename(string archivePath, IList entryNames) + { + var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); + return entryName; + } + /// /// 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 Flatten method. @@ -282,20 +266,6 @@ namespace API.Services 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; - } /// /// Test if the archive path exists and an archive diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 2d3cb4b48..871b7dd32 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -250,11 +250,23 @@ namespace API.Services var imageFile = image.Attributes["src"].Value; 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)); 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; + } } + + + } image.Attributes.Remove("src"); diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index ccf0c282b..0b6c4aa0d 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -170,13 +170,11 @@ namespace API.Services // Calculate what chapter the page belongs to var path = GetCachePath(chapter.Id); var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions); - using var nc = new NaturalSortComparer(); files = files .AsEnumerable() - .OrderBy(Path.GetFileNameWithoutExtension, nc) + .OrderByNatural(Path.GetFileNameWithoutExtension) .ToArray(); - if (files.Length == 0) { return string.Empty; diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index a9f6f291b..bf3c01d25 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Comparators; +using API.Extensions; using Microsoft.Extensions.Logging; namespace API.Services @@ -698,8 +699,7 @@ namespace API.Services { var fileIndex = 1; - using var nc = new NaturalSortComparer(); - foreach (var file in directory.EnumerateFiles().OrderBy(file => file.FullName, nc)) + foreach (var file in directory.EnumerateFiles().OrderByNatural(file => file.FullName)) { if (file.Directory == null) continue; var paddedIndex = Parser.Parser.PadZeros(directoryIndex + ""); @@ -713,8 +713,7 @@ namespace API.Services directoryIndex++; } - var sort = new NaturalSortComparer(); - foreach (var subDirectory in directory.EnumerateDirectories().OrderBy(d => d.FullName, sort)) + foreach (var subDirectory in directory.EnumerateDirectories().OrderByNatural(d => d.FullName)) { FlattenDirectory(root, subDirectory, ref directoryIndex); } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index b7488b6a8..b9862cf05 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -8,6 +8,8 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.Entities; +using API.Extensions; +using Kavita.Common; using Microsoft.Extensions.Logging; namespace API.Services; @@ -41,7 +43,7 @@ public class ReaderService : IReaderService 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}")); } /// @@ -87,34 +89,28 @@ public class ReaderService : IReaderService { var userProgress = GetUserProgressForChapter(user, chapter); - if (userProgress == null) - { - user.Progresses.Add(new AppUserProgress - { - PagesRead = 0, - VolumeId = chapter.VolumeId, - SeriesId = seriesId, - ChapterId = chapter.Id - }); - } - else - { - userProgress.PagesRead = 0; - userProgress.SeriesId = seriesId; - userProgress.VolumeId = chapter.VolumeId; - } + if (userProgress == null) continue; + + userProgress.PagesRead = 0; + userProgress.SeriesId = seriesId; + userProgress.VolumeId = chapter.VolumeId; } } /// /// 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. /// - /// + /// Must have Progresses populated /// /// - public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) + private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) { AppUserProgress userProgress = null; + + if (user.Progresses == null) + { + throw new KavitaException("Progresses must exist on user"); + } try { userProgress = @@ -236,7 +232,7 @@ public class ReaderService : IReaderService if (currentVolume.Number == 0) { // 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; } @@ -287,7 +283,7 @@ public class ReaderService : IReaderService 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; } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index be5066862..d31e50a22 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -177,9 +177,9 @@ namespace API.Services.Tasks // Search all files in bookmarks/ except bookmark files and delete those var bookmarkDirectory = (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()) - .Select(b => _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, + .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, b.FileName))); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index e58356ade..f2daee1b5 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -44,7 +44,6 @@ public class ScannerService : IScannerService private readonly IDirectoryService _directoryService; private readonly IReadingItemService _readingItemService; private readonly ICacheHelper _cacheHelper; - private readonly NaturalSortComparer _naturalSort = new (); public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IMetadataService metadataService, ICacheService cacheService, IHubContext messageHub, @@ -709,7 +708,7 @@ public class ScannerService : IScannerService // Ensure we remove any files that no longer exist AND order existingChapter.Files = existingChapter.Files .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); } }