diff --git a/API.Benchmark/ArchiveSerivceBenchmark.cs b/API.Benchmark/ArchiveSerivceBenchmark.cs index c60a4271fa..11a66d7bc7 100644 --- a/API.Benchmark/ArchiveSerivceBenchmark.cs +++ b/API.Benchmark/ArchiveSerivceBenchmark.cs @@ -1,8 +1,7 @@ -namespace API.Benchmark +namespace API.Benchmark; + +public class ArchiveSerivceBenchmark { - public class ArchiveSerivceBenchmark - { - // Benchmark to test default GetNumberOfPages from archive - // vs a new method where I try to open the archive and return said stream - } + // Benchmark to test default GetNumberOfPages from archive + // vs a new method where I try to open the archive and return said stream } diff --git a/API.Benchmark/ParserBenchmarks.cs b/API.Benchmark/ParserBenchmarks.cs index 63adc69853..d7706a3f42 100644 --- a/API.Benchmark/ParserBenchmarks.cs +++ b/API.Benchmark/ParserBenchmarks.cs @@ -5,75 +5,74 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; -namespace API.Benchmark +namespace API.Benchmark; + +[MemoryDiagnoser] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +[RankColumn] +public class ParserBenchmarks { - [MemoryDiagnoser] - [Orderer(SummaryOrderPolicy.FastestToSlowest)] - [RankColumn] - public class ParserBenchmarks - { - private readonly IList _names; + private readonly IList _names; - private static readonly Regex NormalizeRegex = new Regex(@"[^a-zA-Z0-9]", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(300)); + private static readonly Regex NormalizeRegex = new Regex(@"[^a-zA-Z0-9]", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(300)); - private static readonly Regex IsEpub = new Regex(@"\.epub", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - TimeSpan.FromMilliseconds(300)); + private static readonly Regex IsEpub = new Regex(@"\.epub", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(300)); - public ParserBenchmarks() - { - // Read all series from SeriesNamesForNormalization.txt - _names = File.ReadAllLines("Data/SeriesNamesForNormalization.txt"); - Console.WriteLine($"Performing benchmark on {_names.Count} series"); - } + public ParserBenchmarks() + { + // Read all series from SeriesNamesForNormalization.txt + _names = File.ReadAllLines("Data/SeriesNamesForNormalization.txt"); + Console.WriteLine($"Performing benchmark on {_names.Count} series"); + } - private static string Normalize(string name) - { - // ReSharper disable once UnusedVariable - var ret = NormalizeRegex.Replace(name, string.Empty).ToLower(); - var normalized = NormalizeRegex.Replace(name, string.Empty).ToLower(); - return string.IsNullOrEmpty(normalized) ? name : normalized; - } + private static string Normalize(string name) + { + // ReSharper disable once UnusedVariable + var ret = NormalizeRegex.Replace(name, string.Empty).ToLower(); + var normalized = NormalizeRegex.Replace(name, string.Empty).ToLower(); + return string.IsNullOrEmpty(normalized) ? name : normalized; + } - [Benchmark] - public void TestNormalizeName() + [Benchmark] + public void TestNormalizeName() + { + foreach (var name in _names) { - foreach (var name in _names) - { - Normalize(name); - } + Normalize(name); } + } - [Benchmark] - public void TestIsEpub() + [Benchmark] + public void TestIsEpub() + { + foreach (var name in _names) { - foreach (var name in _names) + if ((name).ToLower() == ".epub") { - if ((name).ToLower() == ".epub") - { - /* No Operation */ - } + /* No Operation */ } } + } - [Benchmark] - public void TestIsEpub_New() + [Benchmark] + public void TestIsEpub_New() + { + foreach (var name in _names) { - foreach (var name in _names) - { - if (Path.GetExtension(name).Equals(".epub", StringComparison.InvariantCultureIgnoreCase)) - { - /* No Operation */ - } + if (Path.GetExtension(name).Equals(".epub", StringComparison.InvariantCultureIgnoreCase)) + { + /* No Operation */ } } + } - } } diff --git a/API.Benchmark/Program.cs b/API.Benchmark/Program.cs index 4a659a1b81..d43b842406 100644 --- a/API.Benchmark/Program.cs +++ b/API.Benchmark/Program.cs @@ -1,22 +1,21 @@ using BenchmarkDotNet.Running; -namespace API.Benchmark +namespace API.Benchmark; + +/// +/// To build this, cd into API.Benchmark directory and run +/// dotnet build -c Release +/// then copy the outputted dll +/// dotnet copied_string\API.Benchmark.dll +/// +public static class Program { - /// - /// To build this, cd into API.Benchmark directory and run - /// dotnet build -c Release - /// then copy the outputted dll - /// dotnet copied_string\API.Benchmark.dll - /// - public static class Program + private static void Main(string[] args) { - private static void Main(string[] args) - { - //BenchmarkRunner.Run(); - //BenchmarkRunner.Run(); - //BenchmarkRunner.Run(); - BenchmarkRunner.Run(); + //BenchmarkRunner.Run(); + //BenchmarkRunner.Run(); + //BenchmarkRunner.Run(); + BenchmarkRunner.Run(); - } } } diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs index c5d2d18e1c..0b48806900 100644 --- a/API.Benchmark/TestBenchmark.cs +++ b/API.Benchmark/TestBenchmark.cs @@ -6,61 +6,60 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; -namespace API.Benchmark +namespace API.Benchmark; + +/// +/// This is used as a scratchpad for testing +/// +[MemoryDiagnoser] +[Orderer(SummaryOrderPolicy.FastestToSlowest)] +[RankColumn] +public class TestBenchmark { - /// - /// This is used as a scratchpad for testing - /// - [MemoryDiagnoser] - [Orderer(SummaryOrderPolicy.FastestToSlowest)] - [RankColumn] - public class TestBenchmark + private static IEnumerable GenerateVolumes(int max) { - private static IEnumerable GenerateVolumes(int max) + var random = new Random(); + var maxIterations = random.Next(max) + 1; + var list = new List(); + for (var i = 0; i < maxIterations; i++) { - var random = new Random(); - var maxIterations = random.Next(max) + 1; - var list = new List(); - for (var i = 0; i < maxIterations; i++) + list.Add(new VolumeDto() { - list.Add(new VolumeDto() - { - Number = random.Next(10) > 5 ? 1 : 0, - Chapters = GenerateChapters() - }); - } - - return list; + Number = random.Next(10) > 5 ? 1 : 0, + Chapters = GenerateChapters() + }); } - private static List GenerateChapters() - { - var list = new List(); - for (var i = 1; i < 40; i++) - { - list.Add(new ChapterDto() - { - Range = i + string.Empty - }); - } - - return list; - } + return list; + } - private static void SortSpecialChapters(IEnumerable volumes) + private static List GenerateChapters() + { + var list = new List(); + for (var i = 1; i < 40; i++) { - foreach (var v in volumes.Where(vDto => vDto.Number == 0)) + list.Add(new ChapterDto() { - v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList(); - } + Range = i + string.Empty + }); } - [Benchmark] - public void TestSortSpecialChapters() + return list; + } + + private static void SortSpecialChapters(IEnumerable volumes) + { + foreach (var v in volumes.Where(vDto => vDto.Number == 0)) { - var volumes = GenerateVolumes(10); - SortSpecialChapters(volumes); + v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList(); } + } + [Benchmark] + public void TestSortSpecialChapters() + { + var volumes = GenerateVolumes(10); + SortSpecialChapters(volumes); } + } diff --git a/API.Tests/Comparers/ChapterSortComparerTest.cs b/API.Tests/Comparers/ChapterSortComparerTest.cs index 11fecf2c2f..220be052db 100644 --- a/API.Tests/Comparers/ChapterSortComparerTest.cs +++ b/API.Tests/Comparers/ChapterSortComparerTest.cs @@ -2,18 +2,17 @@ using API.Comparators; using Xunit; -namespace API.Tests.Comparers +namespace API.Tests.Comparers; + +public class ChapterSortComparerTest { - public class ChapterSortComparerTest + [Theory] + [InlineData(new[] {1, 2, 0}, new[] {1, 2, 0})] + [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})] + [InlineData(new[] {1, 0, 0}, new[] {1, 0, 0})] + public void ChapterSortTest(int[] input, int[] expected) { - [Theory] - [InlineData(new[] {1, 2, 0}, new[] {1, 2, 0})] - [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})] - [InlineData(new[] {1, 0, 0}, new[] {1, 0, 0})] - public void ChapterSortTest(int[] input, int[] expected) - { - Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparer()).ToArray()); - } - + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparer()).ToArray()); } -} \ No newline at end of file + +} diff --git a/API.Tests/Comparers/StringLogicalComparerTest.cs b/API.Tests/Comparers/StringLogicalComparerTest.cs index 3d13e43ac8..13f88243d4 100644 --- a/API.Tests/Comparers/StringLogicalComparerTest.cs +++ b/API.Tests/Comparers/StringLogicalComparerTest.cs @@ -2,33 +2,32 @@ using API.Comparators; using Xunit; -namespace API.Tests.Comparers +namespace API.Tests.Comparers; + +public class StringLogicalComparerTest { - public class StringLogicalComparerTest + [Theory] + [InlineData( + new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, + new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} + )] + [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) { - [Theory] - [InlineData( - new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, - new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} - )] - [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) - { - Array.Sort(input, StringLogicalComparer.Compare); + Array.Sort(input, StringLogicalComparer.Compare); - var i = 0; - foreach (var s in input) - { - Assert.Equal(s, expected[i]); - i++; - } + var i = 0; + foreach (var s in input) + { + Assert.Equal(s, expected[i]); + i++; } } } diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs index 813d824267..4d26edef7e 100644 --- a/API.Tests/Converters/CronConverterTests.cs +++ b/API.Tests/Converters/CronConverterTests.cs @@ -1,19 +1,18 @@ using API.Helpers.Converters; using Xunit; -namespace API.Tests.Converters +namespace API.Tests.Converters; + +public class CronConverterTests { - public class CronConverterTests + [Theory] + [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) { - [Theory] - [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)); - } + Assert.Equal(expected, CronConverter.ConvertToCronNotation(input)); } } diff --git a/API.Tests/Entities/SeriesTest.cs b/API.Tests/Entities/SeriesTest.cs index 70897b49fe..0b49bd3ddd 100644 --- a/API.Tests/Entities/SeriesTest.cs +++ b/API.Tests/Entities/SeriesTest.cs @@ -1,27 +1,26 @@ using API.Data; using Xunit; -namespace API.Tests.Entities +namespace API.Tests.Entities; + +/// +/// Tests for +/// +public class SeriesTest { - /// - /// Tests for - /// - public class SeriesTest + [Theory] + [InlineData("Darker than Black")] + public void CreateSeries(string name) { - [Theory] - [InlineData("Darker than Black")] - public void CreateSeries(string name) - { - var key = API.Services.Tasks.Scanner.Parser.Parser.Normalize(name); - var series = DbFactory.Series(name); - Assert.Equal(0, series.Id); - Assert.Equal(0, series.Pages); - Assert.Equal(name, series.Name); - Assert.Null(series.CoverImage); - Assert.Equal(name, series.LocalizedName); - Assert.Equal(name, series.SortName); - Assert.Equal(name, series.OriginalName); - Assert.Equal(key, series.NormalizedName); - } + var key = API.Services.Tasks.Scanner.Parser.Parser.Normalize(name); + var series = DbFactory.Series(name); + Assert.Equal(0, series.Id); + Assert.Equal(0, series.Pages); + Assert.Equal(name, series.Name); + Assert.Null(series.CoverImage); + Assert.Equal(name, series.LocalizedName); + Assert.Equal(name, series.SortName); + Assert.Equal(name, series.OriginalName); + Assert.Equal(key, series.NormalizedName); } -} \ No newline at end of file +} diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index a1beddf094..698ab185bf 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -6,140 +6,139 @@ using API.Parser; using Xunit; -namespace API.Tests.Extensions +namespace API.Tests.Extensions; + +public class ChapterListExtensionsTests { - public class ChapterListExtensionsTests + private static 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() - { - Range = range, - Number = number, - Files = new List() {file}, - IsSpecial = isSpecial - }; - } - - private static MangaFile CreateFile(string file, MangaFormat format) + return new Chapter() { - return new MangaFile() - { - FilePath = file, - Format = format - }; - } - - [Fact] - public void GetAnyChapterByRange_Test_ShouldBeNull() + Range = range, + Number = number, + Files = new List() {file}, + IsSpecial = isSpecial + }; + } + + private static MangaFile CreateFile(string file, MangaFormat format) + { + return new MangaFile() { - var info = new ParserInfo() - { - Chapters = "0", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - Filename = "darker than black.cbz", - IsSpecial = false, - Series = "darker than black", - Title = "darker than black", - Volumes = "0" - }; - - var chapterList = new List() - { - CreateChapter("darker than black - Some special", "0", CreateFile("/manga/darker than black - special.cbz", MangaFormat.Archive), true) - }; - - var actualChapter = chapterList.GetChapterByRange(info); - - Assert.NotEqual(chapterList[0], actualChapter); - - } - - [Fact] - public void GetAnyChapterByRange_Test_ShouldBeNotNull() + FilePath = file, + Format = format + }; + } + + [Fact] + public void GetAnyChapterByRange_Test_ShouldBeNull() + { + var info = new ParserInfo() { - var info = new ParserInfo() - { - Chapters = "0", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - Filename = "darker than black.cbz", - IsSpecial = true, - Series = "darker than black", - Title = "darker than black", - Volumes = "0" - }; - - var chapterList = new List() - { - CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true) - }; - - var actualChapter = chapterList.GetChapterByRange(info); - - Assert.Equal(chapterList[0], actualChapter); - } - - [Fact] - public void GetChapterByRange_On_Duplicate_Files_Test_Should_Not_Error() + Chapters = "0", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + Filename = "darker than black.cbz", + IsSpecial = false, + Series = "darker than black", + Title = "darker than black", + Volumes = "0" + }; + + var chapterList = new List() { - var info = new ParserInfo() - { - Chapters = "0", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/detective comics #001.cbz", - Filename = "detective comics #001.cbz", - IsSpecial = true, - Series = "detective comics", - Title = "detective comics", - Volumes = "0" - }; + CreateChapter("darker than black - Some special", "0", CreateFile("/manga/darker than black - special.cbz", MangaFormat.Archive), true) + }; + + var actualChapter = chapterList.GetChapterByRange(info); - var chapterList = new List() - { - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) - }; + Assert.NotEqual(chapterList[0], actualChapter); - var actualChapter = chapterList.GetChapterByRange(info); + } - Assert.Equal(chapterList[0], actualChapter); + [Fact] + public void GetAnyChapterByRange_Test_ShouldBeNotNull() + { + var info = new ParserInfo() + { + Chapters = "0", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + Filename = "darker than black.cbz", + IsSpecial = true, + Series = "darker than black", + Title = "darker than black", + Volumes = "0" + }; + + var chapterList = new List() + { + CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true) + }; - } + var actualChapter = chapterList.GetChapterByRange(info); - #region GetFirstChapterWithFiles + Assert.Equal(chapterList[0], actualChapter); + } [Fact] - public void GetFirstChapterWithFiles_ShouldReturnAllChapters() + public void GetChapterByRange_On_Duplicate_Files_Test_Should_Not_Error() + { + var info = new ParserInfo() { - 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), - }; + Chapters = "0", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/detective comics #001.cbz", + Filename = "detective comics #001.cbz", + IsSpecial = true, + Series = "detective comics", + Title = "detective comics", + Volumes = "0" + }; + + var chapterList = new List() + { + CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), + CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) + }; + + var actualChapter = chapterList.GetChapterByRange(info); - Assert.Equal(chapterList.First(), chapterList.GetFirstChapterWithFiles()); - } + Assert.Equal(chapterList[0], actualChapter); - [Fact] - public void GetFirstChapterWithFiles_ShouldReturnSecondChapter() + } + + #region GetFirstChapterWithFiles + + [Fact] + public void GetFirstChapterWithFiles_ShouldReturnAllChapters() + { + var chapterList = new List() { - 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), - }; + 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.First(), chapterList.GetFirstChapterWithFiles()); + } - Assert.Equal(chapterList.Last(), 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(); - #endregion + Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles()); } + + + #endregion } diff --git a/API.Tests/Extensions/FileInfoExtensionsTests.cs b/API.Tests/Extensions/FileInfoExtensionsTests.cs index 5e17ecaeb8..e708356a90 100644 --- a/API.Tests/Extensions/FileInfoExtensionsTests.cs +++ b/API.Tests/Extensions/FileInfoExtensionsTests.cs @@ -4,30 +4,29 @@ using API.Extensions; using Xunit; -namespace API.Tests.Extensions +namespace API.Tests.Extensions; + +public class FileInfoExtensionsTests { - public class FileInfoExtensionsTests - { - private static readonly string TestDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Extensions/Test Data/"); + private static readonly string TestDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Extensions/Test Data/"); - [Fact] - public void HasFileBeenModifiedSince_ShouldBeFalse() - { - var filepath = Path.Join(TestDirectory, "not modified.txt"); - var date = new FileInfo(filepath).LastWriteTime; - Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date)); - File.ReadAllText(filepath); - Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date)); - } + [Fact] + public void HasFileBeenModifiedSince_ShouldBeFalse() + { + var filepath = Path.Join(TestDirectory, "not modified.txt"); + var date = new FileInfo(filepath).LastWriteTime; + Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date)); + File.ReadAllText(filepath); + Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date)); + } - [Fact] - public void HasFileBeenModifiedSince_ShouldBeTrue() - { - var filepath = Path.Join(TestDirectory, "modified on run.txt"); - var date = new FileInfo(filepath).LastWriteTime; - Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date)); - File.AppendAllLines(filepath, new[] { DateTime.Now.ToString(CultureInfo.InvariantCulture) }); - Assert.True(new FileInfo(filepath).HasFileBeenModifiedSince(date)); - } + [Fact] + public void HasFileBeenModifiedSince_ShouldBeTrue() + { + var filepath = Path.Join(TestDirectory, "modified on run.txt"); + var date = new FileInfo(filepath).LastWriteTime; + Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date)); + File.AppendAllLines(filepath, new[] { DateTime.Now.ToString(CultureInfo.InvariantCulture) }); + Assert.True(new FileInfo(filepath).HasFileBeenModifiedSince(date)); } } diff --git a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index ff20403b11..6a00a829b0 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -10,44 +10,43 @@ using NSubstitute; using Xunit; -namespace API.Tests.Extensions +namespace API.Tests.Extensions; + +public class ParserInfoListExtensions { - public class ParserInfoListExtensions + private readonly IDefaultParser _defaultParser; + public ParserInfoListExtensions() { - private readonly IDefaultParser _defaultParser; - public ParserInfoListExtensions() - { - _defaultParser = - new DefaultParser(new DirectoryService(Substitute.For>(), - new MockFileSystem())); - } + _defaultParser = + new DefaultParser(new DirectoryService(Substitute.For>(), + new MockFileSystem())); + } - [Theory] - [InlineData(new[] {"1", "1", "3-5", "5", "8", "0", "0"}, new[] {"1", "3-5", "5", "8", "0"})] - public void DistinctVolumesTest(string[] volumeNumbers, string[] expectedNumbers) - { - var infos = volumeNumbers.Select(n => new ParserInfo() {Volumes = n}).ToList(); - Assert.Equal(expectedNumbers, infos.DistinctVolumes()); - } + [Theory] + [InlineData(new[] {"1", "1", "3-5", "5", "8", "0", "0"}, new[] {"1", "3-5", "5", "8", "0"})] + public void DistinctVolumesTest(string[] volumeNumbers, string[] expectedNumbers) + { + var infos = volumeNumbers.Select(n => new ParserInfo() {Volumes = n}).ToList(); + Assert.Equal(expectedNumbers, infos.DistinctVolumes()); + } - [Theory] - [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] - [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06-07) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] - [InlineData(new[] {@"Cynthia The Mission v20 c12-20 [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, false)] - public void HasInfoTest(string[] inputInfos, string[] inputChapters, bool expectedHasInfo) + [Theory] + [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] + [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06-07) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] + [InlineData(new[] {@"Cynthia The Mission v20 c12-20 [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, false)] + public void HasInfoTest(string[] inputInfos, string[] inputChapters, bool expectedHasInfo) + { + var infos = new List(); + foreach (var filename in inputInfos) { - var infos = new List(); - foreach (var filename in inputInfos) - { - infos.Add(_defaultParser.Parse( - filename, - string.Empty)); - } + infos.Add(_defaultParser.Parse( + filename, + string.Empty)); + } - var files = inputChapters.Select(s => EntityFactory.CreateMangaFile(s, MangaFormat.Archive, 199)).ToList(); - var chapter = EntityFactory.CreateChapter("0-6", false, files); + var files = inputChapters.Select(s => EntityFactory.CreateMangaFile(s, MangaFormat.Archive, 199)).ToList(); + var chapter = EntityFactory.CreateChapter("0-6", false, files); - Assert.Equal(expectedHasInfo, infos.HasInfo(chapter)); - } + Assert.Equal(expectedHasInfo, infos.HasInfo(chapter)); } } diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index b339b306d5..6825ad61af 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -7,86 +7,85 @@ using API.Services.Tasks.Scanner; using Xunit; -namespace API.Tests.Extensions +namespace API.Tests.Extensions; + +public class SeriesExtensionsTests { - public class SeriesExtensionsTests + [Theory] + [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker than Black"}, true)] + [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker_than_Black"}, true)] + [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker then Black!"}, false)] + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"Salem's Lot"}, true)] + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salems lot"}, true)] + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salem's lot"}, 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"}, true)] + [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, new [] {"Kanojo, Okarishimasu"}, true)] + public void NameInListTest(string[] seriesInput, string[] list, bool expected) { - [Theory] - [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker than Black"}, true)] - [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker_than_Black"}, true)] - [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker then Black!"}, false)] - [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"Salem's Lot"}, true)] - [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salems lot"}, true)] - [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salem's lot"}, 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"}, true)] - [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, new [] {"Kanojo, Okarishimasu"}, true)] - public void NameInListTest(string[] seriesInput, string[] list, bool expected) + var series = new Series() { - var series = new Series() - { - Name = seriesInput[0], - LocalizedName = seriesInput[1], - OriginalName = seriesInput[2], - NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), - Metadata = new SeriesMetadata() - }; + Name = seriesInput[0], + LocalizedName = seriesInput[1], + OriginalName = seriesInput[2], + NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), + Metadata = new SeriesMetadata() + }; - 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) + [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() { - var series = new Series() - { - Name = seriesInput[0], - LocalizedName = seriesInput[1], - OriginalName = seriesInput[2], - NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), - Metadata = new SeriesMetadata(), - }; + Name = seriesInput[0], + LocalizedName = seriesInput[1], + OriginalName = seriesInput[2], + NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), + Metadata = new SeriesMetadata(), + }; - var parserInfos = list.Select(s => new ParsedSeries() - { - Name = s, - NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize(s), - }).ToList(); + var parserInfos = list.Select(s => new ParsedSeries() + { + Name = s, + NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize(s), + }).ToList(); - // This doesn't do any checks against format - Assert.Equal(expected, series.NameInList(parserInfos)); - } + // 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)] - [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, "Rent", false)] - public void NameInParserInfoTest(string[] seriesInput, string parserSeries, bool expected) + [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)] + [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, "Rent", false)] + public void NameInParserInfoTest(string[] seriesInput, string parserSeries, bool expected) + { + var series = new Series() { - var series = new Series() - { - Name = seriesInput[0], - LocalizedName = seriesInput[1], - OriginalName = seriesInput[2], - NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), - Metadata = new SeriesMetadata() - }; - var info = new ParserInfo(); - info.Series = parserSeries; + Name = seriesInput[0], + LocalizedName = seriesInput[1], + OriginalName = seriesInput[2], + NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), + Metadata = new SeriesMetadata() + }; + var info = new ParserInfo(); + info.Series = parserSeries; - Assert.Equal(expected, series.NameInParserInfo(info)); - } + Assert.Equal(expected, series.NameInParserInfo(info)); + } - } } diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index 55d947cf5e..2f46cc1f46 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -4,80 +4,79 @@ using API.Entities.Enums; using API.Entities.Metadata; -namespace API.Tests.Helpers +namespace API.Tests.Helpers; + +/// +/// Used to help quickly create DB entities for Unit Testing +/// +public static class EntityFactory { - /// - /// Used to help quickly create DB entities for Unit Testing - /// - public static class EntityFactory + public static Series CreateSeries(string name) { - public static Series CreateSeries(string name) + return new Series() { - return new Series() - { - Name = name, - SortName = name, - LocalizedName = name, - NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize(name), - Volumes = new List(), - Metadata = new SeriesMetadata() - }; - } + Name = name, + SortName = name, + LocalizedName = name, + NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize(name), + Volumes = new List(), + Metadata = new SeriesMetadata() + }; + } - public static Volume CreateVolume(string volumeNumber, List chapters = null) + public static Volume CreateVolume(string volumeNumber, List chapters = null) + { + var chaps = chapters ?? new List(); + var pages = chaps.Count > 0 ? chaps.Max(c => c.Pages) : 0; + return new Volume() { - var chaps = chapters ?? new List(); - var pages = chaps.Count > 0 ? chaps.Max(c => c.Pages) : 0; - return new Volume() - { - Name = volumeNumber, - Number = (int) API.Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber), - Pages = pages, - Chapters = chaps - }; - } + Name = volumeNumber, + Number = (int) API.Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber), + Pages = pages, + Chapters = chaps + }; + } - public static Chapter CreateChapter(string range, bool isSpecial, List files = null, int pageCount = 0) + public static Chapter CreateChapter(string range, bool isSpecial, List files = null, int pageCount = 0) + { + return new Chapter() { - return new Chapter() - { - IsSpecial = isSpecial, - Range = range, - Number = API.Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(range) + string.Empty, - Files = files ?? new List(), - Pages = pageCount, + IsSpecial = isSpecial, + Range = range, + Number = API.Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(range) + string.Empty, + Files = files ?? new List(), + Pages = pageCount, - }; - } + }; + } - public static MangaFile CreateMangaFile(string filename, MangaFormat format, int pages) + public static MangaFile CreateMangaFile(string filename, MangaFormat format, int pages) + { + return new MangaFile() { - return new MangaFile() - { - FilePath = filename, - Format = format, - Pages = pages - }; - } + FilePath = filename, + Format = format, + Pages = pages + }; + } - public static SeriesMetadata CreateSeriesMetadata(ICollection collectionTags) + public static SeriesMetadata CreateSeriesMetadata(ICollection collectionTags) + { + return new SeriesMetadata() { - return new SeriesMetadata() - { - CollectionTags = collectionTags - }; - } + CollectionTags = collectionTags + }; + } - public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted) + public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted) + { + return new CollectionTag() { - return new CollectionTag() - { - Id = id, - NormalizedTitle = API.Services.Tasks.Scanner.Parser.Parser.Normalize(title).ToUpper(), - Title = title, - Summary = summary, - Promoted = promoted - }; - } + Id = id, + NormalizedTitle = API.Services.Tasks.Scanner.Parser.Parser.Normalize(title).ToUpper(), + Title = title, + Summary = summary, + Promoted = promoted + }; } } diff --git a/API.Tests/Helpers/ParserInfoFactory.cs b/API.Tests/Helpers/ParserInfoFactory.cs index 4b4a8e22a2..793b764b0d 100644 --- a/API.Tests/Helpers/ParserInfoFactory.cs +++ b/API.Tests/Helpers/ParserInfoFactory.cs @@ -6,68 +6,67 @@ using API.Parser; using API.Services.Tasks.Scanner; -namespace API.Tests.Helpers +namespace API.Tests.Helpers; + +public static class ParserInfoFactory { - public static class ParserInfoFactory + public static ParserInfo CreateParsedInfo(string series, string volumes, string chapters, string filename, bool isSpecial) { - public static ParserInfo CreateParsedInfo(string series, string volumes, string chapters, string filename, bool isSpecial) + return new ParserInfo() { - return new ParserInfo() - { - Chapters = chapters, - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = Path.Join(@"/manga/", filename), - Filename = filename, - IsSpecial = isSpecial, - Title = Path.GetFileNameWithoutExtension(filename), - Series = series, - Volumes = volumes - }; - } + Chapters = chapters, + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = Path.Join(@"/manga/", filename), + Filename = filename, + IsSpecial = isSpecial, + Title = Path.GetFileNameWithoutExtension(filename), + Series = series, + Volumes = volumes + }; + } - public static void AddToParsedInfo(IDictionary> collectedSeries, ParserInfo info) + public static void AddToParsedInfo(IDictionary> collectedSeries, ParserInfo info) + { + var existingKey = collectedSeries.Keys.FirstOrDefault(ps => + ps.Format == info.Format && ps.NormalizedName == API.Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series)); + existingKey ??= new ParsedSeries() { - var existingKey = collectedSeries.Keys.FirstOrDefault(ps => - ps.Format == info.Format && ps.NormalizedName == API.Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series)); - existingKey ??= new ParsedSeries() - { - Format = info.Format, - Name = info.Series, - NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) - }; - if (collectedSeries.GetType() == typeof(ConcurrentDictionary<,>)) + Format = info.Format, + Name = info.Series, + NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) + }; + if (collectedSeries.GetType() == typeof(ConcurrentDictionary<,>)) + { + ((ConcurrentDictionary>) collectedSeries).AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => { - ((ConcurrentDictionary>) collectedSeries).AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => + oldValue ??= new List(); + if (!oldValue.Contains(info)) { - oldValue ??= new List(); - if (!oldValue.Contains(info)) - { - oldValue.Add(info); - } + oldValue.Add(info); + } - return oldValue; - }); + return oldValue; + }); + } + else + { + if (!collectedSeries.ContainsKey(existingKey)) + { + collectedSeries.Add(existingKey, new List() {info}); } else { - if (!collectedSeries.ContainsKey(existingKey)) + var list = collectedSeries[existingKey]; + if (!list.Contains(info)) { - collectedSeries.Add(existingKey, new List() {info}); - } - else - { - var list = collectedSeries[existingKey]; - if (!list.Contains(info)) - { - list.Add(info); - } - - collectedSeries[existingKey] = list; + list.Add(info); } + collectedSeries[existingKey] = list; } } + } } diff --git a/API.Tests/Helpers/TestCaseGenerator.cs b/API.Tests/Helpers/TestCaseGenerator.cs index 41b99e5e4e..833da05024 100644 --- a/API.Tests/Helpers/TestCaseGenerator.cs +++ b/API.Tests/Helpers/TestCaseGenerator.cs @@ -1,53 +1,52 @@ using System.IO; -namespace API.Tests.Helpers +namespace API.Tests.Helpers; + +/// +/// Given a -testcase.txt file, will generate a folder with fake archive or book files. These files are just renamed txt files. +/// This currently is broken - you cannot create files from a unit test it seems +/// +public static class TestCaseGenerator { - /// - /// Given a -testcase.txt file, will generate a folder with fake archive or book files. These files are just renamed txt files. - /// This currently is broken - you cannot create files from a unit test it seems - /// - public static class TestCaseGenerator + public static string GenerateFiles(string directory, string fileToExpand) { - public static string GenerateFiles(string directory, string fileToExpand) - { - //var files = Directory.GetFiles(directory, fileToExpand); - var file = new FileInfo(fileToExpand); - if (!file.Exists && file.Name.EndsWith("-testcase.txt")) return string.Empty; + //var files = Directory.GetFiles(directory, fileToExpand); + var file = new FileInfo(fileToExpand); + if (!file.Exists && file.Name.EndsWith("-testcase.txt")) return string.Empty; - var baseDirectory = TestCaseGenerator.CreateTestBase(fileToExpand, directory); - var filesToCreate = File.ReadLines(file.FullName); - foreach (var fileToCreate in filesToCreate) - { - // var folders = DirectoryService.GetFoldersTillRoot(directory, fileToCreate); - // foreach (var VARIABLE in COLLECTION) - // { - // - // } - File.Create(fileToCreate); - } + var baseDirectory = TestCaseGenerator.CreateTestBase(fileToExpand, directory); + var filesToCreate = File.ReadLines(file.FullName); + foreach (var fileToCreate in filesToCreate) + { + // var folders = DirectoryService.GetFoldersTillRoot(directory, fileToCreate); + // foreach (var VARIABLE in COLLECTION) + // { + // + // } + File.Create(fileToCreate); + } - return baseDirectory; - } + return baseDirectory; + } - /// - /// Creates and returns a new base directory for data creation for a given testcase - /// - /// - /// - /// - private static string CreateTestBase(string file, string rootDirectory) + /// + /// Creates and returns a new base directory for data creation for a given testcase + /// + /// + /// + /// + private static string CreateTestBase(string file, string rootDirectory) + { + var baseDir = file.Split("-testcase.txt")[0]; + var newDirectory = Path.Join(rootDirectory, baseDir); + if (!Directory.Exists(newDirectory)) { - var baseDir = file.Split("-testcase.txt")[0]; - var newDirectory = Path.Join(rootDirectory, baseDir); - if (!Directory.Exists(newDirectory)) - { - new DirectoryInfo(newDirectory).Create(); - } - - return newDirectory; + new DirectoryInfo(newDirectory).Create(); } + + return newDirectory; } -} \ No newline at end of file +} diff --git a/API.Tests/Parser/BookParserTests.cs b/API.Tests/Parser/BookParserTests.cs index 23b9c6e63c..003dbfecc8 100644 --- a/API.Tests/Parser/BookParserTests.cs +++ b/API.Tests/Parser/BookParserTests.cs @@ -1,43 +1,42 @@ using Xunit; -namespace API.Tests.Parser +namespace API.Tests.Parser; + +public class BookParserTests { - public class BookParserTests + [Theory] + [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")] + [InlineData("BBC Focus 00 The Science of Happiness 2nd Edition (2018)", "BBC Focus 00 The Science of Happiness 2nd Edition")] + [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "Faust")] + public void ParseSeriesTest(string filename, string expected) { - [Theory] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")] - [InlineData("BBC Focus 00 The Science of Happiness 2nd Edition (2018)", "BBC Focus 00 The Science of Happiness 2nd Edition")] - [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "Faust")] - public void ParseSeriesTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); - } - - [Theory] - [InlineData("Harrison, Kim - Dates from Hell - Hollows Vol 2.5.epub", "2.5")] - [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "1")] - public void ParseVolumeTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); - } + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); + } - // [Theory] - // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA", "@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA")] - // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url('fonts/font.css')", "@font-face{font-family:'syyskuu_repaleinen';src:url('TEST/fonts/font.css')")] - // public void ReplaceFontSrcUrl(string input, string expected) - // { - // var apiBase = "TEST/"; - // var actual = API.Parser.Parser.FontSrcUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); - // Assert.Equal(expected, actual); - // } - // - // [Theory] - // [InlineData("@import url('font.css');", "@import url('TEST/font.css');")] - // public void ReplaceImportSrcUrl(string input, string expected) - // { - // var apiBase = "TEST/"; - // var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); - // Assert.Equal(expected, actual); - // } + [Theory] + [InlineData("Harrison, Kim - Dates from Hell - Hollows Vol 2.5.epub", "2.5")] + [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "1")] + public void ParseVolumeTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); } + + // [Theory] + // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA", "@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA")] + // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url('fonts/font.css')", "@font-face{font-family:'syyskuu_repaleinen';src:url('TEST/fonts/font.css')")] + // public void ReplaceFontSrcUrl(string input, string expected) + // { + // var apiBase = "TEST/"; + // var actual = API.Parser.Parser.FontSrcUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); + // Assert.Equal(expected, actual); + // } + // + // [Theory] + // [InlineData("@import url('font.css');", "@import url('TEST/font.css');")] + // public void ReplaceImportSrcUrl(string input, string expected) + // { + // var apiBase = "TEST/"; + // var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); + // Assert.Equal(expected, actual); + // } } diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 74a2b8bb26..90d325fa12 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -6,191 +6,190 @@ using Xunit; using Xunit.Abstractions; -namespace API.Tests.Parser +namespace API.Tests.Parser; + +public class ComicParserTests { - public class ComicParserTests - { - private readonly ITestOutputHelper _testOutputHelper; - private readonly DefaultParser _defaultParser; + private readonly ITestOutputHelper _testOutputHelper; + private readonly DefaultParser _defaultParser; - public ComicParserTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - _defaultParser = - new DefaultParser(new DirectoryService(Substitute.For>(), - new MockFileSystem())); - } + public ComicParserTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _defaultParser = + new DefaultParser(new DirectoryService(Substitute.For>(), + new MockFileSystem())); + } - [Theory] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "Asterix the Gladiator")] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "The First Asterix Frieze")] - [InlineData("Batman & Catwoman - Trail of the Gun 01", "Batman & Catwoman - Trail of the Gun")] - [InlineData("Batman & Daredevil - King of New York", "Batman & Daredevil - King of New York")] - [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "Batman & Grendel")] - [InlineData("Batman & Robin the Teen Wonder #0", "Batman & Robin the Teen Wonder")] - [InlineData("Batman & Wildcat (1 of 3)", "Batman & Wildcat")] - [InlineData("Batman And Superman World's Finest #01", "Batman And Superman World's Finest")] - [InlineData("Babe 01", "Babe")] - [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "Scott Pilgrim")] - [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "Teen Titans")] - [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "Scott Pilgrim")] - [InlineData("Wolverine - Origins 003 (2006) (digital) (Minutemen-PhD)", "Wolverine - Origins")] - [InlineData("Invincible Vol 01 Family matters (2005) (Digital).cbr", "Invincible")] - [InlineData("Amazing Man Comics chapter 25", "Amazing Man Comics")] - [InlineData("Amazing Man Comics issue #25", "Amazing Man Comics")] - [InlineData("Teen Titans v1 038 (1972) (c2c).cbr", "Teen Titans")] - [InlineData("Batman Beyond 02 (of 6) (1999)", "Batman Beyond")] - [InlineData("Batman Beyond - Return of the Joker (2001)", "Batman Beyond - Return of the Joker")] - [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "Invincible")] - [InlineData("Batman Wayne Family Adventures - Ep. 001 - Moving In", "Batman Wayne Family Adventures")] - [InlineData("Saga 001 (2012) (Digital) (Empire-Zone).cbr", "Saga")] - [InlineData("spawn-123", "spawn")] - [InlineData("spawn-chapter-123", "spawn")] - [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "Spawn")] - [InlineData("Batman Beyond 04 (of 6) (1999)", "Batman Beyond")] - [InlineData("Batman Beyond 001 (2012)", "Batman Beyond")] - [InlineData("Batman Beyond 2.0 001 (2013)", "Batman Beyond 2.0")] - [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "Batman - Catwoman")] - [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "Chew")] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "Chew Script Book")] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", "Batman - Detective Comics - Rebirth Deluxe Edition Book")] - [InlineData("Cyberpunk 2077 - Your Voice #01", "Cyberpunk 2077 - Your Voice")] - [InlineData("Cyberpunk 2077 #01", "Cyberpunk 2077")] - [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "Cyberpunk 2077 - Trauma Team")] - [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "Batgirl")] - [InlineData("Batgirl V2000 #57", "Batgirl")] - [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire)", "Fables")] - [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")] - [InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")] - [InlineData("Batman - The Man Who Laughs #1 (2005)", "Batman - The Man Who Laughs")] - [InlineData("Demon 012 (Sep 1973) c2c", "Demon")] - [InlineData("Dragon Age - Until We Sleep 01 (of 03)", "Dragon Age - Until We Sleep")] - [InlineData("Green Lantern v2 017 - The Spy-Eye that doomed Green Lantern v2", "Green Lantern")] - [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire - Adam Strange")] - [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis - Rags Morales Sketches")] - [InlineData("Daredevil - t6 - 10 - (2019)", "Daredevil")] - [InlineData("Batgirl T2000 #57", "Batgirl")] - [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "Teen Titans")] - [InlineData("Conquistador_-Tome_2", "Conquistador")] - [InlineData("Max_l_explorateur-_Tome_0", "Max l explorateur")] - [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")] - [InlineData("Bd Fr-Aldebaran-Antares-t6", "Aldebaran-Antares")] - [InlineData("Tintin - T22 Vol 714 pour Sydney", "Tintin")] - [InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")] - public void ParseComicSeriesTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename)); - } + [Theory] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "Asterix the Gladiator")] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "The First Asterix Frieze")] + [InlineData("Batman & Catwoman - Trail of the Gun 01", "Batman & Catwoman - Trail of the Gun")] + [InlineData("Batman & Daredevil - King of New York", "Batman & Daredevil - King of New York")] + [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "Batman & Grendel")] + [InlineData("Batman & Robin the Teen Wonder #0", "Batman & Robin the Teen Wonder")] + [InlineData("Batman & Wildcat (1 of 3)", "Batman & Wildcat")] + [InlineData("Batman And Superman World's Finest #01", "Batman And Superman World's Finest")] + [InlineData("Babe 01", "Babe")] + [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "Scott Pilgrim")] + [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "Teen Titans")] + [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "Scott Pilgrim")] + [InlineData("Wolverine - Origins 003 (2006) (digital) (Minutemen-PhD)", "Wolverine - Origins")] + [InlineData("Invincible Vol 01 Family matters (2005) (Digital).cbr", "Invincible")] + [InlineData("Amazing Man Comics chapter 25", "Amazing Man Comics")] + [InlineData("Amazing Man Comics issue #25", "Amazing Man Comics")] + [InlineData("Teen Titans v1 038 (1972) (c2c).cbr", "Teen Titans")] + [InlineData("Batman Beyond 02 (of 6) (1999)", "Batman Beyond")] + [InlineData("Batman Beyond - Return of the Joker (2001)", "Batman Beyond - Return of the Joker")] + [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "Invincible")] + [InlineData("Batman Wayne Family Adventures - Ep. 001 - Moving In", "Batman Wayne Family Adventures")] + [InlineData("Saga 001 (2012) (Digital) (Empire-Zone).cbr", "Saga")] + [InlineData("spawn-123", "spawn")] + [InlineData("spawn-chapter-123", "spawn")] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "Spawn")] + [InlineData("Batman Beyond 04 (of 6) (1999)", "Batman Beyond")] + [InlineData("Batman Beyond 001 (2012)", "Batman Beyond")] + [InlineData("Batman Beyond 2.0 001 (2013)", "Batman Beyond 2.0")] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "Batman - Catwoman")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "Chew")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "Chew Script Book")] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", "Batman - Detective Comics - Rebirth Deluxe Edition Book")] + [InlineData("Cyberpunk 2077 - Your Voice #01", "Cyberpunk 2077 - Your Voice")] + [InlineData("Cyberpunk 2077 #01", "Cyberpunk 2077")] + [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "Cyberpunk 2077 - Trauma Team")] + [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "Batgirl")] + [InlineData("Batgirl V2000 #57", "Batgirl")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire)", "Fables")] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")] + [InlineData("Daredevil - v6 - 10 - (2019)", "Daredevil")] + [InlineData("Batman - The Man Who Laughs #1 (2005)", "Batman - The Man Who Laughs")] + [InlineData("Demon 012 (Sep 1973) c2c", "Demon")] + [InlineData("Dragon Age - Until We Sleep 01 (of 03)", "Dragon Age - Until We Sleep")] + [InlineData("Green Lantern v2 017 - The Spy-Eye that doomed Green Lantern v2", "Green Lantern")] + [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire - Adam Strange")] + [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis - Rags Morales Sketches")] + [InlineData("Daredevil - t6 - 10 - (2019)", "Daredevil")] + [InlineData("Batgirl T2000 #57", "Batgirl")] + [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "Teen Titans")] + [InlineData("Conquistador_-Tome_2", "Conquistador")] + [InlineData("Max_l_explorateur-_Tome_0", "Max l explorateur")] + [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")] + [InlineData("Bd Fr-Aldebaran-Antares-t6", "Aldebaran-Antares")] + [InlineData("Tintin - T22 Vol 714 pour Sydney", "Tintin")] + [InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")] + public void ParseComicSeriesTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename)); + } - [Theory] - [InlineData("01 Spider-Man & Wolverine 01.cbr", "0")] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] - [InlineData("Batman & Catwoman - Trail of the Gun 01", "0")] - [InlineData("Batman & Daredevil - King of New York", "0")] - [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "0")] - [InlineData("Batman & Robin the Teen Wonder #0", "0")] - [InlineData("Batman & Wildcat (1 of 3)", "0")] - [InlineData("Batman And Superman World's Finest #01", "0")] - [InlineData("Babe 01", "0")] - [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "0")] - [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] - [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "0")] - [InlineData("Superman v1 024 (09-10 1943)", "1")] - [InlineData("Amazing Man Comics chapter 25", "0")] - [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")] - [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] - [InlineData("spawn-123", "0")] - [InlineData("spawn-chapter-123", "0")] - [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "0")] - [InlineData("Batman Beyond 04 (of 6) (1999)", "0")] - [InlineData("Batman Beyond 001 (2012)", "0")] - [InlineData("Batman Beyond 2.0 001 (2013)", "0")] - [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "0")] - [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "1")] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] - [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "2000")] - [InlineData("Batgirl V2000 #57", "2000")] - [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "0")] - [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] - [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "0")] - [InlineData("Daredevil - v6 - 10 - (2019)", "6")] - // Tome Tests - [InlineData("Daredevil - t6 - 10 - (2019)", "6")] - [InlineData("Batgirl T2000 #57", "2000")] - [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] - [InlineData("Conquistador_Tome_2", "2")] - [InlineData("Max_l_explorateur-_Tome_0", "0")] - [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "3")] - [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "0")] - [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "1")] - public void ParseComicVolumeTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename)); - } + [Theory] + [InlineData("01 Spider-Man & Wolverine 01.cbr", "0")] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] + [InlineData("Batman & Catwoman - Trail of the Gun 01", "0")] + [InlineData("Batman & Daredevil - King of New York", "0")] + [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "0")] + [InlineData("Batman & Robin the Teen Wonder #0", "0")] + [InlineData("Batman & Wildcat (1 of 3)", "0")] + [InlineData("Batman And Superman World's Finest #01", "0")] + [InlineData("Babe 01", "0")] + [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "0")] + [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] + [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "0")] + [InlineData("Superman v1 024 (09-10 1943)", "1")] + [InlineData("Amazing Man Comics chapter 25", "0")] + [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")] + [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] + [InlineData("spawn-123", "0")] + [InlineData("spawn-chapter-123", "0")] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "0")] + [InlineData("Batman Beyond 04 (of 6) (1999)", "0")] + [InlineData("Batman Beyond 001 (2012)", "0")] + [InlineData("Batman Beyond 2.0 001 (2013)", "0")] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "0")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "1")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] + [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "2000")] + [InlineData("Batgirl V2000 #57", "2000")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "0")] + [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "0")] + [InlineData("Daredevil - v6 - 10 - (2019)", "6")] + // Tome Tests + [InlineData("Daredevil - t6 - 10 - (2019)", "6")] + [InlineData("Batgirl T2000 #57", "2000")] + [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] + [InlineData("Conquistador_Tome_2", "2")] + [InlineData("Max_l_explorateur-_Tome_0", "0")] + [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "3")] + [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "0")] + [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "1")] + public void ParseComicVolumeTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename)); + } - [Theory] - [InlineData("01 Spider-Man & Wolverine 01.cbr", "1")] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] - [InlineData("Batman & Catwoman - Trail of the Gun 01", "1")] - [InlineData("Batman & Daredevil - King of New York", "0")] - [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")] - [InlineData("Batman & Robin the Teen Wonder #0", "0")] - [InlineData("Batman & Wildcat (1 of 3)", "1")] - [InlineData("Batman & Wildcat (2 of 3)", "2")] - [InlineData("Batman And Superman World's Finest #01", "1")] - [InlineData("Babe 01", "1")] - [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "1")] - [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] - [InlineData("Superman v1 024 (09-10 1943)", "24")] - [InlineData("Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr", "70.5")] - [InlineData("Amazing Man Comics chapter 25", "25")] - [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "33.5")] - [InlineData("Batman Wayne Family Adventures - Ep. 014 - Moving In", "14")] - [InlineData("Saga 001 (2012) (Digital) (Empire-Zone)", "1")] - [InlineData("spawn-123", "123")] - [InlineData("spawn-chapter-123", "123")] - [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "62")] - [InlineData("Batman Beyond 04 (of 6) (1999)", "4")] - [InlineData("Invincible 052 (c2c) (2008) (Minutemen-TheCouple)", "52")] - [InlineData("Y - The Last Man #001", "1")] - [InlineData("Batman Beyond 001 (2012)", "1")] - [InlineData("Batman Beyond 2.0 001 (2013)", "1")] - [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "1")] - [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "0")] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] - [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "57")] - [InlineData("Batgirl V2000 #57", "57")] - [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "21")] - [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "4")] - [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")] - [InlineData("Daredevil - v6 - 10 - (2019)", "10")] - [InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")] - [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "1")] - [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "0")] - public void ParseComicChapterTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename)); - } + [Theory] + [InlineData("01 Spider-Man & Wolverine 01.cbr", "1")] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] + [InlineData("Batman & Catwoman - Trail of the Gun 01", "1")] + [InlineData("Batman & Daredevil - King of New York", "0")] + [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")] + [InlineData("Batman & Robin the Teen Wonder #0", "0")] + [InlineData("Batman & Wildcat (1 of 3)", "1")] + [InlineData("Batman & Wildcat (2 of 3)", "2")] + [InlineData("Batman And Superman World's Finest #01", "1")] + [InlineData("Babe 01", "1")] + [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "1")] + [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] + [InlineData("Superman v1 024 (09-10 1943)", "24")] + [InlineData("Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr", "70.5")] + [InlineData("Amazing Man Comics chapter 25", "25")] + [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "33.5")] + [InlineData("Batman Wayne Family Adventures - Ep. 014 - Moving In", "14")] + [InlineData("Saga 001 (2012) (Digital) (Empire-Zone)", "1")] + [InlineData("spawn-123", "123")] + [InlineData("spawn-chapter-123", "123")] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "62")] + [InlineData("Batman Beyond 04 (of 6) (1999)", "4")] + [InlineData("Invincible 052 (c2c) (2008) (Minutemen-TheCouple)", "52")] + [InlineData("Y - The Last Man #001", "1")] + [InlineData("Batman Beyond 001 (2012)", "1")] + [InlineData("Batman Beyond 2.0 001 (2013)", "1")] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "1")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "0")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] + [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "57")] + [InlineData("Batgirl V2000 #57", "57")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "21")] + [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "4")] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")] + [InlineData("Daredevil - v6 - 10 - (2019)", "10")] + [InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")] + [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "1")] + [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "0")] + public void ParseComicChapterTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename)); + } - [Theory] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", true)] - [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", true)] - [InlineData("Baldwin the Brave & Other Tales Special SP1.cbr", true)] - [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", true)] - [InlineData("Boule et Bill - THS -Bill à disparu", true)] - [InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)] - [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] - [InlineData("laughs", false)] - [InlineData("Annual Days of Summer", false)] - [InlineData("Adventure Time 2013 Annual #001 (2013)", true)] - [InlineData("Adventure Time 2013_Annual_#001 (2013)", true)] - [InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)] - public void ParseComicSpecialTest(string input, bool expected) - { - Assert.Equal(expected, !string.IsNullOrEmpty(API.Services.Tasks.Scanner.Parser.Parser.ParseComicSpecial(input))); - } + [Theory] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", true)] + [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", true)] + [InlineData("Baldwin the Brave & Other Tales Special SP1.cbr", true)] + [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", true)] + [InlineData("Boule et Bill - THS -Bill à disparu", true)] + [InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)] + [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] + [InlineData("laughs", false)] + [InlineData("Annual Days of Summer", false)] + [InlineData("Adventure Time 2013 Annual #001 (2013)", true)] + [InlineData("Adventure Time 2013_Annual_#001 (2013)", true)] + [InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)] + public void ParseComicSpecialTest(string input, bool expected) + { + Assert.Equal(expected, !string.IsNullOrEmpty(API.Services.Tasks.Scanner.Parser.Parser.ParseComicSpecial(input))); } } diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 12e3126619..e2aa78c6e3 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -2,320 +2,319 @@ using Xunit; using Xunit.Abstractions; -namespace API.Tests.Parser +namespace API.Tests.Parser; + +public class MangaParserTests { - public class MangaParserTests - { - private readonly ITestOutputHelper _testOutputHelper; + private readonly ITestOutputHelper _testOutputHelper; - public MangaParserTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - } + public MangaParserTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } - [Theory] - [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] - [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] - [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "11")] - [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "1")] - [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "1")] - [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1")] - [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] - [InlineData("Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", "1")] - [InlineData("v001", "1")] - [InlineData("Vol 1", "1")] - [InlineData("vol_356-1", "356")] // Mangapy syntax - [InlineData("No Volume", "0")] - [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] - [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1")] - [InlineData("Tonikaku Cawaii [Volume 11].cbz", "11")] - [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "2")] - [InlineData("[xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]", "1")] - [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "1")] - [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "17")] - [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "1")] - [InlineData("Future Diary v02 (2009) (Digital) (Viz).cbz", "2")] - [InlineData("Mujaki no Rakuen Vol12 ch76", "12")] - [InlineData("Ichinensei_ni_Nacchattara_v02_ch11_[Taruby]_v1.3.zip", "2")] - [InlineData("Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", "1")] - [InlineData("Dorohedoro v11 (2013) (Digital) (LostNerevarine-Empire).cbz", "11")] - [InlineData("Dorohedoro v12 (2013) (Digital) (LostNerevarine-Empire).cbz", "12")] - [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] - [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "0")] - [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "0")] - [InlineData("VanDread-v01-c001[MD].zip", "1")] - [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch27_[VISCANS].zip", "4")] - [InlineData("Mob Psycho 100 v02 (2019) (Digital) (Shizu).cbz", "2")] - [InlineData("Kodomo no Jikan vol. 1.cbz", "1")] - [InlineData("Kodomo no Jikan vol. 10.cbz", "10")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", "0")] - [InlineData("Vagabond_v03", "3")] - [InlineData("Mujaki No Rakune Volume 10.cbz", "10")] - [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "0")] - [InlineData("Volume 12 - Janken Boy is Coming!.cbz", "12")] - [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "20")] - [InlineData("Gantz.V26.cbz", "26")] - [InlineData("NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar", "4")] - [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "1")] - [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "4")] - [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "1")] - [InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")] - [InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")] - [InlineData("X-Men v1 #201 (September 2007).cbz", "1")] - [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")] - [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "3")] - [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03.5 Ch. 023.5 - Volume 3 Extras.cbz", "3.5")] - [InlineData("幽游白书完全版 第03卷 天下", "3")] - [InlineData("阿衰online 第1册", "1")] - [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")] - [InlineData("63권#200", "63")] - [InlineData("시즌34삽화2", "34")] - [InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1巻", "1")] - [InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")] - public void ParseVolumeTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); - } + [Theory] + [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] + [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] + [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "11")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "1")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "1")] + [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] + [InlineData("Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", "1")] + [InlineData("v001", "1")] + [InlineData("Vol 1", "1")] + [InlineData("vol_356-1", "356")] // Mangapy syntax + [InlineData("No Volume", "0")] + [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1")] + [InlineData("Tonikaku Cawaii [Volume 11].cbz", "11")] + [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "2")] + [InlineData("[xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]", "1")] + [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "1")] + [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "17")] + [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "1")] + [InlineData("Future Diary v02 (2009) (Digital) (Viz).cbz", "2")] + [InlineData("Mujaki no Rakuen Vol12 ch76", "12")] + [InlineData("Ichinensei_ni_Nacchattara_v02_ch11_[Taruby]_v1.3.zip", "2")] + [InlineData("Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", "1")] + [InlineData("Dorohedoro v11 (2013) (Digital) (LostNerevarine-Empire).cbz", "11")] + [InlineData("Dorohedoro v12 (2013) (Digital) (LostNerevarine-Empire).cbz", "12")] + [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] + [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "0")] + [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "0")] + [InlineData("VanDread-v01-c001[MD].zip", "1")] + [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch27_[VISCANS].zip", "4")] + [InlineData("Mob Psycho 100 v02 (2019) (Digital) (Shizu).cbz", "2")] + [InlineData("Kodomo no Jikan vol. 1.cbz", "1")] + [InlineData("Kodomo no Jikan vol. 10.cbz", "10")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", "0")] + [InlineData("Vagabond_v03", "3")] + [InlineData("Mujaki No Rakune Volume 10.cbz", "10")] + [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "0")] + [InlineData("Volume 12 - Janken Boy is Coming!.cbz", "12")] + [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "20")] + [InlineData("Gantz.V26.cbz", "26")] + [InlineData("NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar", "4")] + [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "1")] + [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "4")] + [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "1")] + [InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")] + [InlineData("X-Men v1 #201 (September 2007).cbz", "1")] + [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")] + [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "3")] + [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03.5 Ch. 023.5 - Volume 3 Extras.cbz", "3.5")] + [InlineData("幽游白书完全版 第03卷 天下", "3")] + [InlineData("阿衰online 第1册", "1")] + [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")] + [InlineData("63권#200", "63")] + [InlineData("시즌34삽화2", "34")] + [InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1巻", "1")] + [InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")] + public void ParseVolumeTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); + } - [Theory] - [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "Killing Bites")] - [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "My Girlfriend Is Shobitch")] - [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "Historys Strongest Disciple Kenichi")] - [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "B Gata H Kei")] - [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "BTOOOM!")] - [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "Gokukoku no Brynhildr")] - [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "Dance in the Vampire Bund")] - [InlineData("v001", "")] - [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "U12")] - [InlineData("Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)", "Akame ga KILL! ZERO")] - [InlineData("APOSIMZ 017 (2018) (Digital) (danke-Empire).cbz", "APOSIMZ")] - [InlineData("Akiiro Bousou Biyori - 01.jpg", "Akiiro Bousou Biyori")] - [InlineData("Beelzebub_172_RHS.zip", "Beelzebub")] - [InlineData("Dr. STONE 136 (2020) (Digital) (LuCaZ).cbz", "Dr. STONE")] - [InlineData("Cynthia the Mission 29.rar", "Cynthia the Mission")] - [InlineData("Darling in the FranXX - Volume 01.cbz", "Darling in the FranXX")] - [InlineData("Darwin's Game - Volume 14 (F).cbz", "Darwin's Game")] - [InlineData("[BAA]_Darker_than_Black_c7.zip", "Darker than Black")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip", "Kedouin Makoto - Corpse Party Musume")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "Kedouin Makoto - Corpse Party Musume")] - [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "Ichiban Ushiro no Daimaou")] - [InlineData("[xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]", "Kyochuu Rettou")] - [InlineData("Loose_Relation_Between_Wizard_and_Apprentice_c07[AN].zip", "Loose Relation Between Wizard and Apprentice")] - [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "Tower Of God")] - [InlineData("Tenjou_Tenge_c106[MT].zip", "Tenjou Tenge")] - [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "Tenjou Tenge")] - [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "Shimoneta - Manmaru Hen")] - [InlineData("Future Diary v02 (2009) (Digital) (Viz).cbz", "Future Diary")] - [InlineData("Tonikaku Cawaii [Volume 11].cbz", "Tonikaku Cawaii")] - [InlineData("Mujaki no Rakuen Vol12 ch76", "Mujaki no Rakuen")] - [InlineData("Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans]", "Knights of Sidonia")] - [InlineData("Vol 1.cbz", "")] - [InlineData("Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", "Ichinensei ni Nacchattara")] - [InlineData("Chrno_Crusade_Dragon_Age_All_Stars[AS].zip", "")] - [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip", "Ichiban Ushiro no Daimaou")] - [InlineData("Rent a Girlfriend v01.cbr", "Rent a Girlfriend")] - [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "Yumekui Merry")] - [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "Itoshi no Karin")] - [InlineData("Tonikaku Kawaii Vol-1 (Ch 01-08)", "Tonikaku Kawaii")] - [InlineData("Tonikaku Kawaii (Ch 59-67) (Ongoing)", "Tonikaku Kawaii")] - [InlineData("7thGARDEN v01 (2016) (Digital) (danke).cbz", "7thGARDEN")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "Kedouin Makoto - Corpse Party Musume")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09", "Kedouin Makoto - Corpse Party Musume")] - [InlineData("Goblin Slayer Side Story - Year One 025.5", "Goblin Slayer Side Story - Year One")] - [InlineData("Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire)", "Goblin Slayer - Brand New Day")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01 [Dametrans][v2]", "Kedouin Makoto - Corpse Party Musume")] - [InlineData("Vagabond_v03", "Vagabond")] - [InlineData("[AN] Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1", "Mahoutsukai to Deshi no Futekisetsu na Kankei")] - [InlineData("Beelzebub_Side_Story_02_RHS.zip", "Beelzebub Side Story")] - [InlineData("[BAA]_Darker_than_Black_Omake-1.zip", "Darker than Black")] - [InlineData("Baketeriya ch01-05.zip", "Baketeriya")] - [InlineData("[PROzess]Kimi_ha_midara_na_Boku_no_Joou_-_Ch01", "Kimi ha midara na Boku no Joou")] - [InlineData("[SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar", "NEEDLESS")] - [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "Fullmetal Alchemist")] - [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "To Love Ru")] - [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "One Piece - Digital Colored Comics")] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01", "Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U")] - [InlineData("Vol03_ch15-22.rar", "")] - [InlineData("Love Hina - Special.cbz", "")] // This has to be a fallback case - [InlineData("Ani-Hina Art Collection.cbz", "")] // This has to be a fallback case - [InlineData("Magi - Ch.252-005.cbz", "Magi")] - [InlineData("Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1", "Umineko no Naku Koro ni")] - [InlineData("Kimetsu no Yaiba - Digital Colored Comics c162 Three Victorious Stars.cbz", "Kimetsu no Yaiba - Digital Colored Comics")] - [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "Amaenaideyo MS")] - [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "NEEDLESS")] - [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "Okusama wa Shougakusei")] - [InlineData("VanDread-v01-c001[MD].zip", "VanDread")] - [InlineData("Momo The Blood Taker - Chapter 027 Violent Emotion.cbz", "Momo The Blood Taker")] - [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "Kiss x Sis")] - [InlineData("Green Worldz - Chapter 112 Final Chapter (End).cbz", "Green Worldz")] - [InlineData("Noblesse - Episode 406 (52 Pages).7z", "Noblesse")] - [InlineData("X-Men v1 #201 (September 2007).cbz", "X-Men")] - [InlineData("Kodoja #001 (March 2016)", "Kodoja")] - [InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "Boku No Kokoro No Yabai Yatsu")] - [InlineData("Kiss x Sis - Ch.36 - A Cold Home Visit.cbz", "Kiss x Sis")] - [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ)", "Seraph of the End - Vampire Reign")] - [InlineData("Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz", "Grand Blue Dreaming")] - [InlineData("Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz", "Yuusha Ga Shinda!")] - [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")] - [InlineData("Getsuyoubi no Tawawa - Ch. 001 - Ai-chan, Part 1", "Getsuyoubi no Tawawa")] - [InlineData("Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz", "Please Go Home, Akutsu-San!")] - [InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")] - [InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")] - [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")] - [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")] - [InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo")] - [InlineData("The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz", "The Duke of Death and His Black Maid")] - [InlineData("The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake", "The Duke of Death and His Black Maid")] - [InlineData("Vol. 04 Ch. 054.5", "")] - [InlineData("Great_Teacher_Onizuka_v16[TheSpectrum]", "Great Teacher Onizuka")] - [InlineData("[Renzokusei]_Kimi_wa_Midara_na_Boku_no_Joou_Ch5_Final_Chapter", "Kimi wa Midara na Boku no Joou")] - [InlineData("Battle Royale, v01 (2000) [TokyoPop] [Manga-Sketchbook]", "Battle Royale")] - [InlineData("Kaiju No. 8 036 (2021) (Digital)", "Kaiju No. 8")] - [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")] - [InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")] - [InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")] - [InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")] - [InlineData("My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras", "My Charms Are Wasted on Kuroiwa Medaka")] - [InlineData("Highschool of the Dead - Full Color Edition v02 [Uasaha] (Yen Press)", "Highschool of the Dead - Full Color Edition")] - [InlineData("諌山創] 進撃の巨人 第23巻", "諌山創] 進撃の巨人")] - [InlineData("(一般コミック) [奥浩哉] いぬやしき 第09巻", "いぬやしき")] - [InlineData("Highschool of the Dead - 02", "Highschool of the Dead")] - public void ParseSeriesTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); - } + [Theory] + [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "Killing Bites")] + [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "My Girlfriend Is Shobitch")] + [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "Historys Strongest Disciple Kenichi")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "B Gata H Kei")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "BTOOOM!")] + [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "Gokukoku no Brynhildr")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "Dance in the Vampire Bund")] + [InlineData("v001", "")] + [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "U12")] + [InlineData("Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)", "Akame ga KILL! ZERO")] + [InlineData("APOSIMZ 017 (2018) (Digital) (danke-Empire).cbz", "APOSIMZ")] + [InlineData("Akiiro Bousou Biyori - 01.jpg", "Akiiro Bousou Biyori")] + [InlineData("Beelzebub_172_RHS.zip", "Beelzebub")] + [InlineData("Dr. STONE 136 (2020) (Digital) (LuCaZ).cbz", "Dr. STONE")] + [InlineData("Cynthia the Mission 29.rar", "Cynthia the Mission")] + [InlineData("Darling in the FranXX - Volume 01.cbz", "Darling in the FranXX")] + [InlineData("Darwin's Game - Volume 14 (F).cbz", "Darwin's Game")] + [InlineData("[BAA]_Darker_than_Black_c7.zip", "Darker than Black")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "Ichiban Ushiro no Daimaou")] + [InlineData("[xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]", "Kyochuu Rettou")] + [InlineData("Loose_Relation_Between_Wizard_and_Apprentice_c07[AN].zip", "Loose Relation Between Wizard and Apprentice")] + [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "Tower Of God")] + [InlineData("Tenjou_Tenge_c106[MT].zip", "Tenjou Tenge")] + [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "Tenjou Tenge")] + [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "Shimoneta - Manmaru Hen")] + [InlineData("Future Diary v02 (2009) (Digital) (Viz).cbz", "Future Diary")] + [InlineData("Tonikaku Cawaii [Volume 11].cbz", "Tonikaku Cawaii")] + [InlineData("Mujaki no Rakuen Vol12 ch76", "Mujaki no Rakuen")] + [InlineData("Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans]", "Knights of Sidonia")] + [InlineData("Vol 1.cbz", "")] + [InlineData("Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", "Ichinensei ni Nacchattara")] + [InlineData("Chrno_Crusade_Dragon_Age_All_Stars[AS].zip", "")] + [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip", "Ichiban Ushiro no Daimaou")] + [InlineData("Rent a Girlfriend v01.cbr", "Rent a Girlfriend")] + [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "Yumekui Merry")] + [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "Itoshi no Karin")] + [InlineData("Tonikaku Kawaii Vol-1 (Ch 01-08)", "Tonikaku Kawaii")] + [InlineData("Tonikaku Kawaii (Ch 59-67) (Ongoing)", "Tonikaku Kawaii")] + [InlineData("7thGARDEN v01 (2016) (Digital) (danke).cbz", "7thGARDEN")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Goblin Slayer Side Story - Year One 025.5", "Goblin Slayer Side Story - Year One")] + [InlineData("Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire)", "Goblin Slayer - Brand New Day")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01 [Dametrans][v2]", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Vagabond_v03", "Vagabond")] + [InlineData("[AN] Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1", "Mahoutsukai to Deshi no Futekisetsu na Kankei")] + [InlineData("Beelzebub_Side_Story_02_RHS.zip", "Beelzebub Side Story")] + [InlineData("[BAA]_Darker_than_Black_Omake-1.zip", "Darker than Black")] + [InlineData("Baketeriya ch01-05.zip", "Baketeriya")] + [InlineData("[PROzess]Kimi_ha_midara_na_Boku_no_Joou_-_Ch01", "Kimi ha midara na Boku no Joou")] + [InlineData("[SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar", "NEEDLESS")] + [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "Fullmetal Alchemist")] + [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "To Love Ru")] + [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "One Piece - Digital Colored Comics")] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01", "Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U")] + [InlineData("Vol03_ch15-22.rar", "")] + [InlineData("Love Hina - Special.cbz", "")] // This has to be a fallback case + [InlineData("Ani-Hina Art Collection.cbz", "")] // This has to be a fallback case + [InlineData("Magi - Ch.252-005.cbz", "Magi")] + [InlineData("Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1", "Umineko no Naku Koro ni")] + [InlineData("Kimetsu no Yaiba - Digital Colored Comics c162 Three Victorious Stars.cbz", "Kimetsu no Yaiba - Digital Colored Comics")] + [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "Amaenaideyo MS")] + [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "NEEDLESS")] + [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "Okusama wa Shougakusei")] + [InlineData("VanDread-v01-c001[MD].zip", "VanDread")] + [InlineData("Momo The Blood Taker - Chapter 027 Violent Emotion.cbz", "Momo The Blood Taker")] + [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "Kiss x Sis")] + [InlineData("Green Worldz - Chapter 112 Final Chapter (End).cbz", "Green Worldz")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", "Noblesse")] + [InlineData("X-Men v1 #201 (September 2007).cbz", "X-Men")] + [InlineData("Kodoja #001 (March 2016)", "Kodoja")] + [InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "Boku No Kokoro No Yabai Yatsu")] + [InlineData("Kiss x Sis - Ch.36 - A Cold Home Visit.cbz", "Kiss x Sis")] + [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ)", "Seraph of the End - Vampire Reign")] + [InlineData("Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz", "Grand Blue Dreaming")] + [InlineData("Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz", "Yuusha Ga Shinda!")] + [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")] + [InlineData("Getsuyoubi no Tawawa - Ch. 001 - Ai-chan, Part 1", "Getsuyoubi no Tawawa")] + [InlineData("Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz", "Please Go Home, Akutsu-San!")] + [InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")] + [InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")] + [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")] + [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")] + [InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo")] + [InlineData("The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz", "The Duke of Death and His Black Maid")] + [InlineData("The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake", "The Duke of Death and His Black Maid")] + [InlineData("Vol. 04 Ch. 054.5", "")] + [InlineData("Great_Teacher_Onizuka_v16[TheSpectrum]", "Great Teacher Onizuka")] + [InlineData("[Renzokusei]_Kimi_wa_Midara_na_Boku_no_Joou_Ch5_Final_Chapter", "Kimi wa Midara na Boku no Joou")] + [InlineData("Battle Royale, v01 (2000) [TokyoPop] [Manga-Sketchbook]", "Battle Royale")] + [InlineData("Kaiju No. 8 036 (2021) (Digital)", "Kaiju No. 8")] + [InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")] + [InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")] + [InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")] + [InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")] + [InlineData("My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras", "My Charms Are Wasted on Kuroiwa Medaka")] + [InlineData("Highschool of the Dead - Full Color Edition v02 [Uasaha] (Yen Press)", "Highschool of the Dead - Full Color Edition")] + [InlineData("諌山創] 進撃の巨人 第23巻", "諌山創] 進撃の巨人")] + [InlineData("(一般コミック) [奥浩哉] いぬやしき 第09巻", "いぬやしき")] + [InlineData("Highschool of the Dead - 02", "Highschool of the Dead")] + public void ParseSeriesTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); + } - [Theory] - [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] - [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] - [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "90-98")] - [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "0")] - [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "0")] - [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1-8")] - [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "0")] - [InlineData("c001", "1")] - [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", "12")] - [InlineData("Adding volume 1 with File: Ana Satsujin Vol. 1 Ch. 5 - Manga Box (gb).cbz", "5")] - [InlineData("Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz", "18")] - [InlineData("Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip", "0-6")] - [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "10")] - [InlineData("Loose_Relation_Between_Wizard_and_Apprentice_c07[AN].zip", "7")] - [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "14")] - [InlineData("Tenjou_Tenge_c106[MT].zip", "106")] - [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "100")] - [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "1-6")] - [InlineData("Mujaki no Rakuen Vol12 ch76", "76")] - [InlineData("Beelzebub_01_[Noodles].zip", "1")] - [InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")] - [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] - [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "11")] - [InlineData("Yumekui-Merry DKThiasScanlations Chapter51v2", "51")] - [InlineData("Yumekui-Merry_DKThiasScanlations&RenzokuseiScans_Chapter61", "61")] - [InlineData("Goblin Slayer Side Story - Year One 017.5", "17.5")] - [InlineData("Beelzebub_53[KSH].zip", "53")] - [InlineData("Black Bullet - v4 c20.5 [batoto]", "20.5")] - [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1-6")] - [InlineData("APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", "40")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "12")] - [InlineData("Vol 1", "0")] - [InlineData("VanDread-v01-c001[MD].zip", "1")] - [InlineData("Goblin Slayer Side Story - Year One 025.5", "25.5")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "1")] - [InlineData("To Love Ru v11 Uncensored (Ch.089-097+Omake)", "89-97")] - [InlineData("To Love Ru v18 Uncensored (Ch.153-162.5)", "153-162.5")] - [InlineData("[AN] Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1", "1")] - [InlineData("Beelzebub_Side_Story_02_RHS.zip", "2")] - [InlineData("[PROzess]Kimi_ha_midara_na_Boku_no_Joou_-_Ch01", "1")] - [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "101-108")] - [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "2")] - [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "71-79")] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter.rar", "0")] - [InlineData("Beelzebub_153b_RHS.zip", "153.5")] - [InlineData("Beelzebub_150-153b_RHS.zip", "150-153.5")] - [InlineData("Transferred to another world magical swordsman v1.1", "1")] - [InlineData("Transferred to another world magical swordsman v1.2", "2")] - [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "15")] - [InlineData("Kiss x Sis - Ch.12 - 1 , 2 , 3P!.cbz", "12")] - [InlineData("Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1", "1")] - [InlineData("Kiss x Sis - Ch.00 - Let's Start from 0.cbz", "0")] - [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "2")] - [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "3")] - [InlineData("Tomogui Kyoushitsu - Chapter 006 Game 005 - Fingernails On Right Hand (Part 002).cbz", "6")] - [InlineData("Noblesse - Episode 406 (52 Pages).7z", "406")] - [InlineData("X-Men v1 #201 (September 2007).cbz", "201")] - [InlineData("Kodoja #001 (March 2016)", "1")] - [InlineData("Noblesse - Episode 429 (74 Pages).7z", "429")] - [InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")] - [InlineData("Ijousha No Ai - Vol.01 Chapter 029 8 Years Ago", "29")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", "9")] - [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "34.5")] - [InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "1-10")] - [InlineData("Deku_&_Bakugo_-_Rising_v1_c1.1.cbz", "1.1")] - [InlineData("Chapter 63 - The Promise Made for 520 Cenz.cbr", "63")] - [InlineData("Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", "0")] - [InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")] - [InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")] - [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")] - [InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")] - [InlineData("[ハレム]ナナとカオル ~高校生のSMごっこ~ 第10話", "10")] - public void ParseChaptersTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); - } + [Theory] + [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] + [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] + [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "90-98")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "0")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "0")] + [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1-8")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "0")] + [InlineData("c001", "1")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", "12")] + [InlineData("Adding volume 1 with File: Ana Satsujin Vol. 1 Ch. 5 - Manga Box (gb).cbz", "5")] + [InlineData("Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz", "18")] + [InlineData("Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip", "0-6")] + [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "10")] + [InlineData("Loose_Relation_Between_Wizard_and_Apprentice_c07[AN].zip", "7")] + [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "14")] + [InlineData("Tenjou_Tenge_c106[MT].zip", "106")] + [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "100")] + [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "1-6")] + [InlineData("Mujaki no Rakuen Vol12 ch76", "76")] + [InlineData("Beelzebub_01_[Noodles].zip", "1")] + [InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")] + [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] + [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "11")] + [InlineData("Yumekui-Merry DKThiasScanlations Chapter51v2", "51")] + [InlineData("Yumekui-Merry_DKThiasScanlations&RenzokuseiScans_Chapter61", "61")] + [InlineData("Goblin Slayer Side Story - Year One 017.5", "17.5")] + [InlineData("Beelzebub_53[KSH].zip", "53")] + [InlineData("Black Bullet - v4 c20.5 [batoto]", "20.5")] + [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1-6")] + [InlineData("APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", "40")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "12")] + [InlineData("Vol 1", "0")] + [InlineData("VanDread-v01-c001[MD].zip", "1")] + [InlineData("Goblin Slayer Side Story - Year One 025.5", "25.5")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "1")] + [InlineData("To Love Ru v11 Uncensored (Ch.089-097+Omake)", "89-97")] + [InlineData("To Love Ru v18 Uncensored (Ch.153-162.5)", "153-162.5")] + [InlineData("[AN] Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1", "1")] + [InlineData("Beelzebub_Side_Story_02_RHS.zip", "2")] + [InlineData("[PROzess]Kimi_ha_midara_na_Boku_no_Joou_-_Ch01", "1")] + [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "101-108")] + [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "2")] + [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "71-79")] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter.rar", "0")] + [InlineData("Beelzebub_153b_RHS.zip", "153.5")] + [InlineData("Beelzebub_150-153b_RHS.zip", "150-153.5")] + [InlineData("Transferred to another world magical swordsman v1.1", "1")] + [InlineData("Transferred to another world magical swordsman v1.2", "2")] + [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "15")] + [InlineData("Kiss x Sis - Ch.12 - 1 , 2 , 3P!.cbz", "12")] + [InlineData("Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1", "1")] + [InlineData("Kiss x Sis - Ch.00 - Let's Start from 0.cbz", "0")] + [InlineData("[Hidoi]_Amaenaideyo_MS_vol01_chp02.rar", "2")] + [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "3")] + [InlineData("Tomogui Kyoushitsu - Chapter 006 Game 005 - Fingernails On Right Hand (Part 002).cbz", "6")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", "406")] + [InlineData("X-Men v1 #201 (September 2007).cbz", "201")] + [InlineData("Kodoja #001 (March 2016)", "1")] + [InlineData("Noblesse - Episode 429 (74 Pages).7z", "429")] + [InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")] + [InlineData("Ijousha No Ai - Vol.01 Chapter 029 8 Years Ago", "29")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", "9")] + [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "34.5")] + [InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "1-10")] + [InlineData("Deku_&_Bakugo_-_Rising_v1_c1.1.cbz", "1.1")] + [InlineData("Chapter 63 - The Promise Made for 520 Cenz.cbr", "63")] + [InlineData("Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", "0")] + [InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")] + [InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")] + [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")] + [InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")] + [InlineData("[ハレム]ナナとカオル ~高校生のSMごっこ~ 第10話", "10")] + public void ParseChaptersTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); + } - [Theory] - [InlineData("Tenjou Tenge Omnibus", "Omnibus")] - [InlineData("Tenjou Tenge {Full Contact Edition}", "")] - [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "")] - [InlineData("Wotakoi - Love is Hard for Otaku Omnibus v01 (2018) (Digital) (danke-Empire)", "Omnibus")] - [InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")] - [InlineData("Chobits Omnibus Edition v01 [Dark Horse]", "Omnibus Edition")] - [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "")] - [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "")] - [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")] - public void ParseEditionTest(string input, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseEdition(input)); - } - [Theory] - [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", true)] - [InlineData("Beelzebub_Omake_June_2012_RHS", true)] - [InlineData("Beelzebub_Side_Story_02_RHS.zip", false)] - [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", true)] - [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", true)] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] - [InlineData("Ani-Hina Art Collection.cbz", true)] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", true)] - [InlineData("A Town Where You Live - Bonus Chapter.zip", true)] - [InlineData("Yuki Merry - 4-Komga Anthology", false)] - [InlineData("Beastars - SP01", false)] - [InlineData("Beastars SP01", false)] - [InlineData("The League of Extraordinary Gentlemen", false)] - [InlineData("The League of Extra-ordinary Gentlemen", false)] - public void ParseMangaSpecialTest(string input, bool expected) - { - Assert.Equal(expected, !string.IsNullOrEmpty(API.Services.Tasks.Scanner.Parser.Parser.ParseMangaSpecial(input))); - } + [Theory] + [InlineData("Tenjou Tenge Omnibus", "Omnibus")] + [InlineData("Tenjou Tenge {Full Contact Edition}", "")] + [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "")] + [InlineData("Wotakoi - Love is Hard for Otaku Omnibus v01 (2018) (Digital) (danke-Empire)", "Omnibus")] + [InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")] + [InlineData("Chobits Omnibus Edition v01 [Dark Horse]", "Omnibus Edition")] + [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "")] + [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "")] + [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")] + public void ParseEditionTest(string input, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseEdition(input)); + } + [Theory] + [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", true)] + [InlineData("Beelzebub_Omake_June_2012_RHS", true)] + [InlineData("Beelzebub_Side_Story_02_RHS.zip", false)] + [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", true)] + [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", true)] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] + [InlineData("Ani-Hina Art Collection.cbz", true)] + [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", true)] + [InlineData("A Town Where You Live - Bonus Chapter.zip", true)] + [InlineData("Yuki Merry - 4-Komga Anthology", false)] + [InlineData("Beastars - SP01", false)] + [InlineData("Beastars SP01", false)] + [InlineData("The League of Extraordinary Gentlemen", false)] + [InlineData("The League of Extra-ordinary Gentlemen", false)] + public void ParseMangaSpecialTest(string input, bool expected) + { + Assert.Equal(expected, !string.IsNullOrEmpty(API.Services.Tasks.Scanner.Parser.Parser.ParseMangaSpecial(input))); + } - [Theory] - [InlineData("image.png", MangaFormat.Image)] - [InlineData("image.cbz", MangaFormat.Archive)] - [InlineData("image.txt", MangaFormat.Unknown)] - public void ParseFormatTest(string inputFile, MangaFormat expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseFormat(inputFile)); - } + [Theory] + [InlineData("image.png", MangaFormat.Image)] + [InlineData("image.cbz", MangaFormat.Archive)] + [InlineData("image.txt", MangaFormat.Unknown)] + public void ParseFormatTest(string inputFile, MangaFormat expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseFormat(inputFile)); + } - [Theory] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown].epub", "Side Stories")] - public void ParseSpecialTest(string inputFile, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseMangaSpecial(inputFile)); - } + [Theory] + [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown].epub", "Side Stories")] + public void ParseSpecialTest(string inputFile, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseMangaSpecial(inputFile)); + } - } } diff --git a/API.Tests/Parser/ParserInfoTests.cs b/API.Tests/Parser/ParserInfoTests.cs index 16906cf559..ee4881eff5 100644 --- a/API.Tests/Parser/ParserInfoTests.cs +++ b/API.Tests/Parser/ParserInfoTests.cs @@ -2,109 +2,108 @@ using API.Parser; using Xunit; -namespace API.Tests.Parser +namespace API.Tests.Parser; + +public class ParserInfoTests { - public class ParserInfoTests + [Fact] + public void MergeFromTest() { - [Fact] - public void MergeFromTest() + var p1 = new ParserInfo() { - var p1 = new ParserInfo() - { - Chapters = "0", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - IsSpecial = false, - Series = "darker than black", - Title = "darker than black", - Volumes = "0" - }; + Chapters = "0", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + IsSpecial = false, + Series = "darker than black", + Title = "darker than black", + Volumes = "0" + }; - var p2 = new ParserInfo() - { - Chapters = "1", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - IsSpecial = false, - Series = "darker than black", - Title = "Darker Than Black", - Volumes = "0" - }; + var p2 = new ParserInfo() + { + Chapters = "1", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + IsSpecial = false, + Series = "darker than black", + Title = "Darker Than Black", + Volumes = "0" + }; - var expected = new ParserInfo() - { - Chapters = "1", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - IsSpecial = false, - Series = "darker than black", - Title = "darker than black", - Volumes = "0" - }; - p1.Merge(p2); + var expected = new ParserInfo() + { + Chapters = "1", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + IsSpecial = false, + Series = "darker than black", + Title = "darker than black", + Volumes = "0" + }; + p1.Merge(p2); - AssertSame(expected, p1); + AssertSame(expected, p1); - } + } - [Fact] - public void MergeFromTest2() + [Fact] + public void MergeFromTest2() + { + var p1 = new ParserInfo() { - var p1 = new ParserInfo() - { - Chapters = "1", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - IsSpecial = true, - Series = "darker than black", - Title = "darker than black", - Volumes = "0" - }; + Chapters = "1", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + IsSpecial = true, + Series = "darker than black", + Title = "darker than black", + Volumes = "0" + }; - var p2 = new ParserInfo() - { - Chapters = "0", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - IsSpecial = false, - Series = "darker than black", - Title = "Darker Than Black", - Volumes = "1" - }; + var p2 = new ParserInfo() + { + Chapters = "0", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + IsSpecial = false, + Series = "darker than black", + Title = "Darker Than Black", + Volumes = "1" + }; - var expected = new ParserInfo() - { - Chapters = "1", - Edition = "", - Format = MangaFormat.Archive, - FullFilePath = "/manga/darker than black.cbz", - IsSpecial = true, - Series = "darker than black", - Title = "darker than black", - Volumes = "1" - }; - p1.Merge(p2); + var expected = new ParserInfo() + { + Chapters = "1", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/darker than black.cbz", + IsSpecial = true, + Series = "darker than black", + Title = "darker than black", + Volumes = "1" + }; + p1.Merge(p2); - AssertSame(expected, p1); + AssertSame(expected, p1); - } + } - private static void AssertSame(ParserInfo expected, ParserInfo actual) - { - Assert.Equal(expected.Chapters, actual.Chapters); - Assert.Equal(expected.Volumes, actual.Volumes); - Assert.Equal(expected.Edition, actual.Edition); - Assert.Equal(expected.Filename, actual.Filename); - Assert.Equal(expected.Format, actual.Format); - Assert.Equal(expected.Series, actual.Series); - Assert.Equal(expected.IsSpecial, actual.IsSpecial); - Assert.Equal(expected.FullFilePath, actual.FullFilePath); - } + private static void AssertSame(ParserInfo expected, ParserInfo actual) + { + Assert.Equal(expected.Chapters, actual.Chapters); + Assert.Equal(expected.Volumes, actual.Volumes); + Assert.Equal(expected.Edition, actual.Edition); + Assert.Equal(expected.Filename, actual.Filename); + Assert.Equal(expected.Format, actual.Format); + Assert.Equal(expected.Series, actual.Series); + Assert.Equal(expected.IsSpecial, actual.IsSpecial); + Assert.Equal(expected.FullFilePath, actual.FullFilePath); } } diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index c1ef966c93..df5e0c2d78 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -2,233 +2,232 @@ using Xunit; using static API.Services.Tasks.Scanner.Parser.Parser; -namespace API.Tests.Parser +namespace API.Tests.Parser; + +public class ParserTests { - public class ParserTests - { - [Theory] - [InlineData("Joe Shmo, Green Blue", "Joe Shmo, Green Blue")] - [InlineData("Shmo, Joe", "Shmo, Joe")] - [InlineData(" Joe Shmo ", "Joe Shmo")] - public void CleanAuthorTest(string input, string expected) - { - Assert.Equal(expected, CleanAuthor(input)); - } - - [Theory] - [InlineData("", "")] - [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")] - [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")] - [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")] - public void CleanSpecialTitleTest(string input, string expected) - { - Assert.Equal(expected, CleanSpecialTitle(input)); - } - - [Theory] - [InlineData("Beastars - SP01", true)] - [InlineData("Beastars SP01", true)] - [InlineData("Beastars Special 01", false)] - [InlineData("Beastars Extra 01", false)] - [InlineData("Batman Beyond - Return of the Joker (2001) SP01", true)] - public void HasSpecialTest(string input, bool expected) - { - Assert.Equal(expected, HasSpecialMarker(input)); - } - - [Theory] - [InlineData("0001", "1")] - [InlineData("1", "1")] - [InlineData("0013", "13")] - public void RemoveLeadingZeroesTest(string input, string expected) - { - Assert.Equal(expected, RemoveLeadingZeroes(input)); - } - - [Theory] - [InlineData("1", "001")] - [InlineData("10", "010")] - [InlineData("100", "100")] - public void PadZerosTest(string input, string expected) - { - Assert.Equal(expected, PadZeros(input)); - } - - [Theory] - [InlineData("Hello_I_am_here", false, "Hello I am here")] - [InlineData("Hello_I_am_here ", false, "Hello I am here")] - [InlineData("[ReleaseGroup] The Title", false, "The Title")] - [InlineData("[ReleaseGroup]_The_Title", false, "The Title")] - [InlineData("-The Title", false, "The Title")] - [InlineData("- The Title", false, "The Title")] - [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] - [InlineData("Something - Full Color Edition", false, "Something - Full Color Edition")] - public void CleanTitleTest(string input, bool isComic, string expected) - { - Assert.Equal(expected, CleanTitle(input, isComic)); - } - - [Theory] - [InlineData("src: url(fonts/AvenirNext-UltraLight.ttf)", true)] - [InlineData("src: url(ideal-sans-serif.woff)", true)] - [InlineData("src: local(\"Helvetica Neue Bold\")", true)] - [InlineData("src: url(\"/fonts/OpenSans-Regular-webfont.woff2\")", true)] - [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", true)] - [InlineData("src: url(data:application/x-font-woff", false)] - public void FontCssRewriteMatches(string input, bool expectedMatch) - { - Assert.Equal(expectedMatch, FontSrcUrlRegex.Matches(input).Count > 0); - } - - [Theory] - [InlineData("src: url(fonts/AvenirNext-UltraLight.ttf)", new [] {"src: url(", "fonts/AvenirNext-UltraLight.ttf", ")"})] - [InlineData("src: url(ideal-sans-serif.woff)", new [] {"src: url(", "ideal-sans-serif.woff", ")"})] - [InlineData("src: local(\"Helvetica Neue Bold\")", new [] {"src: local(\"", "Helvetica Neue Bold", "\")"})] - [InlineData("src: url(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: url(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] - [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: local(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] - public void FontCssCorrectlySeparates(string input, string[] expected) - { - Assert.Equal(expected, FontSrcUrlRegex.Match(input).Groups.Values.Select(g => g.Value).Where((_, i) => i > 0).ToArray()); - } - - - [Theory] - [InlineData("test.cbz", true)] - [InlineData("test.cbr", true)] - [InlineData("test.zip", true)] - [InlineData("test.rar", true)] - [InlineData("test.rar.!qb", false)] - [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.jpg", false)] - public void IsArchiveTest(string input, bool expected) - { - Assert.Equal(expected, IsArchive(input)); - } - - [Theory] - [InlineData("test.epub", true)] - [InlineData("test.pdf", true)] - [InlineData("test.mobi", false)] - [InlineData("test.djvu", false)] - [InlineData("test.zip", false)] - [InlineData("test.rar", false)] - [InlineData("test.epub.!qb", false)] - [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.ebub", false)] - public void IsBookTest(string input, bool expected) - { - Assert.Equal(expected, IsBook(input)); - } - - [Theory] - [InlineData("test.epub", true)] - [InlineData("test.EPUB", true)] - [InlineData("test.mobi", false)] - [InlineData("test.epub.!qb", false)] - [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.ebub", false)] - public void IsEpubTest(string input, bool expected) - { - Assert.Equal(expected, IsEpub(input)); - } - - [Theory] - [InlineData("12-14", 12)] - [InlineData("24", 24)] - [InlineData("18-04", 4)] - [InlineData("18-04.5", 4.5)] - [InlineData("40", 40)] - [InlineData("40a-040b", 0)] - [InlineData("40.1_a", 0)] - public void MinimumNumberFromRangeTest(string input, float expected) - { - Assert.Equal(expected, MinNumberFromRange(input)); - } - - [Theory] - [InlineData("12-14", 14)] - [InlineData("24", 24)] - [InlineData("18-04", 18)] - [InlineData("18-04.5", 18)] - [InlineData("40", 40)] - [InlineData("40a-040b", 0)] - [InlineData("40.1_a", 0)] - public void MaximumNumberFromRangeTest(string input, float expected) - { - Assert.Equal(expected, MaxNumberFromRange(input)); - } - - [Theory] - [InlineData("Darker Than Black", "darkerthanblack")] - [InlineData("Darker Than Black - Something", "darkerthanblacksomething")] - [InlineData("Darker Than_Black", "darkerthanblack")] - [InlineData("Citrus", "citrus")] - [InlineData("Citrus+", "citrus+")] - [InlineData("Again!!!!", "again")] - [InlineData("카비타", "카비타")] - [InlineData("06", "06")] - [InlineData("", "")] - public void NormalizeTest(string input, string expected) - { - Assert.Equal(expected, Normalize(input)); - } - - - - [Theory] - [InlineData("test.jpg", true)] - [InlineData("test.jpeg", true)] - [InlineData("test.png", true)] - [InlineData(".test.jpg", false)] - [InlineData("!test.jpg", true)] - [InlineData("test.webp", true)] - [InlineData("test.gif", true)] - public void IsImageTest(string filename, bool expected) - { - Assert.Equal(expected, IsImage(filename)); - } - - - - [Theory] - [InlineData("Love Hina - Special.jpg", false)] - [InlineData("folder.jpg", true)] - [InlineData("DearS_v01_cover.jpg", true)] - [InlineData("DearS_v01_covers.jpg", false)] - [InlineData("!cover.jpg", true)] - [InlineData("cover.jpg", true)] - [InlineData("cover.png", true)] - [InlineData("ch1/cover.png", true)] - [InlineData("ch1/backcover.png", false)] - [InlineData("backcover.png", false)] - [InlineData("back_cover.png", false)] - public void IsCoverImageTest(string inputPath, bool expected) - { - Assert.Equal(expected, IsCoverImage(inputPath)); - } - - [Theory] - [InlineData("__MACOSX/Love Hina - Special.jpg", true)] - [InlineData("TEST/Love Hina - Special.jpg", false)] - [InlineData("__macosx/Love Hina/", false)] - [InlineData("MACOSX/Love Hina/", false)] - [InlineData("._Love Hina/Love Hina/", true)] - [InlineData("@Recently-Snapshot/Love Hina/", true)] - [InlineData("@recycle/Love Hina/", true)] - [InlineData("E:/Test/__MACOSX/Love Hina/", true)] - public void HasBlacklistedFolderInPathTest(string inputPath, bool expected) - { - 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)); - } + [Theory] + [InlineData("Joe Shmo, Green Blue", "Joe Shmo, Green Blue")] + [InlineData("Shmo, Joe", "Shmo, Joe")] + [InlineData(" Joe Shmo ", "Joe Shmo")] + public void CleanAuthorTest(string input, string expected) + { + Assert.Equal(expected, CleanAuthor(input)); + } + + [Theory] + [InlineData("", "")] + [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")] + [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")] + [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")] + public void CleanSpecialTitleTest(string input, string expected) + { + Assert.Equal(expected, CleanSpecialTitle(input)); + } + + [Theory] + [InlineData("Beastars - SP01", true)] + [InlineData("Beastars SP01", true)] + [InlineData("Beastars Special 01", false)] + [InlineData("Beastars Extra 01", false)] + [InlineData("Batman Beyond - Return of the Joker (2001) SP01", true)] + public void HasSpecialTest(string input, bool expected) + { + Assert.Equal(expected, HasSpecialMarker(input)); + } + + [Theory] + [InlineData("0001", "1")] + [InlineData("1", "1")] + [InlineData("0013", "13")] + public void RemoveLeadingZeroesTest(string input, string expected) + { + Assert.Equal(expected, RemoveLeadingZeroes(input)); + } + + [Theory] + [InlineData("1", "001")] + [InlineData("10", "010")] + [InlineData("100", "100")] + public void PadZerosTest(string input, string expected) + { + Assert.Equal(expected, PadZeros(input)); + } + + [Theory] + [InlineData("Hello_I_am_here", false, "Hello I am here")] + [InlineData("Hello_I_am_here ", false, "Hello I am here")] + [InlineData("[ReleaseGroup] The Title", false, "The Title")] + [InlineData("[ReleaseGroup]_The_Title", false, "The Title")] + [InlineData("-The Title", false, "The Title")] + [InlineData("- The Title", false, "The Title")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] + [InlineData("Something - Full Color Edition", false, "Something - Full Color Edition")] + public void CleanTitleTest(string input, bool isComic, string expected) + { + Assert.Equal(expected, CleanTitle(input, isComic)); + } + + [Theory] + [InlineData("src: url(fonts/AvenirNext-UltraLight.ttf)", true)] + [InlineData("src: url(ideal-sans-serif.woff)", true)] + [InlineData("src: local(\"Helvetica Neue Bold\")", true)] + [InlineData("src: url(\"/fonts/OpenSans-Regular-webfont.woff2\")", true)] + [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", true)] + [InlineData("src: url(data:application/x-font-woff", false)] + public void FontCssRewriteMatches(string input, bool expectedMatch) + { + Assert.Equal(expectedMatch, FontSrcUrlRegex.Matches(input).Count > 0); + } + + [Theory] + [InlineData("src: url(fonts/AvenirNext-UltraLight.ttf)", new [] {"src: url(", "fonts/AvenirNext-UltraLight.ttf", ")"})] + [InlineData("src: url(ideal-sans-serif.woff)", new [] {"src: url(", "ideal-sans-serif.woff", ")"})] + [InlineData("src: local(\"Helvetica Neue Bold\")", new [] {"src: local(\"", "Helvetica Neue Bold", "\")"})] + [InlineData("src: url(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: url(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] + [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: local(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] + public void FontCssCorrectlySeparates(string input, string[] expected) + { + Assert.Equal(expected, FontSrcUrlRegex.Match(input).Groups.Values.Select(g => g.Value).Where((_, i) => i > 0).ToArray()); + } + + + [Theory] + [InlineData("test.cbz", true)] + [InlineData("test.cbr", true)] + [InlineData("test.zip", true)] + [InlineData("test.rar", true)] + [InlineData("test.rar.!qb", false)] + [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.jpg", false)] + public void IsArchiveTest(string input, bool expected) + { + Assert.Equal(expected, IsArchive(input)); + } + + [Theory] + [InlineData("test.epub", true)] + [InlineData("test.pdf", true)] + [InlineData("test.mobi", false)] + [InlineData("test.djvu", false)] + [InlineData("test.zip", false)] + [InlineData("test.rar", false)] + [InlineData("test.epub.!qb", false)] + [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.ebub", false)] + public void IsBookTest(string input, bool expected) + { + Assert.Equal(expected, IsBook(input)); + } + + [Theory] + [InlineData("test.epub", true)] + [InlineData("test.EPUB", true)] + [InlineData("test.mobi", false)] + [InlineData("test.epub.!qb", false)] + [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.ebub", false)] + public void IsEpubTest(string input, bool expected) + { + Assert.Equal(expected, IsEpub(input)); + } + + [Theory] + [InlineData("12-14", 12)] + [InlineData("24", 24)] + [InlineData("18-04", 4)] + [InlineData("18-04.5", 4.5)] + [InlineData("40", 40)] + [InlineData("40a-040b", 0)] + [InlineData("40.1_a", 0)] + public void MinimumNumberFromRangeTest(string input, float expected) + { + Assert.Equal(expected, MinNumberFromRange(input)); + } + + [Theory] + [InlineData("12-14", 14)] + [InlineData("24", 24)] + [InlineData("18-04", 18)] + [InlineData("18-04.5", 18)] + [InlineData("40", 40)] + [InlineData("40a-040b", 0)] + [InlineData("40.1_a", 0)] + public void MaximumNumberFromRangeTest(string input, float expected) + { + Assert.Equal(expected, MaxNumberFromRange(input)); + } + + [Theory] + [InlineData("Darker Than Black", "darkerthanblack")] + [InlineData("Darker Than Black - Something", "darkerthanblacksomething")] + [InlineData("Darker Than_Black", "darkerthanblack")] + [InlineData("Citrus", "citrus")] + [InlineData("Citrus+", "citrus+")] + [InlineData("Again!!!!", "again")] + [InlineData("카비타", "카비타")] + [InlineData("06", "06")] + [InlineData("", "")] + public void NormalizeTest(string input, string expected) + { + Assert.Equal(expected, Normalize(input)); + } + + + + [Theory] + [InlineData("test.jpg", true)] + [InlineData("test.jpeg", true)] + [InlineData("test.png", true)] + [InlineData(".test.jpg", false)] + [InlineData("!test.jpg", true)] + [InlineData("test.webp", true)] + [InlineData("test.gif", true)] + public void IsImageTest(string filename, bool expected) + { + Assert.Equal(expected, IsImage(filename)); + } + + + + [Theory] + [InlineData("Love Hina - Special.jpg", false)] + [InlineData("folder.jpg", true)] + [InlineData("DearS_v01_cover.jpg", true)] + [InlineData("DearS_v01_covers.jpg", false)] + [InlineData("!cover.jpg", true)] + [InlineData("cover.jpg", true)] + [InlineData("cover.png", true)] + [InlineData("ch1/cover.png", true)] + [InlineData("ch1/backcover.png", false)] + [InlineData("backcover.png", false)] + [InlineData("back_cover.png", false)] + public void IsCoverImageTest(string inputPath, bool expected) + { + Assert.Equal(expected, IsCoverImage(inputPath)); + } + + [Theory] + [InlineData("__MACOSX/Love Hina - Special.jpg", true)] + [InlineData("TEST/Love Hina - Special.jpg", false)] + [InlineData("__macosx/Love Hina/", false)] + [InlineData("MACOSX/Love Hina/", false)] + [InlineData("._Love Hina/Love Hina/", true)] + [InlineData("@Recently-Snapshot/Love Hina/", true)] + [InlineData("@recycle/Love Hina/", true)] + [InlineData("E:/Test/__MACOSX/Love Hina/", true)] + public void HasBlacklistedFolderInPathTest(string inputPath, bool expected) + { + 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 2521d17af8..be0662a537 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -14,317 +14,316 @@ using Xunit; using Xunit.Abstractions; -namespace API.Tests.Services +namespace API.Tests.Services; + +public class ArchiveServiceTests { - public class ArchiveServiceTests + private readonly ITestOutputHelper _testOutputHelper; + private readonly ArchiveService _archiveService; + private readonly ILogger _logger = Substitute.For>(); + private readonly ILogger _directoryServiceLogger = Substitute.For>(); + private readonly IDirectoryService _directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); + + public ArchiveServiceTests(ITestOutputHelper testOutputHelper) { - private readonly ITestOutputHelper _testOutputHelper; - private readonly ArchiveService _archiveService; - private readonly ILogger _logger = Substitute.For>(); - private readonly ILogger _directoryServiceLogger = Substitute.For>(); - private readonly IDirectoryService _directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); + _testOutputHelper = testOutputHelper; + _archiveService = new ArchiveService(_logger, _directoryService, new ImageService(Substitute.For>(), _directoryService)); + } - public ArchiveServiceTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - _archiveService = new ArchiveService(_logger, _directoryService, new ImageService(Substitute.For>(), _directoryService)); - } - - [Theory] - [InlineData("flat file.zip", false)] - [InlineData("file in folder in folder.zip", true)] - [InlineData("file in folder.zip", true)] - [InlineData("file in folder_alt.zip", true)] - public void ArchiveNeedsFlatteningTest(string archivePath, bool expected) - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - var file = Path.Join(testDirectory, archivePath); - using ZipArchive archive = ZipFile.OpenRead(file); - Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive)); - } - - [Theory] - [InlineData("non existent file.zip", false)] - [InlineData("winrar.rar", true)] - [InlineData("empty.zip", true)] - [InlineData("flat file.zip", true)] - [InlineData("file in folder in folder.zip", true)] - [InlineData("file in folder.zip", true)] - [InlineData("file in folder_alt.zip", true)] - public void IsValidArchiveTest(string archivePath, bool expected) - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - Assert.Equal(expected, _archiveService.IsValidArchive(Path.Join(testDirectory, archivePath))); - } - - [Theory] - [InlineData("non existent file.zip", 0)] - [InlineData("winrar.rar", 0)] - [InlineData("empty.zip", 0)] - [InlineData("flat file.zip", 1)] - [InlineData("file in folder in folder.zip", 1)] - [InlineData("file in folder.zip", 1)] - [InlineData("file in folder_alt.zip", 1)] - [InlineData("macos_none.zip", 0)] - [InlineData("macos_one.zip", 1)] - [InlineData("macos_native.zip", 21)] - [InlineData("macos_withdotunder_one.zip", 1)] - public void GetNumberOfPagesFromArchiveTest(string archivePath, int expected) - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - var sw = Stopwatch.StartNew(); - Assert.Equal(expected, _archiveService.GetNumberOfPagesFromArchive(Path.Join(testDirectory, archivePath))); - _testOutputHelper.WriteLine($"Processed Original in {sw.ElapsedMilliseconds} ms"); - } - - - - [Theory] - [InlineData("non existent file.zip", ArchiveLibrary.NotSupported)] - [InlineData("winrar.rar", ArchiveLibrary.SharpCompress)] - [InlineData("empty.zip", ArchiveLibrary.Default)] - [InlineData("flat file.zip", ArchiveLibrary.Default)] - [InlineData("file in folder in folder.zip", ArchiveLibrary.Default)] - [InlineData("file in folder.zip", ArchiveLibrary.Default)] - [InlineData("file in folder_alt.zip", ArchiveLibrary.Default)] - public void CanOpenArchive(string archivePath, ArchiveLibrary expected) - { - var sw = Stopwatch.StartNew(); - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - - Assert.Equal(expected, _archiveService.CanOpen(Path.Join(testDirectory, archivePath))); - _testOutputHelper.WriteLine($"Processed Original in {sw.ElapsedMilliseconds} ms"); - } - - - [Theory] - [InlineData("non existent file.zip", 0)] - [InlineData("winrar.rar", 0)] - [InlineData("empty.zip", 0)] - [InlineData("flat file.zip", 1)] - [InlineData("file in folder in folder.zip", 1)] - [InlineData("file in folder.zip", 1)] - [InlineData("file in folder_alt.zip", 1)] - public void CanExtractArchive(string archivePath, int expectedFileCount) - { + [Theory] + [InlineData("flat file.zip", false)] + [InlineData("file in folder in folder.zip", true)] + [InlineData("file in folder.zip", true)] + [InlineData("file in folder_alt.zip", true)] + public void ArchiveNeedsFlatteningTest(string archivePath, bool expected) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + var file = Path.Join(testDirectory, archivePath); + using ZipArchive archive = ZipFile.OpenRead(file); + Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive)); + } - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - var extractDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives/Extraction"); + [Theory] + [InlineData("non existent file.zip", false)] + [InlineData("winrar.rar", true)] + [InlineData("empty.zip", true)] + [InlineData("flat file.zip", true)] + [InlineData("file in folder in folder.zip", true)] + [InlineData("file in folder.zip", true)] + [InlineData("file in folder_alt.zip", true)] + public void IsValidArchiveTest(string archivePath, bool expected) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + Assert.Equal(expected, _archiveService.IsValidArchive(Path.Join(testDirectory, archivePath))); + } - _directoryService.ClearAndDeleteDirectory(extractDirectory); + [Theory] + [InlineData("non existent file.zip", 0)] + [InlineData("winrar.rar", 0)] + [InlineData("empty.zip", 0)] + [InlineData("flat file.zip", 1)] + [InlineData("file in folder in folder.zip", 1)] + [InlineData("file in folder.zip", 1)] + [InlineData("file in folder_alt.zip", 1)] + [InlineData("macos_none.zip", 0)] + [InlineData("macos_one.zip", 1)] + [InlineData("macos_native.zip", 21)] + [InlineData("macos_withdotunder_one.zip", 1)] + public void GetNumberOfPagesFromArchiveTest(string archivePath, int expected) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + var sw = Stopwatch.StartNew(); + Assert.Equal(expected, _archiveService.GetNumberOfPagesFromArchive(Path.Join(testDirectory, archivePath))); + _testOutputHelper.WriteLine($"Processed Original in {sw.ElapsedMilliseconds} ms"); + } - var sw = Stopwatch.StartNew(); - _archiveService.ExtractArchive(Path.Join(testDirectory, archivePath), extractDirectory); - var di1 = new DirectoryInfo(extractDirectory); - Assert.Equal(expectedFileCount, di1.Exists ? _directoryService.GetFiles(extractDirectory, searchOption:SearchOption.AllDirectories).Count() : 0); - _testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); - _directoryService.ClearAndDeleteDirectory(extractDirectory); - } + [Theory] + [InlineData("non existent file.zip", ArchiveLibrary.NotSupported)] + [InlineData("winrar.rar", ArchiveLibrary.SharpCompress)] + [InlineData("empty.zip", ArchiveLibrary.Default)] + [InlineData("flat file.zip", ArchiveLibrary.Default)] + [InlineData("file in folder in folder.zip", ArchiveLibrary.Default)] + [InlineData("file in folder.zip", ArchiveLibrary.Default)] + [InlineData("file in folder_alt.zip", ArchiveLibrary.Default)] + public void CanOpenArchive(string archivePath, ArchiveLibrary expected) + { + var sw = Stopwatch.StartNew(); + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - [Theory] - [InlineData(new [] {"folder.jpg"}, "folder.jpg")] - [InlineData(new [] {"vol1/"}, "")] - [InlineData(new [] {"folder.jpg", "vol1/folder.jpg"}, "folder.jpg")] - [InlineData(new [] {"cover.jpg", "vol1/folder.jpg"}, "cover.jpg")] - [InlineData(new [] {"__MACOSX/cover.jpg", "vol1/page 01.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) - { - var foundFile = ArchiveService.FindFolderEntry(files); - Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); - } - - [Theory] - [InlineData(new [] {"folder.jpg"}, "folder.jpg")] - [InlineData(new [] {"vol1/"}, "")] - [InlineData(new [] {"folder.jpg", "vol1/folder.jpg"}, "folder.jpg")] - [InlineData(new [] {"cover.jpg", "vol1/folder.jpg"}, "cover.jpg")] - [InlineData(new [] {"page 2.jpg", "page 10.jpg"}, "page 2.jpg")] - [InlineData(new [] {"__MACOSX/cover.jpg", "vol1/page 01.jpg"}, "vol1/page 01.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"}, "Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg")] - [InlineData(new [] {"001.jpg", "001 - chapter 1/001.jpg"}, "001.jpg")] - [InlineData(new [] {"chapter 1/001.jpg", "chapter 2/002.jpg", "somefile.jpg"}, "somefile.jpg")] - public void FindFirstEntry(string[] files, string expected) - { - var foundFile = ArchiveService.FirstFileEntry(files, string.Empty); - Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); - } - - - [Theory] - [InlineData("v10.cbz", "v10.expected.png")] - [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")] - [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")] - [InlineData("macos_native.zip", "macos_native.png")] - [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")] - [InlineData("sorting.zip", "sorting.expected.png")] - [InlineData("test.zip", "test.expected.jpg")] - public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) - { - var ds = Substitute.For(_directoryServiceLogger, new FileSystem()); - var imageService = new ImageService(Substitute.For>(), ds); - var archiveService = Substitute.For(_logger, ds, imageService); + Assert.Equal(expected, _archiveService.CanOpen(Path.Join(testDirectory, archivePath))); + _testOutputHelper.WriteLine($"Processed Original in {sw.ElapsedMilliseconds} ms"); + } - var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); - var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png"); - archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.Default); + [Theory] + [InlineData("non existent file.zip", 0)] + [InlineData("winrar.rar", 0)] + [InlineData("empty.zip", 0)] + [InlineData("flat file.zip", 1)] + [InlineData("file in folder in folder.zip", 1)] + [InlineData("file in folder.zip", 1)] + [InlineData("file in folder_alt.zip", 1)] + public void CanExtractArchive(string archivePath, int expectedFileCount) + { - var outputDir = Path.Join(testDirectory, "output"); - _directoryService.ClearDirectory(outputDir); - _directoryService.ExistOrCreate(outputDir); + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + var extractDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives/Extraction"); - var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), - Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir); - var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath)); + _directoryService.ClearAndDeleteDirectory(extractDirectory); + var sw = Stopwatch.StartNew(); + _archiveService.ExtractArchive(Path.Join(testDirectory, archivePath), extractDirectory); + var di1 = new DirectoryInfo(extractDirectory); + Assert.Equal(expectedFileCount, di1.Exists ? _directoryService.GetFiles(extractDirectory, searchOption:SearchOption.AllDirectories).Count() : 0); + _testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); - Assert.Equal(expectedBytes, actual); - _directoryService.ClearAndDeleteDirectory(outputDir); - } + _directoryService.ClearAndDeleteDirectory(extractDirectory); + } - [Theory] - [InlineData("v10.cbz", "v10.expected.png")] - [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")] - [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")] - [InlineData("macos_native.zip", "macos_native.png")] - [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")] - [InlineData("sorting.zip", "sorting.expected.png")] - public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) - { - var imageService = new ImageService(Substitute.For>(), _directoryService); - var archiveService = Substitute.For(_logger, - new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService); - var testDirectory = API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"))); - - var outputDir = Path.Join(testDirectory, "output"); - _directoryService.ClearDirectory(outputDir); - _directoryService.ExistOrCreate(outputDir); - - archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress); - var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), - Path.GetFileNameWithoutExtension(inputFile), outputDir); - var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile)); - var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); - Assert.Equal(expectedBytes, actualBytes); - - _directoryService.ClearAndDeleteDirectory(outputDir); - } - - [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/"); - 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] - 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!?"; - - var comicInfo = _archiveService.GetComicInfo(archive); - Assert.NotNull(comicInfo); - Assert.Equal(summaryInfo, comicInfo.Summary); - } - - [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); - } - - [Fact] - public void ShouldHaveComicInfo_TopLevelFileOnly() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); - var archive = Path.Join(testDirectory, "ComicInfo_duplicateInfos.zip"); - - var comicInfo = _archiveService.GetComicInfo(archive); - Assert.NotNull(comicInfo); - Assert.Equal("BTOOOM!", comicInfo.Series); - } - - #endregion - - #region CanParseComicInfo - - [Fact] - public void CanParseComicInfo() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); - var archive = Path.Join(testDirectory, "ComicInfo.zip"); - var actual = _archiveService.GetComicInfo(archive); - var expected = new ComicInfo() - { - Publisher = "Yen Press", - Genre = "Manga, Movies & TV", - Summary = - "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!?", - PageCount = 194, - LanguageISO = "en", - Notes = "Scraped metadata from Comixology [CMXDB450184]", - Series = "BTOOOM!", - Title = "v01", - Web = "https://www.comixology.com/BTOOOM/digital-comic/450184" - }; - - 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)); - } + [Theory] + [InlineData(new [] {"folder.jpg"}, "folder.jpg")] + [InlineData(new [] {"vol1/"}, "")] + [InlineData(new [] {"folder.jpg", "vol1/folder.jpg"}, "folder.jpg")] + [InlineData(new [] {"cover.jpg", "vol1/folder.jpg"}, "cover.jpg")] + [InlineData(new [] {"__MACOSX/cover.jpg", "vol1/page 01.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) + { + var foundFile = ArchiveService.FindFolderEntry(files); + Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); + } + + [Theory] + [InlineData(new [] {"folder.jpg"}, "folder.jpg")] + [InlineData(new [] {"vol1/"}, "")] + [InlineData(new [] {"folder.jpg", "vol1/folder.jpg"}, "folder.jpg")] + [InlineData(new [] {"cover.jpg", "vol1/folder.jpg"}, "cover.jpg")] + [InlineData(new [] {"page 2.jpg", "page 10.jpg"}, "page 2.jpg")] + [InlineData(new [] {"__MACOSX/cover.jpg", "vol1/page 01.jpg"}, "vol1/page 01.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"}, "Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg")] + [InlineData(new [] {"001.jpg", "001 - chapter 1/001.jpg"}, "001.jpg")] + [InlineData(new [] {"chapter 1/001.jpg", "chapter 2/002.jpg", "somefile.jpg"}, "somefile.jpg")] + public void FindFirstEntry(string[] files, string expected) + { + var foundFile = ArchiveService.FirstFileEntry(files, string.Empty); + Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); + } + + + [Theory] + [InlineData("v10.cbz", "v10.expected.png")] + [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")] + [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")] + [InlineData("macos_native.zip", "macos_native.png")] + [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")] + [InlineData("sorting.zip", "sorting.expected.png")] + [InlineData("test.zip", "test.expected.jpg")] + public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) + { + var ds = Substitute.For(_directoryServiceLogger, new FileSystem()); + var imageService = new ImageService(Substitute.For>(), ds); + var archiveService = Substitute.For(_logger, ds, imageService); + + var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); + var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png"); + + archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.Default); + var outputDir = Path.Join(testDirectory, "output"); + _directoryService.ClearDirectory(outputDir); + _directoryService.ExistOrCreate(outputDir); - #endregion + var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), + Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir); + var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath)); - #region CreateZipForDownload - //[Fact] - public void CreateZipForDownloadTest() + Assert.Equal(expectedBytes, actual); + _directoryService.ClearAndDeleteDirectory(outputDir); + } + + + [Theory] + [InlineData("v10.cbz", "v10.expected.png")] + [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")] + [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")] + [InlineData("macos_native.zip", "macos_native.png")] + [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")] + [InlineData("sorting.zip", "sorting.expected.png")] + public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) + { + var imageService = new ImageService(Substitute.For>(), _directoryService); + var archiveService = Substitute.For(_logger, + new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService); + var testDirectory = API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"))); + + var outputDir = Path.Join(testDirectory, "output"); + _directoryService.ClearDirectory(outputDir); + _directoryService.ExistOrCreate(outputDir); + + archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress); + var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), + Path.GetFileNameWithoutExtension(inputFile), outputDir); + var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile)); + var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); + Assert.Equal(expectedBytes, actualBytes); + + _directoryService.ClearAndDeleteDirectory(outputDir); + } + + [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/"); + 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] + 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!?"; + + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal(summaryInfo, comicInfo.Summary); + } + + [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); + } + + [Fact] + public void ShouldHaveComicInfo_TopLevelFileOnly() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo_duplicateInfos.zip"); + + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("BTOOOM!", comicInfo.Series); + } + + #endregion + + #region CanParseComicInfo + + [Fact] + public void CanParseComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo.zip"); + var actual = _archiveService.GetComicInfo(archive); + var expected = new ComicInfo() { - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - //_archiveService.CreateZipForDownload(new []{}, outputDirectory) - } + Publisher = "Yen Press", + Genre = "Manga, Movies & TV", + Summary = + "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!?", + PageCount = 194, + LanguageISO = "en", + Notes = "Scraped metadata from Comixology [CMXDB450184]", + Series = "BTOOOM!", + Title = "v01", + Web = "https://www.comixology.com/BTOOOM/digital-comic/450184" + }; + + Assert.NotStrictEqual(expected, actual); + } + + #endregion - #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/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs index ad7f8b9f9c..783e0b62d4 100644 --- a/API.Tests/Services/BackupServiceTests.cs +++ b/API.Tests/Services/BackupServiceTests.cs @@ -135,17 +135,9 @@ public void GetLogFiles_ExpectAllFiles_NoRollingFiles() filesystem.AddFile($"{LogDirectory}kavita1.log", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var inMemorySettings = new Dictionary { - {"Logging:File:Path", "config/logs/kavita.log"}, - {"Logging:File:MaxRollingFiles", "0"}, - }; - IConfiguration configuration = new ConfigurationBuilder() - .AddInMemoryCollection(inMemorySettings) - .Build(); + var backupService = new BackupService(_logger, _unitOfWork, ds, _messageHub); - var backupService = new BackupService(_logger, _unitOfWork, ds, configuration, _messageHub); - - var backupLogFiles = backupService.GetLogFiles(0, LogDirectory).ToList(); + var backupLogFiles = backupService.GetLogFiles(false).ToList(); Assert.Single(backupLogFiles); Assert.Equal(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath($"{LogDirectory}kavita.log"), API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(backupLogFiles.First())); } @@ -155,20 +147,12 @@ public void GetLogFiles_ExpectAllFiles_WithRollingFiles() { var filesystem = CreateFileSystem(); filesystem.AddFile($"{LogDirectory}kavita.log", new MockFileData("")); - filesystem.AddFile($"{LogDirectory}kavita1.log", new MockFileData("")); + filesystem.AddFile($"{LogDirectory}kavita20200213.log", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var inMemorySettings = new Dictionary { - {"Logging:File:Path", "config/logs/kavita.log"}, - {"Logging:File:MaxRollingFiles", "1"}, - }; - IConfiguration configuration = new ConfigurationBuilder() - .AddInMemoryCollection(inMemorySettings) - .Build(); - - var backupService = new BackupService(_logger, _unitOfWork, ds, configuration, _messageHub); + var backupService = new BackupService(_logger, _unitOfWork, ds, _messageHub); - var backupLogFiles = backupService.GetLogFiles(1, LogDirectory).Select(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath).ToList(); + var backupLogFiles = backupService.GetLogFiles().Select(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath).ToList(); Assert.NotEmpty(backupLogFiles.Where(file => file.Equals(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath($"{LogDirectory}kavita.log")) || file.Equals(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath($"{LogDirectory}kavita1.log")))); } diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index f8b726ac5e..38a5da8960 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -5,54 +5,53 @@ using NSubstitute; using Xunit; -namespace API.Tests.Services +namespace API.Tests.Services; + +public class BookServiceTests { - public class BookServiceTests + private readonly IBookService _bookService; + private readonly ILogger _logger = Substitute.For>(); + + public BookServiceTests() + { + var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); + _bookService = new BookService(_logger, directoryService, new ImageService(Substitute.For>(), directoryService)); + } + + [Theory] + [InlineData("The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub", 16)] + [InlineData("Non-existent file.epub", 0)] + [InlineData("Non an ebub.pdf", 0)] + [InlineData("test_ſ.pdf", 1)] // This is dependent on Docnet bug https://github.com/GowenGit/docnet/issues/80 + [InlineData("test.pdf", 1)] + public void GetNumberOfPagesTest(string filePath, int expectedPages) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + Assert.Equal(expectedPages, _bookService.GetNumberOfPages(Path.Join(testDirectory, filePath))); + } + + [Fact] + public void ShouldHaveComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var archive = Path.Join(testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"); + const string summaryInfo = "Book Description"; + + var comicInfo = _bookService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal(summaryInfo, comicInfo.Summary); + Assert.Equal("genre1, genre2", comicInfo.Genre); + } + + [Fact] + public void ShouldHaveComicInfo_WithAuthors() { - private readonly IBookService _bookService; - private readonly ILogger _logger = Substitute.For>(); - - public BookServiceTests() - { - var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); - _bookService = new BookService(_logger, directoryService, new ImageService(Substitute.For>(), directoryService)); - } - - [Theory] - [InlineData("The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub", 16)] - [InlineData("Non-existent file.epub", 0)] - [InlineData("Non an ebub.pdf", 0)] - [InlineData("test_ſ.pdf", 1)] // This is dependent on Docnet bug https://github.com/GowenGit/docnet/issues/80 - [InlineData("test.pdf", 1)] - public void GetNumberOfPagesTest(string filePath, int expectedPages) - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); - Assert.Equal(expectedPages, _bookService.GetNumberOfPages(Path.Join(testDirectory, filePath))); - } - - [Fact] - public void ShouldHaveComicInfo() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); - var archive = Path.Join(testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"); - const string summaryInfo = "Book Description"; - - var comicInfo = _bookService.GetComicInfo(archive); - Assert.NotNull(comicInfo); - Assert.Equal(summaryInfo, comicInfo.Summary); - Assert.Equal("genre1, genre2", comicInfo.Genre); - } - - [Fact] - public void ShouldHaveComicInfo_WithAuthors() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); - var archive = Path.Join(testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"); - - var comicInfo = _bookService.GetComicInfo(archive); - Assert.NotNull(comicInfo); - Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer); - } + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var archive = Path.Join(testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"); + var comicInfo = _bookService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer); } + } diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index a812e5bdde..e3be8dce5c 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -20,501 +20,500 @@ using NSubstitute; using Xunit; -namespace API.Tests.Services -{ - internal class MockReadingItemServiceForCacheService : IReadingItemService - { - private readonly DirectoryService _directoryService; +namespace API.Tests.Services; - public MockReadingItemServiceForCacheService(DirectoryService directoryService) - { - _directoryService = directoryService; - } +internal class MockReadingItemServiceForCacheService : IReadingItemService +{ + private readonly DirectoryService _directoryService; - public ComicInfo GetComicInfo(string filePath) - { - return null; - } + public MockReadingItemServiceForCacheService(DirectoryService directoryService) + { + _directoryService = directoryService; + } - public int GetNumberOfPages(string filePath, MangaFormat format) - { - return 1; - } + public ComicInfo GetComicInfo(string filePath) + { + return null; + } - public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format) - { - return string.Empty; - } + public int GetNumberOfPages(string filePath, MangaFormat format) + { + return 1; + } - public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1) - { - throw new System.NotImplementedException(); - } + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format) + { + return string.Empty; + } - public ParserInfo Parse(string path, string rootPath, LibraryType type) - { - throw new System.NotImplementedException(); - } + public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1) + { + throw new System.NotImplementedException(); + } - public ParserInfo ParseFile(string path, string rootPath, LibraryType type) - { - throw new System.NotImplementedException(); - } + public ParserInfo Parse(string path, string rootPath, LibraryType type) + { + throw new System.NotImplementedException(); } - public class CacheServiceTests + + public ParserInfo ParseFile(string path, string rootPath, LibraryType type) { - private readonly ILogger _logger = Substitute.For>(); - private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub = Substitute.For>(); + throw new System.NotImplementedException(); + } +} +public class CacheServiceTests +{ + 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 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/"; + 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 CacheServiceTests() - { - var contextOptions = new DbContextOptionsBuilder() - .UseSqlite(CreateInMemoryDatabase()) - .Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + public CacheServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); - _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); - } + _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + } - #region Setup + #region Setup - private static DbConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); - connection.Open(); + connection.Open(); - return connection; - } + return connection; + } - public void Dispose() => _connection.Dispose(); + public void Dispose() => _connection.Dispose(); - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; + 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; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; - _context.ServerSetting.Update(setting); + _context.ServerSetting.Update(setting); - _context.Library.Add(new Library() + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() { - Name = "Manga", - Folders = new List() + new FolderPath() { - new FolderPath() - { - Path = "C:/data/" - } + Path = "C:/data/" } - }); - return await _context.SaveChangesAsync() > 0; - } + } + }); + return await _context.SaveChangesAsync() > 0; + } - private async Task ResetDB() - { - _context.Series.RemoveRange(_context.Series.ToList()); + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); - await _context.SaveChangesAsync(); - } + 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; - } + 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 + #endregion - #region Ensure + #region Ensure - [Fact] - public async Task Ensure_DirectoryAlreadyExists_DontExtractAnything() + [Fact] + public async Task Ensure_DirectoryAlreadyExists_DontExtractAnything() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{DataDirectory}Test v1.zip", new MockFileData("")); + filesystem.AddDirectory($"{CacheDirectory}1/"); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); + + await ResetDB(); + var s = DbFactory.Series("Test"); + var v = DbFactory.Volume("1"); + var c = new Chapter() { - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{DataDirectory}Test v1.zip", new MockFileData("")); - filesystem.AddDirectory($"{CacheDirectory}1/"); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); - - await ResetDB(); - var s = DbFactory.Series("Test"); - var v = DbFactory.Volume("1"); - var c = new Chapter() + Number = "1", + Files = new List() { - Number = "1", - Files = new List() + new MangaFile() { - new MangaFile() - { - Format = MangaFormat.Archive, - FilePath = $"{DataDirectory}Test v1.zip", - } + Format = MangaFormat.Archive, + FilePath = $"{DataDirectory}Test v1.zip", } - }; - v.Chapters.Add(c); - s.Volumes.Add(v); - s.LibraryId = 1; - _context.Series.Add(s); + } + }; + v.Chapters.Add(c); + s.Volumes.Add(v); + s.LibraryId = 1; + _context.Series.Add(s); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(); - await cleanupService.Ensure(1); - Assert.Empty(ds.GetFiles(filesystem.Path.Join(CacheDirectory, "1"), searchOption:SearchOption.AllDirectories)); - } + await cleanupService.Ensure(1); + Assert.Empty(ds.GetFiles(filesystem.Path.Join(CacheDirectory, "1"), searchOption:SearchOption.AllDirectories)); + } - // [Fact] - // public async Task Ensure_DirectoryAlreadyExists_ExtractsImages() - // { - // // TODO: Figure out a way to test this - // var filesystem = CreateFileSystem(); - // filesystem.AddFile($"{DataDirectory}Test v1.zip", new MockFileData("")); - // filesystem.AddDirectory($"{CacheDirectory}1/"); - // var ds = new DirectoryService(Substitute.For>(), filesystem); - // var archiveService = Substitute.For(); - // archiveService.ExtractArchive($"{DataDirectory}Test v1.zip", - // filesystem.Path.Join(CacheDirectory, "1")); - // var cleanupService = new CacheService(_logger, _unitOfWork, ds, - // new ReadingItemService(archiveService, Substitute.For(), Substitute.For(), ds)); - // - // await ResetDB(); - // var s = DbFactory.Series("Test"); - // var v = DbFactory.Volume("1"); - // var c = new Chapter() - // { - // Number = "1", - // Files = new List() - // { - // new MangaFile() - // { - // Format = MangaFormat.Archive, - // FilePath = $"{DataDirectory}Test v1.zip", - // } - // } - // }; - // v.Chapters.Add(c); - // s.Volumes.Add(v); - // s.LibraryId = 1; - // _context.Series.Add(s); - // - // await _context.SaveChangesAsync(); - // - // await cleanupService.Ensure(1); - // Assert.Empty(ds.GetFiles(filesystem.Path.Join(CacheDirectory, "1"), searchOption:SearchOption.AllDirectories)); - // } - - - #endregion - - #region CleanupChapters - - [Fact] - public void CleanupChapters_AllFilesShouldBeDeleted() - { - var filesystem = CreateFileSystem(); - filesystem.AddDirectory($"{CacheDirectory}1/"); - filesystem.AddFile($"{CacheDirectory}1/001.jpg", new MockFileData("")); - filesystem.AddFile($"{CacheDirectory}1/002.jpg", new MockFileData("")); - filesystem.AddFile($"{CacheDirectory}3/003.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cleanupService = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); - - cleanupService.CleanupChapters(new []{1, 3}); - Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories)); - } + // [Fact] + // public async Task Ensure_DirectoryAlreadyExists_ExtractsImages() + // { + // // TODO: Figure out a way to test this + // var filesystem = CreateFileSystem(); + // filesystem.AddFile($"{DataDirectory}Test v1.zip", new MockFileData("")); + // filesystem.AddDirectory($"{CacheDirectory}1/"); + // var ds = new DirectoryService(Substitute.For>(), filesystem); + // var archiveService = Substitute.For(); + // archiveService.ExtractArchive($"{DataDirectory}Test v1.zip", + // filesystem.Path.Join(CacheDirectory, "1")); + // var cleanupService = new CacheService(_logger, _unitOfWork, ds, + // new ReadingItemService(archiveService, Substitute.For(), Substitute.For(), ds)); + // + // await ResetDB(); + // var s = DbFactory.Series("Test"); + // var v = DbFactory.Volume("1"); + // var c = new Chapter() + // { + // Number = "1", + // Files = new List() + // { + // new MangaFile() + // { + // Format = MangaFormat.Archive, + // FilePath = $"{DataDirectory}Test v1.zip", + // } + // } + // }; + // v.Chapters.Add(c); + // s.Volumes.Add(v); + // s.LibraryId = 1; + // _context.Series.Add(s); + // + // await _context.SaveChangesAsync(); + // + // await cleanupService.Ensure(1); + // Assert.Empty(ds.GetFiles(filesystem.Path.Join(CacheDirectory, "1"), searchOption:SearchOption.AllDirectories)); + // } + + + #endregion + + #region CleanupChapters + + [Fact] + public void CleanupChapters_AllFilesShouldBeDeleted() + { + var filesystem = CreateFileSystem(); + filesystem.AddDirectory($"{CacheDirectory}1/"); + filesystem.AddFile($"{CacheDirectory}1/001.jpg", new MockFileData("")); + filesystem.AddFile($"{CacheDirectory}1/002.jpg", new MockFileData("")); + filesystem.AddFile($"{CacheDirectory}3/003.jpg", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); + + cleanupService.CleanupChapters(new []{1, 3}); + Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories)); + } - #endregion + #endregion - #region GetCachedEpubFile + #region GetCachedEpubFile - [Fact] - public void GetCachedEpubFile_ShouldReturnFirstEpub() + [Fact] + public void GetCachedEpubFile_ShouldReturnFirstEpub() + { + var filesystem = CreateFileSystem(); + filesystem.AddDirectory($"{CacheDirectory}1/"); + filesystem.AddFile($"{DataDirectory}1.epub", new MockFileData("")); + filesystem.AddFile($"{DataDirectory}2.epub", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); + + var c = new Chapter() { - var filesystem = CreateFileSystem(); - filesystem.AddDirectory($"{CacheDirectory}1/"); - filesystem.AddFile($"{DataDirectory}1.epub", new MockFileData("")); - filesystem.AddFile($"{DataDirectory}2.epub", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); - - var c = new Chapter() + Files = new List() { - Files = new List() + new MangaFile() { - new MangaFile() - { - FilePath = $"{DataDirectory}1.epub" - }, - new MangaFile() - { - FilePath = $"{DataDirectory}2.epub" - } + FilePath = $"{DataDirectory}1.epub" + }, + new MangaFile() + { + FilePath = $"{DataDirectory}2.epub" } - }; - cs.GetCachedFile(c); - Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c)); - } + } + }; + cs.GetCachedFile(c); + Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c)); + } - #endregion + #endregion - #region GetCachedPagePath + #region GetCachedPagePath - [Fact] - public void GetCachedPagePath_ReturnNullIfNoFiles() + [Fact] + public void GetCachedPagePath_ReturnNullIfNoFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddDirectory($"{CacheDirectory}1/"); + filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); + filesystem.AddFile($"{DataDirectory}2.zip", new MockFileData("")); + + var c = new Chapter() { - var filesystem = CreateFileSystem(); - filesystem.AddDirectory($"{CacheDirectory}1/"); - filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); - filesystem.AddFile($"{DataDirectory}2.zip", new MockFileData("")); + Id = 1, + Files = new List() + }; - var c = new Chapter() + var fileIndex = 0; + foreach (var file in c.Files) + { + for (var i = 0; i < file.Pages - 1; i++) { - Id = 1, - Files = new List() - }; + filesystem.AddFile($"{CacheDirectory}1/{fileIndex}/{i+1}.jpg", new MockFileData("")); + } - var fileIndex = 0; - foreach (var file in c.Files) - { - for (var i = 0; i < file.Pages - 1; i++) - { - filesystem.AddFile($"{CacheDirectory}1/{fileIndex}/{i+1}.jpg", new MockFileData("")); - } + fileIndex++; + } - fileIndex++; - } + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + // Flatten to prepare for how GetFullPath expects + ds.Flatten($"{CacheDirectory}1/"); - // Flatten to prepare for how GetFullPath expects - ds.Flatten($"{CacheDirectory}1/"); + var path = cs.GetCachedPagePath(c, 11); + Assert.Equal(string.Empty, path); + } - var path = cs.GetCachedPagePath(c, 11); - Assert.Equal(string.Empty, path); - } + [Fact] + public void GetCachedPagePath_GetFileFromFirstFile() + { + var filesystem = CreateFileSystem(); + filesystem.AddDirectory($"{CacheDirectory}1/"); + filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); + filesystem.AddFile($"{DataDirectory}2.zip", new MockFileData("")); - [Fact] - public void GetCachedPagePath_GetFileFromFirstFile() + var c = new Chapter() { - var filesystem = CreateFileSystem(); - filesystem.AddDirectory($"{CacheDirectory}1/"); - filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); - filesystem.AddFile($"{DataDirectory}2.zip", new MockFileData("")); - - var c = new Chapter() + Id = 1, + Files = new List() { - Id = 1, - Files = new List() + new MangaFile() { - new MangaFile() - { - Id = 1, - FilePath = $"{DataDirectory}1.zip", - Pages = 10 - - }, - new MangaFile() - { - Id = 2, - FilePath = $"{DataDirectory}2.zip", - Pages = 5 - } - } - }; + Id = 1, + FilePath = $"{DataDirectory}1.zip", + Pages = 10 - var fileIndex = 0; - foreach (var file in c.Files) - { - for (var i = 0; i < file.Pages; i++) + }, + new MangaFile() { - filesystem.AddFile($"{CacheDirectory}1/00{fileIndex}_00{i+1}.jpg", new MockFileData("")); + Id = 2, + FilePath = $"{DataDirectory}2.zip", + Pages = 5 } + } + }; - fileIndex++; + var fileIndex = 0; + foreach (var file in c.Files) + { + for (var i = 0; i < file.Pages; i++) + { + filesystem.AddFile($"{CacheDirectory}1/00{fileIndex}_00{i+1}.jpg", new MockFileData("")); } - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + fileIndex++; + } - // Flatten to prepare for how GetFullPath expects - ds.Flatten($"{CacheDirectory}1/"); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); - Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_001.jpg"), ds.FileSystem.Path.GetFullPath(cs.GetCachedPagePath(c, 0))); + // Flatten to prepare for how GetFullPath expects + ds.Flatten($"{CacheDirectory}1/"); - } + Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_001.jpg"), ds.FileSystem.Path.GetFullPath(cs.GetCachedPagePath(c, 0))); + } - [Fact] - public void GetCachedPagePath_GetLastPageFromSingleFile() - { - var filesystem = CreateFileSystem(); - filesystem.AddDirectory($"{CacheDirectory}1/"); - filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); - var c = new Chapter() + [Fact] + public void GetCachedPagePath_GetLastPageFromSingleFile() + { + var filesystem = CreateFileSystem(); + filesystem.AddDirectory($"{CacheDirectory}1/"); + filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); + + var c = new Chapter() + { + Id = 1, + Files = new List() { - Id = 1, - Files = new List() + new MangaFile() { - new MangaFile() - { - Id = 1, - FilePath = $"{DataDirectory}1.zip", - Pages = 10 + Id = 1, + FilePath = $"{DataDirectory}1.zip", + Pages = 10 - } } - }; - c.Pages = c.Files.Sum(f => f.Pages); + } + }; + c.Pages = c.Files.Sum(f => f.Pages); - var fileIndex = 0; - foreach (var file in c.Files) + var fileIndex = 0; + foreach (var file in c.Files) + { + for (var i = 0; i < file.Pages; i++) { - for (var i = 0; i < file.Pages; i++) - { - filesystem.AddFile($"{CacheDirectory}1/{fileIndex}/{i+1}.jpg", new MockFileData("")); - } - - fileIndex++; + filesystem.AddFile($"{CacheDirectory}1/{fileIndex}/{i+1}.jpg", new MockFileData("")); } - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + fileIndex++; + } + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); - // Flatten to prepare for how GetFullPath expects - ds.Flatten($"{CacheDirectory}1/"); + // Flatten to prepare for how GetFullPath expects + ds.Flatten($"{CacheDirectory}1/"); - // Remember that we start at 0, so this is the 10th file - var path = cs.GetCachedPagePath(c, c.Pages); - Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_0{c.Pages}.jpg"), ds.FileSystem.Path.GetFullPath(path)); - } + // Remember that we start at 0, so this is the 10th file + var path = cs.GetCachedPagePath(c, c.Pages); + Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_0{c.Pages}.jpg"), ds.FileSystem.Path.GetFullPath(path)); + } - [Fact] - public void GetCachedPagePath_GetFileFromSecondFile() - { - var filesystem = CreateFileSystem(); - filesystem.AddDirectory($"{CacheDirectory}1/"); - filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); - filesystem.AddFile($"{DataDirectory}2.zip", new MockFileData("")); + [Fact] + public void GetCachedPagePath_GetFileFromSecondFile() + { + var filesystem = CreateFileSystem(); + filesystem.AddDirectory($"{CacheDirectory}1/"); + filesystem.AddFile($"{DataDirectory}1.zip", new MockFileData("")); + filesystem.AddFile($"{DataDirectory}2.zip", new MockFileData("")); - var c = new Chapter() + var c = new Chapter() + { + Id = 1, + Files = new List() { - Id = 1, - Files = new List() + new MangaFile() { - new MangaFile() - { - Id = 1, - FilePath = $"{DataDirectory}1.zip", - Pages = 10 - - }, - new MangaFile() - { - Id = 2, - FilePath = $"{DataDirectory}2.zip", - Pages = 5 - } - } - }; + Id = 1, + FilePath = $"{DataDirectory}1.zip", + Pages = 10 - var fileIndex = 0; - foreach (var file in c.Files) - { - for (var i = 0; i < file.Pages; i++) + }, + new MangaFile() { - filesystem.AddFile($"{CacheDirectory}1/{fileIndex}/{i+1}.jpg", new MockFileData("")); + Id = 2, + FilePath = $"{DataDirectory}2.zip", + Pages = 5 } + } + }; - fileIndex++; + var fileIndex = 0; + foreach (var file in c.Files) + { + for (var i = 0; i < file.Pages; i++) + { + filesystem.AddFile($"{CacheDirectory}1/{fileIndex}/{i+1}.jpg", new MockFileData("")); } - var ds = new DirectoryService(Substitute.For>(), filesystem); - var cs = new CacheService(_logger, _unitOfWork, ds, - new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + fileIndex++; + } - // Flatten to prepare for how GetFullPath expects - ds.Flatten($"{CacheDirectory}1/"); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), + Substitute.For(), Substitute.For(), ds), Substitute.For()); - // Remember that we start at 0, so this is the page + 1 file - var path = cs.GetCachedPagePath(c, 10); - Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/001_001.jpg"), ds.FileSystem.Path.GetFullPath(path)); - } + // Flatten to prepare for how GetFullPath expects + ds.Flatten($"{CacheDirectory}1/"); - #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 + // Remember that we start at 0, so this is the page + 1 file + var path = cs.GetCachedPagePath(c, 10); + Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/001_001.jpg"), ds.FileSystem.Path.GetFullPath(path)); } + + #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 b6ebf67226..0f6340c5b8 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -10,941 +10,940 @@ using NSubstitute; using Xunit; -namespace API.Tests.Services -{ +namespace API.Tests.Services; - public class DirectoryServiceTests - { - private readonly ILogger _logger = Substitute.For>(); +public class DirectoryServiceTests +{ + private readonly ILogger _logger = Substitute.For>(); - #region TraverseTreeParallelForEach - [Fact] - public void TraverseTreeParallelForEach_JustArchives_ShouldBe28() + #region TraverseTreeParallelForEach + [Fact] + public void TraverseTreeParallelForEach_JustArchives_ShouldBe28() + { + var testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 28; i++) { - var testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 28; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - - fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = new List(); - var fileCount = ds.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), - API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions, _logger); - - Assert.Equal(28, fileCount); - Assert.Equal(28, files.Count); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - [Fact] - public void TraverseTreeParallelForEach_LongDirectory_ShouldBe1() + fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = new List(); + var fileCount = ds.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), + API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions, _logger); + + Assert.Equal(28, fileCount); + Assert.Equal(28, files.Count); + } + + [Fact] + public void TraverseTreeParallelForEach_LongDirectory_ShouldBe1() + { + var fileSystem = new MockFileSystem(); + // Create a super long path + var testDirectory = "/manga/"; + for (var i = 0; i < 200; i++) { - var fileSystem = new MockFileSystem(); - // Create a super long path - var testDirectory = "/manga/"; - for (var i = 0; i < 200; i++) - { - testDirectory = fileSystem.FileSystem.Path.Join(testDirectory, "supercalifragilisticexpialidocious"); - } - - - fileSystem.AddFile(fileSystem.FileSystem.Path.Join(testDirectory, "file_29.jpg"), new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = new List(); - try - { - var fileCount = ds.TraverseTreeParallelForEach("/manga/", s => files.Add(s), - API.Services.Tasks.Scanner.Parser.Parser.ImageFileExtensions, _logger); - Assert.Equal(1, fileCount); - } - catch (Exception ex) - { - Assert.False(true); - } - - - Assert.Equal(1, files.Count); + testDirectory = fileSystem.FileSystem.Path.Join(testDirectory, "supercalifragilisticexpialidocious"); } + fileSystem.AddFile(fileSystem.FileSystem.Path.Join(testDirectory, "file_29.jpg"), new MockFileData("")); - [Fact] - public void TraverseTreeParallelForEach_DontCountExcludedDirectories_ShouldBe28() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = new List(); + try { - var testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 28; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - - fileSystem.AddFile($"{Path.Join(testDirectory, "@eaDir")}file_{29}.jpg", new MockFileData("")); - fileSystem.AddFile($"{Path.Join(testDirectory, ".DS_Store")}file_{30}.jpg", new MockFileData("")); - fileSystem.AddFile($"{Path.Join(testDirectory, ".qpkg")}file_{30}.jpg", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = new List(); - var fileCount = ds.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), - API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions, _logger); - - Assert.Equal(28, fileCount); - Assert.Equal(28, files.Count); + var fileCount = ds.TraverseTreeParallelForEach("/manga/", s => files.Add(s), + API.Services.Tasks.Scanner.Parser.Parser.ImageFileExtensions, _logger); + Assert.Equal(1, fileCount); } - #endregion - - #region GetFilesWithCertainExtensions - [Fact] - public void GetFilesWithCertainExtensions_ShouldBe10() + catch (Exception ex) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + Assert.False(true); + } - fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFilesWithExtension(testDirectory, API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions); + Assert.Equal(1, files.Count); + } - Assert.Equal(10, files.Length); - Assert.All(files, s => fileSystem.Path.GetExtension(s).Equals(".zip")); - } - [Fact] - public void GetFilesWithCertainExtensions_OnlyArchives() + + [Fact] + public void TraverseTreeParallelForEach_DontCountExcludedDirectories_ShouldBe28() + { + var testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 28; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"{testDirectory}file_{29}.rar", new MockFileData("")); + fileSystem.AddFile($"{Path.Join(testDirectory, "@eaDir")}file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"{Path.Join(testDirectory, ".DS_Store")}file_{30}.jpg", new MockFileData("")); + fileSystem.AddFile($"{Path.Join(testDirectory, ".qpkg")}file_{30}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFilesWithExtension(testDirectory, ".zip|.rar"); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = new List(); + var fileCount = ds.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), + API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions, _logger); - Assert.Equal(11, files.Length); - } - #endregion + Assert.Equal(28, fileCount); + Assert.Equal(28, files.Count); + } + #endregion - #region GetFiles - [Fact] - public void GetFiles_ArchiveOnly_ShouldBe10() + #region GetFilesWithCertainExtensions + [Fact] + public void GetFilesWithCertainExtensions_ShouldBe10() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory, API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions).ToList(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFilesWithExtension(testDirectory, API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions); - Assert.Equal(10, files.Count()); - Assert.All(files, s => fileSystem.Path.GetExtension(s).Equals(".zip")); - } + Assert.Equal(10, files.Length); + Assert.All(files, s => fileSystem.Path.GetExtension(s).Equals(".zip")); + } - [Fact] - public void GetFiles_All_ShouldBe11() + [Fact] + public void GetFilesWithCertainExtensions_OnlyArchives() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}file_{29}.rar", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory).ToList(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFilesWithExtension(testDirectory, ".zip|.rar"); - Assert.Equal(11, files.Count()); - } + Assert.Equal(11, files.Length); + } + #endregion - [Fact] - public void GetFiles_All_MixedPathSeparators() + #region GetFiles + [Fact] + public void GetFiles_ArchiveOnly_ShouldBe10() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"/manga\\file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory).ToList(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory, API.Services.Tasks.Scanner.Parser.Parser.ArchiveFileExtensions).ToList(); - Assert.Equal(11, files.Count()); - } + Assert.Equal(10, files.Count()); + Assert.All(files, s => fileSystem.Path.GetExtension(s).Equals(".zip")); + } - [Fact] - public void GetFiles_All_TopDirectoryOnly_ShouldBe10() + [Fact] + public void GetFiles_All_ShouldBe11() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"{testDirectory}/SubDir/file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}file_{29}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory).ToList(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); - Assert.Equal(10, files.Count()); - } + Assert.Equal(11, files.Count()); + } - [Fact] - public void GetFiles_WithSubDirectories_ShouldCountOnlyTopLevel() + [Fact] + public void GetFiles_All_MixedPathSeparators() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"{testDirectory}/SubDir/file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"/manga\\file_{29}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory).ToList(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); - Assert.Equal(10, files.Count()); - } + Assert.Equal(11, files.Count()); + } - [Fact] - public void GetFiles_ShouldNotReturnFilesThatAreExcluded() + [Fact] + public void GetFiles_All_TopDirectoryOnly_ShouldBe10() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); + } - fileSystem.AddFile($"{testDirectory}/._file_{29}.jpg", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}/SubDir/file_{29}.jpg", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory).ToList(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); - Assert.Equal(10, files.Count()); - } + Assert.Equal(10, files.Count()); + } - [Fact] - public void GetFiles_WithCustomRegex_ShouldBe10() + [Fact] + public void GetFiles_WithSubDirectories_ShouldCountOnlyTopLevel() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}data-{i}.txt", new MockFileData("")); - } - fileSystem.AddFile($"{testDirectory}joe.txt", new MockFileData("")); - fileSystem.AddFile($"{testDirectory}0d.txt", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory, @".*d.*\.txt"); - Assert.Equal(11, files.Count()); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - [Fact] - public void GetFiles_WithCustomRegexThatContainsFolder_ShouldBe10() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("")); - } - fileSystem.AddFile($"{testDirectory}joe.txt", new MockFileData("")); - fileSystem.AddFile($"{testDirectory}0d.txt", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var files = ds.GetFiles(testDirectory, @".*d.*\.txt", SearchOption.AllDirectories); - Assert.Equal(11, files.Count()); - } - #endregion + fileSystem.AddFile($"{testDirectory}/SubDir/file_{29}.jpg", new MockFileData("")); - #region GetTotalSize - [Fact] - public void GetTotalSize_ShouldBeGreaterThan0() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); - } - fileSystem.AddFile($"{testDirectory}joe.txt", new MockFileData("")); - - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var fileSize = ds.GetTotalSize(fileSystem.AllFiles); - Assert.True(fileSize > 0); - } - #endregion + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); - #region CopyFileToDirectory - [Fact] - public void CopyFileToDirectory_ShouldCopyFileToNonExistentDirectory() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFileToDirectory($"{testDirectory}file/data-0.txt", "/manga/output/"); - Assert.True(fileSystem.FileExists("manga/output/data-0.txt")); - Assert.True(fileSystem.FileExists("manga/file/data-0.txt")); - } - [Fact] - public void CopyFileToDirectory_ShouldCopyFileToExistingDirectoryAndOverwrite() + Assert.Equal(10, files.Count()); + } + + [Fact] + public void GetFiles_ShouldNotReturnFilesThatAreExcluded() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}output/data-0.txt", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFileToDirectory($"{testDirectory}file/data-0.txt", "/manga/output/"); - Assert.True(fileSystem.FileExists("/manga/output/data-0.txt")); - Assert.True(fileSystem.FileExists("/manga/file/data-0.txt")); - Assert.True(fileSystem.FileInfo.FromFileName("/manga/file/data-0.txt").Length == fileSystem.FileInfo.FromFileName("/manga/output/data-0.txt").Length); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - #endregion - #region CopyDirectoryToDirectory - [Fact] - public void CopyDirectoryToDirectory_ShouldThrowWhenSourceDestinationDoesntExist() + fileSystem.AddFile($"{testDirectory}/._file_{29}.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); + + Assert.Equal(10, files.Count()); + } + + [Fact] + public void GetFiles_WithCustomRegex_ShouldBe10() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}output/data-0.txt", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var ex = Assert.Throws(() => ds.CopyDirectoryToDirectory("/comics/", "/manga/output/")); - Assert.Equal(ex.Message, "Source directory does not exist or could not be found: " + "/comics/"); + fileSystem.AddFile($"{testDirectory}data-{i}.txt", new MockFileData("")); } + fileSystem.AddFile($"{testDirectory}joe.txt", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}0d.txt", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory, @".*d.*\.txt"); + Assert.Equal(11, files.Count()); + } - [Fact] - public void CopyDirectoryToDirectory_ShouldCopyEmptyDirectory() + [Fact] + public void GetFiles_WithCustomRegexThatContainsFolder_ShouldBe10() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - fileSystem.AddDirectory($"{testDirectory}empty/"); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyDirectoryToDirectory($"{testDirectory}empty/", "/manga/output/"); - Assert.Empty(fileSystem.DirectoryInfo.FromDirectoryName("/manga/output/").GetFiles()); + fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("")); } + fileSystem.AddFile($"{testDirectory}joe.txt", new MockFileData("")); + fileSystem.AddFile($"{testDirectory}0d.txt", new MockFileData("")); - [Fact] - public void CopyDirectoryToDirectory_ShouldCopyAllFileAndNestedDirectoriesOver() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory, @".*d.*\.txt", SearchOption.AllDirectories); + Assert.Equal(11, files.Count()); + } + #endregion + + #region GetTotalSize + [Fact] + public void GetTotalSize_ShouldBeGreaterThan0() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-1.txt", new MockFileData("abc")); - fileSystem.AddDirectory($"{testDirectory}empty/"); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyDirectoryToDirectory($"{testDirectory}", "/manga/output/"); - Assert.Equal(2, ds.GetFiles("/manga/output/", searchOption: SearchOption.AllDirectories).Count()); + fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); } - #endregion + fileSystem.AddFile($"{testDirectory}joe.txt", new MockFileData("")); - #region IsDriveMounted - [Fact] - public void IsDriveMounted_DriveIsNotMounted() - { - const string testDirectory = "c:/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.False(ds.IsDriveMounted("d:/manga/")); - } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var fileSize = ds.GetTotalSize(fileSystem.AllFiles); + Assert.True(fileSize > 0); + } + #endregion - [Fact] - public void IsDriveMounted_DriveIsMounted() - { - const string testDirectory = "c:/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); + #region CopyFileToDirectory + [Fact] + public void CopyFileToDirectory_ShouldCopyFileToNonExistentDirectory() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - Assert.True(ds.IsDriveMounted("c:/manga/file")); - } - #endregion + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFileToDirectory($"{testDirectory}file/data-0.txt", "/manga/output/"); + Assert.True(fileSystem.FileExists("manga/output/data-0.txt")); + Assert.True(fileSystem.FileExists("manga/file/data-0.txt")); + } + [Fact] + public void CopyFileToDirectory_ShouldCopyFileToExistingDirectoryAndOverwrite() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}output/data-0.txt", new MockFileData("")); - #region IsDirectoryEmpty - [Fact] - public void IsDirectoryEmpty_DirectoryIsEmpty() - { - const string testDirectory = "c:/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(testDirectory); - var ds = new DirectoryService(Substitute.For>(), fileSystem); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFileToDirectory($"{testDirectory}file/data-0.txt", "/manga/output/"); + Assert.True(fileSystem.FileExists("/manga/output/data-0.txt")); + Assert.True(fileSystem.FileExists("/manga/file/data-0.txt")); + Assert.True(fileSystem.FileInfo.FromFileName("/manga/file/data-0.txt").Length == fileSystem.FileInfo.FromFileName("/manga/output/data-0.txt").Length); + } + #endregion - Assert.True(ds.IsDirectoryEmpty("c:/manga/")); - } + #region CopyDirectoryToDirectory + [Fact] + public void CopyDirectoryToDirectory_ShouldThrowWhenSourceDestinationDoesntExist() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}output/data-0.txt", new MockFileData("")); - [Fact] - public void IsDirectoryEmpty_DirectoryIsNotEmpty() - { - const string testDirectory = "c:/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var ex = Assert.Throws(() => ds.CopyDirectoryToDirectory("/comics/", "/manga/output/")); + Assert.Equal(ex.Message, "Source directory does not exist or could not be found: " + "/comics/"); + } - Assert.False(ds.IsDirectoryEmpty("c:/manga/")); - } - #endregion + [Fact] + public void CopyDirectoryToDirectory_ShouldCopyEmptyDirectory() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); + fileSystem.AddDirectory($"{testDirectory}empty/"); - #region ExistOrCreate - [Fact] - public void ExistOrCreate_ShouldCreate() - { - var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.ExistOrCreate("c:/manga/output/"); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyDirectoryToDirectory($"{testDirectory}empty/", "/manga/output/"); + Assert.Empty(fileSystem.DirectoryInfo.FromDirectoryName("/manga/output/").GetFiles()); + } - Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName("c:/manga/output/").Exists); - } - #endregion + [Fact] + public void CopyDirectoryToDirectory_ShouldCopyAllFileAndNestedDirectoriesOver() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-1.txt", new MockFileData("abc")); + fileSystem.AddDirectory($"{testDirectory}empty/"); - #region ClearAndDeleteDirectory - [Fact] - public void ClearAndDeleteDirectory_ShouldDeleteSelfAndAllFilesAndFolders() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyDirectoryToDirectory($"{testDirectory}", "/manga/output/"); + Assert.Equal(2, ds.GetFiles("/manga/output/", searchOption: SearchOption.AllDirectories).Count()); + } + #endregion + + #region IsDriveMounted + [Fact] + public void IsDriveMounted_DriveIsNotMounted() + { + const string testDirectory = "c:/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + Assert.False(ds.IsDriveMounted("d:/manga/")); + } + + [Fact] + public void IsDriveMounted_DriveIsMounted() + { + const string testDirectory = "c:/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + Assert.True(ds.IsDriveMounted("c:/manga/file")); + } + #endregion + + #region IsDirectoryEmpty + [Fact] + public void IsDirectoryEmpty_DirectoryIsEmpty() + { + const string testDirectory = "c:/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(testDirectory); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + Assert.True(ds.IsDirectoryEmpty("c:/manga/")); + } + + [Fact] + public void IsDirectoryEmpty_DirectoryIsNotEmpty() + { + const string testDirectory = "c:/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + Assert.False(ds.IsDirectoryEmpty("c:/manga/")); + } + #endregion + + #region ExistOrCreate + [Fact] + public void ExistOrCreate_ShouldCreate() + { + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.ExistOrCreate("c:/manga/output/"); + + Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName("c:/manga/output/").Exists); + } + #endregion + + #region ClearAndDeleteDirectory + [Fact] + public void ClearAndDeleteDirectory_ShouldDeleteSelfAndAllFilesAndFolders() + { + const string testDirectory = "/manga/base/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/base/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); - } - fileSystem.AddFile($"{testDirectory}data-a.txt", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-b.txt", new MockFileData("abc")); - fileSystem.AddDirectory($"{testDirectory}empty/"); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.ClearAndDeleteDirectory($"{testDirectory}"); - Assert.Empty(ds.GetFiles("/manga/", searchOption: SearchOption.AllDirectories)); - Assert.Empty(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/").GetDirectories()); - Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/").Exists); - Assert.False(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/base").Exists); + fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); } - #endregion + fileSystem.AddFile($"{testDirectory}data-a.txt", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-b.txt", new MockFileData("abc")); + fileSystem.AddDirectory($"{testDirectory}empty/"); - #region ClearDirectory - [Fact] - public void ClearDirectory_ShouldDeleteAllFilesAndFolders_LeaveSelf() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.ClearAndDeleteDirectory($"{testDirectory}"); + Assert.Empty(ds.GetFiles("/manga/", searchOption: SearchOption.AllDirectories)); + Assert.Empty(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/").GetDirectories()); + Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/").Exists); + Assert.False(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/base").Exists); + } + #endregion + + #region ClearDirectory + [Fact] + public void ClearDirectory_ShouldDeleteAllFilesAndFolders_LeaveSelf() + { + const string testDirectory = "/manga/base/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/base/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); - } - fileSystem.AddFile($"{testDirectory}data-a.txt", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-b.txt", new MockFileData("abc")); - fileSystem.AddDirectory($"{testDirectory}file/empty/"); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.ClearDirectory($"{testDirectory}file/"); - Assert.Empty(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}file/").GetDirectories()); - Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/").Exists); - Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}file/").Exists); + fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); } + fileSystem.AddFile($"{testDirectory}data-a.txt", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-b.txt", new MockFileData("abc")); + fileSystem.AddDirectory($"{testDirectory}file/empty/"); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.ClearDirectory($"{testDirectory}file/"); + Assert.Empty(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}file/").GetDirectories()); + Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName("/manga/").Exists); + Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}file/").Exists); + } - [Fact] - public void ClearDirectory_ShouldDeleteFoldersWithOneFileInside() + [Fact] + public void ClearDirectory_ShouldDeleteFoldersWithOneFileInside() + { + const string testDirectory = "/manga/base/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/base/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); - } - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.ClearDirectory($"{testDirectory}"); - Assert.Empty(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}").GetDirectories()); - Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName(testDirectory).Exists); - Assert.False(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}file/").Exists); + fileSystem.AddFile($"{testDirectory}file/data-{i}.txt", new MockFileData("abc")); } - #endregion - #region CopyFilesToDirectory - [Fact] - public void CopyFilesToDirectory_ShouldMoveAllFiles() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.ClearDirectory($"{testDirectory}"); + Assert.Empty(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}").GetDirectories()); + Assert.True(ds.FileSystem.DirectoryInfo.FromDirectoryName(testDirectory).Exists); + Assert.False(ds.FileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}file/").Exists); + } + #endregion + + #region CopyFilesToDirectory + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFiles() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip"}, "/manga/output/"); - Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - [Fact] - public void CopyFilesToDirectory_ShouldMoveAllFilesAndNotFailOnNonExistentFiles() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip"}, "/manga/output/"); + Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + } + + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFilesAndNotFailOnNonExistentFiles() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{200}.zip", $"{testDirectory}file_{1}.zip"}, "/manga/output/"); - Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - [Fact] - public void CopyFilesToDirectory_ShouldMoveAllFiles_InclFilesInNestedFolders() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{200}.zip", $"{testDirectory}file_{1}.zip"}, "/manga/output/"); + Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + } + + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFiles_InclFilesInNestedFolders() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - fileSystem.AddFile($"{testDirectory}nested/file_11.zip", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip", $"{testDirectory}nested/file_11.zip"}, "/manga/output/"); - Assert.Equal(3, ds.GetFiles("/manga/output/").Count()); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } + fileSystem.AddFile($"{testDirectory}nested/file_11.zip", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip", $"{testDirectory}nested/file_11.zip"}, "/manga/output/"); + Assert.Equal(3, ds.GetFiles("/manga/output/").Count()); + } - [Fact] - public void CopyFilesToDirectory_ShouldMoveAllFiles_WithPrepend() + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFiles_WithPrepend() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip", $"{testDirectory}nested/file_11.zip"}, - "/manga/output/", "mangarocks_"); - Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); - Assert.All(ds.GetFiles("/manga/output/"), filepath => ds.FileSystem.Path.GetFileName(filepath).StartsWith("mangarocks_")); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - [Fact] - public void CopyFilesToDirectory_ShouldMoveOnlyFilesThatExist() + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip", $"{testDirectory}nested/file_11.zip"}, + "/manga/output/", "mangarocks_"); + Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + Assert.All(ds.GetFiles("/manga/output/"), filepath => ds.FileSystem.Path.GetFileName(filepath).StartsWith("mangarocks_")); + } + + [Fact] + public void CopyFilesToDirectory_ShouldMoveOnlyFilesThatExist() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + for (var i = 0; i < 10; i++) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - for (var i = 0; i < 10; i++) - { - fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); - } - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip", $"{testDirectory}nested/file_11.zip"}, - "/manga/output/"); - Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); } - [Fact] - public void CopyFilesToDirectory_ShouldAppendWhenTargetFileExists() - { + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{$"{testDirectory}file_{0}.zip", $"{testDirectory}file_{1}.zip", $"{testDirectory}nested/file_11.zip"}, + "/manga/output/"); + Assert.Equal(2, ds.GetFiles("/manga/output/").Count()); + } - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData("")); - fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (1).zip"), new MockFileData("")); - fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (2).zip"), new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); - ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); - var outputFiles = ds.GetFiles("/manga/output/").Select(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath).ToList(); - Assert.Equal(4, outputFiles.Count()); // we have 2 already there and 2 copies - // For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing) - // https://github.com/TestableIO/System.IO.Abstractions/issues/831 - Assert.True(outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) - || outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); - } + [Fact] + public void CopyFilesToDirectory_ShouldAppendWhenTargetFileExists() + { - #endregion + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(MockUnixSupport.Path($"{testDirectory}file.zip"), new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (1).zip"), new MockFileData("")); + fileSystem.AddFile(MockUnixSupport.Path($"/manga/output/file (2).zip"), new MockFileData("")); - #region ListDirectory - [Fact] - public void ListDirectory_EmptyForNonExistent() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file_0.zip", new MockFileData("")); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); + ds.CopyFilesToDirectory(new []{MockUnixSupport.Path($"{testDirectory}file.zip")}, "/manga/output/"); + var outputFiles = ds.GetFiles("/manga/output/").Select(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath).ToList(); + Assert.Equal(4, outputFiles.Count()); // we have 2 already there and 2 copies + // For some reason, this has C:/ on directory even though everything is emulated (System.IO.Abstractions issue, not changing) + // https://github.com/TestableIO/System.IO.Abstractions/issues/831 + Assert.True(outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("/manga/output/file (3).zip")) + || outputFiles.Contains(API.Services.Tasks.Scanner.Parser.Parser.NormalizePath("C:/manga/output/file (3).zip"))); + } - var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.Empty(ds.ListDirectory("/comics/")); - } + #endregion - [Fact] - public void ListDirectory_ListsAllDirectories() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory($"{testDirectory}dir1"); - fileSystem.AddDirectory($"{testDirectory}dir2"); - fileSystem.AddDirectory($"{testDirectory}dir3"); - fileSystem.AddFile($"{testDirectory}file_0.zip", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.Equal(3, ds.ListDirectory(testDirectory).Count()); - } + #region ListDirectory + [Fact] + public void ListDirectory_EmptyForNonExistent() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file_0.zip", new MockFileData("")); - [Fact] - public void ListDirectory_ListsOnlyNonSystemAndHiddenOnly() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory($"{testDirectory}dir1"); - var di = fileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}dir1"); - di.Attributes |= FileAttributes.System; - fileSystem.AddDirectory($"{testDirectory}dir2"); - di = fileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}dir2"); - di.Attributes |= FileAttributes.Hidden; - fileSystem.AddDirectory($"{testDirectory}dir3"); - fileSystem.AddFile($"{testDirectory}file_0.zip", new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.Equal(1, ds.ListDirectory(testDirectory).Count()); - } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + Assert.Empty(ds.ListDirectory("/comics/")); + } - #endregion + [Fact] + public void ListDirectory_ListsAllDirectories() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory($"{testDirectory}dir1"); + fileSystem.AddDirectory($"{testDirectory}dir2"); + fileSystem.AddDirectory($"{testDirectory}dir3"); + fileSystem.AddFile($"{testDirectory}file_0.zip", new MockFileData("")); - #region ReadFileAsync + var ds = new DirectoryService(Substitute.For>(), fileSystem); + Assert.Equal(3, ds.ListDirectory(testDirectory).Count()); + } - [Fact] - public async Task ReadFileAsync_ShouldGetBytes() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file_1.zip", new MockFileData("Hello")); + [Fact] + public void ListDirectory_ListsOnlyNonSystemAndHiddenOnly() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory($"{testDirectory}dir1"); + var di = fileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}dir1"); + di.Attributes |= FileAttributes.System; + fileSystem.AddDirectory($"{testDirectory}dir2"); + di = fileSystem.DirectoryInfo.FromDirectoryName($"{testDirectory}dir2"); + di.Attributes |= FileAttributes.Hidden; + fileSystem.AddDirectory($"{testDirectory}dir3"); + fileSystem.AddFile($"{testDirectory}file_0.zip", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var bytes = await ds.ReadFileAsync($"{testDirectory}file_1.zip"); - Assert.Equal(Encoding.UTF8.GetBytes("Hello"), bytes); - } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + Assert.Equal(1, ds.ListDirectory(testDirectory).Count()); + } - [Fact] - public async Task ReadFileAsync_ShouldReadNothingFromNonExistent() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddFile($"{testDirectory}file_1.zip", new MockFileData("Hello")); + #endregion - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var bytes = await ds.ReadFileAsync($"{testDirectory}file_32123.zip"); - Assert.Empty(bytes); - } + #region ReadFileAsync + + [Fact] + public async Task ReadFileAsync_ShouldGetBytes() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file_1.zip", new MockFileData("Hello")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var bytes = await ds.ReadFileAsync($"{testDirectory}file_1.zip"); + Assert.Equal(Encoding.UTF8.GetBytes("Hello"), bytes); + } + + [Fact] + public async Task ReadFileAsync_ShouldReadNothingFromNonExistent() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile($"{testDirectory}file_1.zip", new MockFileData("Hello")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var bytes = await ds.ReadFileAsync($"{testDirectory}file_32123.zip"); + Assert.Empty(bytes); + } - #endregion + #endregion - #region FindHighestDirectoriesFromFiles + #region FindHighestDirectoriesFromFiles - [Theory] - [InlineData(new [] {"C:/Manga/"}, new [] {"C:/Manga/Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] - [InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/Dir 2/"}, new [] {"C:/Manga/Dir 1/Love Hina/Vol. 01.cbz"}, "C:/Manga/Dir 1/Love Hina")] - [InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/"}, new [] {"D:/Manga/Love Hina/Vol. 01.cbz", "D:/Manga/Vol. 01.cbz"}, "")] - [InlineData(new [] {"C:/Manga/"}, new [] {"C:/Manga//Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] - [InlineData(new [] {@"C:\mount\drive\Library\Test Library\Comics\"}, new [] {@"C:\mount\drive\Library\Test Library\Comics\Bruce Lee (1994)\Bruce Lee #001 (1994).cbz"}, @"C:/mount/drive/Library/Test Library/Comics/Bruce Lee (1994)")] - public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string[] files, string expectedDirectory) + [Theory] + [InlineData(new [] {"C:/Manga/"}, new [] {"C:/Manga/Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] + [InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/Dir 2/"}, new [] {"C:/Manga/Dir 1/Love Hina/Vol. 01.cbz"}, "C:/Manga/Dir 1/Love Hina")] + [InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/"}, new [] {"D:/Manga/Love Hina/Vol. 01.cbz", "D:/Manga/Vol. 01.cbz"}, "")] + [InlineData(new [] {"C:/Manga/"}, new [] {"C:/Manga//Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] + [InlineData(new [] {@"C:\mount\drive\Library\Test Library\Comics\"}, new [] {@"C:\mount\drive\Library\Test Library\Comics\Bruce Lee (1994)\Bruce Lee #001 (1994).cbz"}, @"C:/mount/drive/Library/Test Library/Comics/Bruce Lee (1994)")] + public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string[] files, string expectedDirectory) + { + var fileSystem = new MockFileSystem(); + foreach (var directory in rootDirectories) + { + fileSystem.AddDirectory(directory); + } + foreach (var f in files) { - var fileSystem = new MockFileSystem(); - foreach (var directory in rootDirectories) - { - fileSystem.AddDirectory(directory); - } - foreach (var f in files) - { - fileSystem.AddFile(f, new MockFileData("")); - } - var ds = new DirectoryService(Substitute.For>(), fileSystem); - - var actual = ds.FindHighestDirectoriesFromFiles(rootDirectories, files); - var expected = new Dictionary(); - if (!string.IsNullOrEmpty(expectedDirectory)) - { - expected = new Dictionary {{expectedDirectory, ""}}; - } - - Assert.Equal(expected, actual); + fileSystem.AddFile(f, new MockFileData("")); } + var ds = new DirectoryService(Substitute.For>(), fileSystem); - #endregion - - #region GetFoldersTillRoot - - [Theory] - [InlineData("C:/Manga/", "C:/Manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")] - [InlineData("C:/Manga/", "C:/Manga/Love Hina/Specials/Omake", "Omake,Specials,Love Hina")] - [InlineData("C:/Manga", "C:/Manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")] - [InlineData("C:/Manga", @"C:\Manga\Love Hina\Specials\Omake\", "Omake,Specials,Love Hina")] - [InlineData(@"/manga/", @"/manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")] - [InlineData(@"/manga/", @"/manga/", "")] - [InlineData(@"E:\test", @"E:\test\Sweet X Trouble\Sweet X Trouble - Chapter 001.cbz", "Sweet X Trouble")] - [InlineData(@"C:\/mount/gdrive/Library/Test Library/Comics/", @"C:\/mount/gdrive/Library/Test Library/Comics\godzilla rivals vs hedorah\vol 1\", "vol 1,godzilla rivals vs hedorah")] - [InlineData(@"/manga/", @"/manga/Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")] - [InlineData(@"C:/", @"C://Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")] - [InlineData(@"C:\\", @"C://Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")] - [InlineData(@"C://mount/gdrive/Library/Test Library/Comics", @"C://mount/gdrive/Library/Test Library/Comics/Dragon Age/Test", "Test,Dragon Age")] - [InlineData(@"M:\", @"M:\Toukyou Akazukin\Vol. 01 Ch. 005.cbz", @"Toukyou Akazukin")] - public void GetFoldersTillRoot_Test(string rootPath, string fullpath, string expectedArray) + var actual = ds.FindHighestDirectoriesFromFiles(rootDirectories, files); + var expected = new Dictionary(); + if (!string.IsNullOrEmpty(expectedDirectory)) { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(rootPath); - fileSystem.AddFile(fullpath, new MockFileData("")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - - var expected = expectedArray.Split(","); - if (expectedArray.Equals(string.Empty)) - { - expected = Array.Empty(); - } - Assert.Equal(expected, ds.GetFoldersTillRoot(rootPath, fullpath)); + expected = new Dictionary {{expectedDirectory, ""}}; } - #endregion + Assert.Equal(expected, actual); + } - #region RemoveNonImages + #endregion - [Fact] - public void RemoveNonImages() + #region GetFoldersTillRoot + + [Theory] + [InlineData("C:/Manga/", "C:/Manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")] + [InlineData("C:/Manga/", "C:/Manga/Love Hina/Specials/Omake", "Omake,Specials,Love Hina")] + [InlineData("C:/Manga", "C:/Manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")] + [InlineData("C:/Manga", @"C:\Manga\Love Hina\Specials\Omake\", "Omake,Specials,Love Hina")] + [InlineData(@"/manga/", @"/manga/Love Hina/Specials/Omake/", "Omake,Specials,Love Hina")] + [InlineData(@"/manga/", @"/manga/", "")] + [InlineData(@"E:\test", @"E:\test\Sweet X Trouble\Sweet X Trouble - Chapter 001.cbz", "Sweet X Trouble")] + [InlineData(@"C:\/mount/gdrive/Library/Test Library/Comics/", @"C:\/mount/gdrive/Library/Test Library/Comics\godzilla rivals vs hedorah\vol 1\", "vol 1,godzilla rivals vs hedorah")] + [InlineData(@"/manga/", @"/manga/Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")] + [InlineData(@"C:/", @"C://Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")] + [InlineData(@"C:\\", @"C://Btooom!/Vol.1 Chapter 2/1.cbz", "Vol.1 Chapter 2,Btooom!")] + [InlineData(@"C://mount/gdrive/Library/Test Library/Comics", @"C://mount/gdrive/Library/Test Library/Comics/Dragon Age/Test", "Test,Dragon Age")] + [InlineData(@"M:\", @"M:\Toukyou Akazukin\Vol. 01 Ch. 005.cbz", @"Toukyou Akazukin")] + public void GetFoldersTillRoot_Test(string rootPath, string fullpath, string expectedArray) + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(rootPath); + fileSystem.AddFile(fullpath, new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + var expected = expectedArray.Split(","); + if (expectedArray.Equals(string.Empty)) { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(testDirectory); - fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-2.png", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-3.webp", new MockFileData("abc")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.RemoveNonImages($"{testDirectory}"); - Assert.False(fileSystem.FileExists($"{testDirectory}file/data-0.txt")); - Assert.Equal(3, ds.GetFiles($"{testDirectory}", searchOption:SearchOption.AllDirectories).Count()); + expected = Array.Empty(); } + Assert.Equal(expected, ds.GetFoldersTillRoot(rootPath, fullpath)); + } - #endregion + #endregion - #region Flatten + #region RemoveNonImages - [Fact] - public void Flatten_ShouldDoNothing() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(testDirectory); - fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-2.png", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}data-3.webp", new MockFileData("abc")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.Flatten($"{testDirectory}"); - Assert.True(fileSystem.FileExists($"{testDirectory}data-1.jpg")); - Assert.True(fileSystem.FileExists($"{testDirectory}data-2.png")); - Assert.True(fileSystem.FileExists($"{testDirectory}data-3.webp")); - } + [Fact] + public void RemoveNonImages() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(testDirectory); + fileSystem.AddFile($"{testDirectory}file/data-0.txt", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-2.png", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-3.webp", new MockFileData("abc")); - [Fact] - public void Flatten_ShouldFlatten() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(testDirectory); - fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}subdir/data-3.webp", new MockFileData("abc")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.Flatten($"{testDirectory}"); - Assert.Equal(2, ds.GetFiles(testDirectory).Count()); - Assert.False(fileSystem.FileExists($"{testDirectory}subdir/data-3.webp")); - Assert.True(fileSystem.Directory.Exists($"{testDirectory}subdir/")); - } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.RemoveNonImages($"{testDirectory}"); + Assert.False(fileSystem.FileExists($"{testDirectory}file/data-0.txt")); + Assert.Equal(3, ds.GetFiles($"{testDirectory}", searchOption:SearchOption.AllDirectories).Count()); + } - [Fact] - public void Flatten_ShouldFlatten_WithoutMacosx() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(testDirectory); - fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}subdir/data-3.webp", new MockFileData("abc")); - fileSystem.AddFile($"{testDirectory}__MACOSX/data-4.webp", new MockFileData("abc")); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - ds.Flatten($"{testDirectory}"); - Assert.Equal(2, ds.GetFiles(testDirectory).Count()); - Assert.False(fileSystem.FileExists($"{testDirectory}data-4.webp")); - } + #endregion - #endregion + #region Flatten - #region CheckWriteAccess + [Fact] + public void Flatten_ShouldDoNothing() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(testDirectory); + fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-2.png", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}data-3.webp", new MockFileData("abc")); - [Fact] - public async Task CheckWriteAccess_ShouldHaveAccess() - { - const string testDirectory = "/manga/"; - var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.Flatten($"{testDirectory}"); + Assert.True(fileSystem.FileExists($"{testDirectory}data-1.jpg")); + Assert.True(fileSystem.FileExists($"{testDirectory}data-2.png")); + Assert.True(fileSystem.FileExists($"{testDirectory}data-3.webp")); + } - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var hasAccess = await ds.CheckWriteAccess(ds.FileSystem.Path.Join(testDirectory, "bookmarks")); - Assert.True(hasAccess); + [Fact] + public void Flatten_ShouldFlatten() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(testDirectory); + fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}subdir/data-3.webp", new MockFileData("abc")); - Assert.False(ds.FileSystem.Directory.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks"))); - Assert.False(ds.FileSystem.File.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks", "test.txt"))); - } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.Flatten($"{testDirectory}"); + Assert.Equal(2, ds.GetFiles(testDirectory).Count()); + Assert.False(fileSystem.FileExists($"{testDirectory}subdir/data-3.webp")); + Assert.True(fileSystem.Directory.Exists($"{testDirectory}subdir/")); + } + [Fact] + public void Flatten_ShouldFlatten_WithoutMacosx() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(testDirectory); + fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}subdir/data-3.webp", new MockFileData("abc")); + fileSystem.AddFile($"{testDirectory}__MACOSX/data-4.webp", new MockFileData("abc")); - #endregion + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.Flatten($"{testDirectory}"); + Assert.Equal(2, ds.GetFiles(testDirectory).Count()); + Assert.False(fileSystem.FileExists($"{testDirectory}data-4.webp")); + } - #region GetHumanReadableBytes + #endregion - [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 + #region CheckWriteAccess - #region ScanFiles + [Fact] + public async Task CheckWriteAccess_ShouldHaveAccess() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); - [Fact] - public Task ScanFiles_ShouldFindNoFiles_AllAreIgnored() - { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddDirectory("C:/Data/Accel World"); - fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); - fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/.kavitaignore", new MockFileData("*.*")); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var hasAccess = await ds.CheckWriteAccess(ds.FileSystem.Path.Join(testDirectory, "bookmarks")); + Assert.True(hasAccess); - var ds = new DirectoryService(Substitute.For>(), fileSystem); + Assert.False(ds.FileSystem.Directory.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks"))); + Assert.False(ds.FileSystem.File.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks", "test.txt"))); + } - var allFiles = ds.ScanFiles("C:/Data/"); + #endregion - Assert.Equal(0, allFiles.Count); + #region GetHumanReadableBytes - return Task.CompletedTask; - } + [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 + #region ScanFiles - [Fact] - public Task ScanFiles_ShouldFindNoNestedFiles_IgnoreNestedFiles() - { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddDirectory("C:/Data/Accel World"); - fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); - fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/.kavitaignore", new MockFileData("**/Accel World/*")); - fileSystem.AddFile("C:/Data/Hello.pdf", new MockFileData(string.Empty)); + [Fact] + public Task ScanFiles_ShouldFindNoFiles_AllAreIgnored() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddDirectory("C:/Data/Accel World"); + fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); + fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/.kavitaignore", new MockFileData("*.*")); - var ds = new DirectoryService(Substitute.For>(), fileSystem); + var ds = new DirectoryService(Substitute.For>(), fileSystem); - var allFiles = ds.ScanFiles("C:/Data/"); - Assert.Equal(1, allFiles.Count); // Ignore files are not counted in files, only valid extensions + var allFiles = ds.ScanFiles("C:/Data/"); - return Task.CompletedTask; - } + Assert.Equal(0, allFiles.Count); + return Task.CompletedTask; + } - [Fact] - public Task ScanFiles_NestedIgnore_IgnoreNestedFilesInOneDirectoryOnly() - { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddDirectory("C:/Data/Accel World"); - fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); - fileSystem.AddDirectory("C:/Data/Specials/"); - fileSystem.AddDirectory("C:/Data/Specials/ArtBooks/"); - fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/.kavitaignore", new MockFileData("**/Accel World/*")); - fileSystem.AddFile("C:/Data/Specials/.kavitaignore", new MockFileData("**/ArtBooks/*")); - fileSystem.AddFile("C:/Data/Specials/Hi.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Specials/ArtBooks/art book 01.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Hello.pdf", new MockFileData(string.Empty)); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - - var allFiles = ds.ScanFiles("C:/Data/"); - - Assert.Equal(2, allFiles.Count); // Ignore files are not counted in files, only valid extensions - - return Task.CompletedTask; - } + [Fact] + public Task ScanFiles_ShouldFindNoNestedFiles_IgnoreNestedFiles() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddDirectory("C:/Data/Accel World"); + fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); + fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/.kavitaignore", new MockFileData("**/Accel World/*")); + fileSystem.AddFile("C:/Data/Hello.pdf", new MockFileData(string.Empty)); - [Fact] - public Task ScanFiles_ShouldFindAllFiles() - { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddDirectory("C:/Data/Accel World"); - fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); - fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.txt", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty)); + var ds = new DirectoryService(Substitute.For>(), fileSystem); - var ds = new DirectoryService(Substitute.For>(), fileSystem); + var allFiles = ds.ScanFiles("C:/Data/"); - var allFiles = ds.ScanFiles("C:/Data/"); + Assert.Equal(1, allFiles.Count); // Ignore files are not counted in files, only valid extensions - Assert.Equal(5, allFiles.Count); + return Task.CompletedTask; + } - return Task.CompletedTask; - } + + [Fact] + public Task ScanFiles_NestedIgnore_IgnoreNestedFilesInOneDirectoryOnly() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddDirectory("C:/Data/Accel World"); + fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); + fileSystem.AddDirectory("C:/Data/Specials/"); + fileSystem.AddDirectory("C:/Data/Specials/ArtBooks/"); + fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/.kavitaignore", new MockFileData("**/Accel World/*")); + fileSystem.AddFile("C:/Data/Specials/.kavitaignore", new MockFileData("**/ArtBooks/*")); + fileSystem.AddFile("C:/Data/Specials/Hi.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Specials/ArtBooks/art book 01.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Hello.pdf", new MockFileData(string.Empty)); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + var allFiles = ds.ScanFiles("C:/Data/"); + + Assert.Equal(2, allFiles.Count); // Ignore files are not counted in files, only valid extensions + + return Task.CompletedTask; + } + + + [Fact] + public Task ScanFiles_ShouldFindAllFiles() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddDirectory("C:/Data/Accel World"); + fileSystem.AddDirectory("C:/Data/Accel World/Specials/"); + fileSystem.AddFile("C:/Data/Accel World/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World/Specials/Accel World SP01.txt", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty)); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + var allFiles = ds.ScanFiles("C:/Data/"); + + Assert.Equal(5, allFiles.Count); + + return Task.CompletedTask; + } #endregion @@ -966,35 +965,34 @@ public void GetAllDirectories_ShouldFindAllNestedDirectories() #endregion - #region GetParentDirectory + #region GetParentDirectory - [Theory] - [InlineData(@"C:/file.txt", "C:/")] - [InlineData(@"C:/folder/file.txt", "C:/folder")] - [InlineData(@"C:/folder/subfolder/file.txt", "C:/folder/subfolder")] - public void GetParentDirectoryName_ShouldFindParentOfFiles(string path, string expected) - { - var fileSystem = new MockFileSystem(new Dictionary - { - { path, new MockFileData(string.Empty)} - }); - - var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.Equal(expected, ds.GetParentDirectoryName(path)); - } - [Theory] - [InlineData(@"C:/folder", "C:/")] - [InlineData(@"C:/folder/subfolder", "C:/folder")] - [InlineData(@"C:/folder/subfolder/another", "C:/folder/subfolder")] - public void GetParentDirectoryName_ShouldFindParentOfDirectories(string path, string expected) + [Theory] + [InlineData(@"C:/file.txt", "C:/")] + [InlineData(@"C:/folder/file.txt", "C:/folder")] + [InlineData(@"C:/folder/subfolder/file.txt", "C:/folder/subfolder")] + public void GetParentDirectoryName_ShouldFindParentOfFiles(string path, string expected) + { + var fileSystem = new MockFileSystem(new Dictionary { - var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory(path); + { path, new MockFileData(string.Empty)} + }); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.Equal(expected, ds.GetParentDirectoryName(path)); - } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + Assert.Equal(expected, ds.GetParentDirectoryName(path)); + } + [Theory] + [InlineData(@"C:/folder", "C:/")] + [InlineData(@"C:/folder/subfolder", "C:/folder")] + [InlineData(@"C:/folder/subfolder/another", "C:/folder/subfolder")] + public void GetParentDirectoryName_ShouldFindParentOfDirectories(string path, string expected) + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(path); - #endregion + var ds = new DirectoryService(Substitute.For>(), fileSystem); + Assert.Equal(expected, ds.GetParentDirectoryName(path)); } + + #endregion } diff --git a/API.Tests/Services/MetadataServiceTests.cs b/API.Tests/Services/MetadataServiceTests.cs index 60a1bd0bd8..01a0842420 100644 --- a/API.Tests/Services/MetadataServiceTests.cs +++ b/API.Tests/Services/MetadataServiceTests.cs @@ -5,38 +5,37 @@ using API.Helpers; using API.Services; -namespace API.Tests.Services +namespace API.Tests.Services; + +public class MetadataServiceTests { - public class MetadataServiceTests - { - private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - private const string TestCoverImageFile = "thumbnail.jpg"; - private const string TestCoverArchive = @"c:\file in folder.zip"; - private readonly string _testCoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), @"../../../Services/Test Data/ArchiveService/CoverImages"); - //private readonly MetadataService _metadataService; - // private readonly IUnitOfWork _unitOfWork = Substitute.For(); - // private readonly IImageService _imageService = Substitute.For(); - // private readonly IBookService _bookService = Substitute.For(); - // private readonly IArchiveService _archiveService = Substitute.For(); - // private readonly ILogger _logger = Substitute.For>(); - // private readonly IHubContext _messageHub = Substitute.For>(); - private readonly ICacheHelper _cacheHelper; + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + private const string TestCoverImageFile = "thumbnail.jpg"; + private const string TestCoverArchive = @"c:\file in folder.zip"; + private readonly string _testCoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), @"../../../Services/Test Data/ArchiveService/CoverImages"); + //private readonly MetadataService _metadataService; + // private readonly IUnitOfWork _unitOfWork = Substitute.For(); + // private readonly IImageService _imageService = Substitute.For(); + // private readonly IBookService _bookService = Substitute.For(); + // private readonly IArchiveService _archiveService = Substitute.For(); + // private readonly ILogger _logger = Substitute.For>(); + // private readonly IHubContext _messageHub = Substitute.For>(); + private readonly ICacheHelper _cacheHelper; - public MetadataServiceTests() + public MetadataServiceTests() + { + //_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService, _messageHub); + var file = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary { - //_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService, _messageHub); - var file = new MockFileData("") - { - LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) - }; - var fileSystem = new MockFileSystem(new Dictionary - { - { TestCoverArchive, file } - }); + { TestCoverArchive, file } + }); - var fileService = new FileService(fileSystem); - _cacheHelper = new CacheHelper(fileService); - } + var fileService = new FileService(fileSystem); + _cacheHelper = new CacheHelper(fileService); } } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index f54f2d3e9b..2298aa003f 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -9,124 +9,123 @@ using API.Tests.Helpers; using Xunit; -namespace API.Tests.Services +namespace API.Tests.Services; + +public class ScannerServiceTests { - public class ScannerServiceTests + [Fact] + public void FindSeriesNotOnDisk_Should_Remove1() { - [Fact] - public void FindSeriesNotOnDisk_Should_Remove1() - { - var infos = new Dictionary>(); + var infos = new Dictionary>(); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Archive}); - //AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Epub}); + ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Archive}); + //AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Epub}); - var existingSeries = new List + var existingSeries = new List + { + new Series() { - new Series() + Name = "Darker Than Black", + LocalizedName = "Darker Than Black", + OriginalName = "Darker Than Black", + Volumes = new List() { - Name = "Darker Than Black", - LocalizedName = "Darker Than Black", - OriginalName = "Darker Than Black", - Volumes = new List() + new Volume() { - new Volume() - { - Number = 1, - Name = "1" - } - }, - NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize("Darker Than Black"), - Metadata = new SeriesMetadata(), - Format = MangaFormat.Epub - } - }; - - Assert.Equal(1, ScannerService.FindSeriesNotOnDisk(existingSeries, infos).Count()); - } - - [Fact] - public void FindSeriesNotOnDisk_Should_RemoveNothing_Test() - { - var infos = new Dictionary>(); + Number = 1, + Name = "1" + } + }, + NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata(), + Format = MangaFormat.Epub + } + }; - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Format = MangaFormat.Archive}); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "1", Format = MangaFormat.Archive}); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "10", Format = MangaFormat.Archive}); + Assert.Equal(1, ScannerService.FindSeriesNotOnDisk(existingSeries, infos).Count()); + } + + [Fact] + public void FindSeriesNotOnDisk_Should_RemoveNothing_Test() + { + var infos = new Dictionary>(); - var existingSeries = new List + ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Format = MangaFormat.Archive}); + ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "1", Format = MangaFormat.Archive}); + ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "10", Format = MangaFormat.Archive}); + + var existingSeries = new List + { + new Series() { - new Series() - { - Name = "Cage of Eden", - LocalizedName = "Cage of Eden", - OriginalName = "Cage of Eden", - NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize("Cage of Eden"), - Metadata = new SeriesMetadata(), - Format = MangaFormat.Archive - }, - new Series() - { - Name = "Darker Than Black", - LocalizedName = "Darker Than Black", - OriginalName = "Darker Than Black", - NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize("Darker Than Black"), - Metadata = new SeriesMetadata(), - Format = MangaFormat.Archive - } - }; - - - - Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); - } - - - // TODO: Figure out how to do this with ParseScannedFiles - // [Theory] - // [InlineData(new [] {"Darker than Black"}, "Darker than Black", "Darker than Black")] - // [InlineData(new [] {"Darker than Black"}, "Darker Than Black", "Darker than Black")] - // [InlineData(new [] {"Darker than Black"}, "Darker Than Black!", "Darker than Black")] - // [InlineData(new [] {""}, "Runaway Jack", "Runaway Jack")] - // public void MergeNameTest(string[] existingSeriesNames, string parsedInfoName, string expected) - // { - // var collectedSeries = new ConcurrentDictionary>(); - // foreach (var seriesName in existingSeriesNames) - // { - // AddToParsedInfo(collectedSeries, new ParserInfo() {Series = seriesName, Format = MangaFormat.Archive}); - // } - // - // var actualName = new ParseScannedFiles(_bookService, _logger).MergeName(collectedSeries, new ParserInfo() - // { - // Series = parsedInfoName, - // Format = MangaFormat.Archive - // }); - // - // Assert.Equal(expected, actualName); - // } - - // [Fact] - // public void RemoveMissingSeries_Should_RemoveSeries() - // { - // var existingSeries = new List() - // { - // EntityFactory.CreateSeries("Darker than Black Vol 1"), - // EntityFactory.CreateSeries("Darker than Black"), - // EntityFactory.CreateSeries("Beastars"), - // }; - // var missingSeries = new List() - // { - // EntityFactory.CreateSeries("Darker than Black Vol 1"), - // }; - // existingSeries = ScannerService.RemoveMissingSeries(existingSeries, missingSeries, out var removeCount).ToList(); - // - // Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); - // Assert.Equal(missingSeries.Count, removeCount); - // } - - - // TODO: I want a test for UpdateSeries where if I have chapter 10 and now it's mapping into Vol 2 Chapter 10, - // if I can do it without deleting the underlying chapter (aka id change) + Name = "Cage of Eden", + LocalizedName = "Cage of Eden", + OriginalName = "Cage of Eden", + NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize("Cage of Eden"), + Metadata = new SeriesMetadata(), + Format = MangaFormat.Archive + }, + new Series() + { + Name = "Darker Than Black", + LocalizedName = "Darker Than Black", + OriginalName = "Darker Than Black", + NormalizedName = API.Services.Tasks.Scanner.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata(), + Format = MangaFormat.Archive + } + }; + + + Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); } + + + // TODO: Figure out how to do this with ParseScannedFiles + // [Theory] + // [InlineData(new [] {"Darker than Black"}, "Darker than Black", "Darker than Black")] + // [InlineData(new [] {"Darker than Black"}, "Darker Than Black", "Darker than Black")] + // [InlineData(new [] {"Darker than Black"}, "Darker Than Black!", "Darker than Black")] + // [InlineData(new [] {""}, "Runaway Jack", "Runaway Jack")] + // public void MergeNameTest(string[] existingSeriesNames, string parsedInfoName, string expected) + // { + // var collectedSeries = new ConcurrentDictionary>(); + // foreach (var seriesName in existingSeriesNames) + // { + // AddToParsedInfo(collectedSeries, new ParserInfo() {Series = seriesName, Format = MangaFormat.Archive}); + // } + // + // var actualName = new ParseScannedFiles(_bookService, _logger).MergeName(collectedSeries, new ParserInfo() + // { + // Series = parsedInfoName, + // Format = MangaFormat.Archive + // }); + // + // Assert.Equal(expected, actualName); + // } + + // [Fact] + // public void RemoveMissingSeries_Should_RemoveSeries() + // { + // var existingSeries = new List() + // { + // EntityFactory.CreateSeries("Darker than Black Vol 1"), + // EntityFactory.CreateSeries("Darker than Black"), + // EntityFactory.CreateSeries("Beastars"), + // }; + // var missingSeries = new List() + // { + // EntityFactory.CreateSeries("Darker than Black Vol 1"), + // }; + // existingSeries = ScannerService.RemoveMissingSeries(existingSeries, missingSeries, out var removeCount).ToList(); + // + // Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); + // Assert.Equal(missingSeries.Count, removeCount); + // } + + + // TODO: I want a test for UpdateSeries where if I have chapter 10 and now it's mapping into Vol 2 Chapter 10, + // if I can do it without deleting the underlying chapter (aka id change) + } diff --git a/API/API.csproj b/API/API.csproj index ca3f44f850..c11a79a219 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -73,6 +73,14 @@ + + + + + + + + diff --git a/API/Archive/ArchiveLibrary.cs b/API/Archive/ArchiveLibrary.cs index 2d87e24b63..721a371133 100644 --- a/API/Archive/ArchiveLibrary.cs +++ b/API/Archive/ArchiveLibrary.cs @@ -1,21 +1,20 @@ -namespace API.Archive +namespace API.Archive; + +/// +/// Represents which library should handle opening this library +/// +public enum ArchiveLibrary { /// - /// Represents which library should handle opening this library + /// The underlying archive cannot be opened /// - public enum ArchiveLibrary - { - /// - /// The underlying archive cannot be opened - /// - NotSupported = 0, - /// - /// The underlying archive can be opened by SharpCompress - /// - SharpCompress = 1, - /// - /// The underlying archive can be opened by default .NET - /// - Default = 2 - } + NotSupported = 0, + /// + /// The underlying archive can be opened by SharpCompress + /// + SharpCompress = 1, + /// + /// The underlying archive can be opened by default .NET + /// + Default = 2 } diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs index ca55381bc0..5993105143 100644 --- a/API/Comparators/ChapterSortComparer.cs +++ b/API/Comparators/ChapterSortComparer.cs @@ -1,66 +1,65 @@ using System.Collections.Generic; -namespace API.Comparators +namespace API.Comparators; + +/// +/// Sorts chapters based on their Number. Uses natural ordering of doubles. +/// +public class ChapterSortComparer : IComparer { /// - /// Sorts chapters based on their Number. Uses natural ordering of doubles. + /// Normal sort for 2 doubles. 0 always comes last /// - public class ChapterSortComparer : IComparer + /// + /// + /// + public int Compare(double x, double y) { - /// - /// Normal sort for 2 doubles. 0 always comes last - /// - /// - /// - /// - public int Compare(double x, double y) - { - if (x == 0.0 && y == 0.0) return 0; - // if x is 0, it comes second - if (x == 0.0) return 1; - // if y is 0, it comes second - if (y == 0.0) return -1; - - return x.CompareTo(y); - } + if (x == 0.0 && y == 0.0) return 0; + // if x is 0, it comes second + if (x == 0.0) return 1; + // if y is 0, it comes second + if (y == 0.0) return -1; - public static readonly ChapterSortComparer Default = new ChapterSortComparer(); + return x.CompareTo(y); } - /// - /// This is a special case comparer used exclusively for sorting chapters within a single Volume for reading order. - /// - /// Volume 10 has "Series - Vol 10" and "Series - Vol 10 Chapter 81". In this case, for reading order, the order is Vol 10, Vol 10 Chapter 81. - /// This is represented by Chapter 0, Chapter 81. - /// - /// - public class ChapterSortComparerZeroFirst : IComparer - { - public int Compare(double x, double y) - { - if (x == 0.0 && y == 0.0) return 0; - // if x is 0, it comes first - if (x == 0.0) return -1; - // if y is 0, it comes first - if (y == 0.0) return 1; + public static readonly ChapterSortComparer Default = new ChapterSortComparer(); +} - return x.CompareTo(y); - } +/// +/// This is a special case comparer used exclusively for sorting chapters within a single Volume for reading order. +/// +/// Volume 10 has "Series - Vol 10" and "Series - Vol 10 Chapter 81". In this case, for reading order, the order is Vol 10, Vol 10 Chapter 81. +/// This is represented by Chapter 0, Chapter 81. +/// +/// +public class ChapterSortComparerZeroFirst : IComparer +{ + public int Compare(double x, double y) + { + if (x == 0.0 && y == 0.0) return 0; + // if x is 0, it comes first + if (x == 0.0) return -1; + // if y is 0, it comes first + if (y == 0.0) return 1; - public static readonly ChapterSortComparerZeroFirst Default = new ChapterSortComparerZeroFirst(); + return x.CompareTo(y); } - public class SortComparerZeroLast : IComparer + public static readonly ChapterSortComparerZeroFirst Default = new ChapterSortComparerZeroFirst(); +} + +public class SortComparerZeroLast : IComparer +{ + public int Compare(double x, double y) { - public int Compare(double x, double y) - { - if (x == 0.0 && y == 0.0) return 0; - // if x is 0, it comes last - if (x == 0.0) return 1; - // if y is 0, it comes last - if (y == 0.0) return -1; + if (x == 0.0 && y == 0.0) return 0; + // if x is 0, it comes last + if (x == 0.0) return 1; + // if y is 0, it comes last + if (y == 0.0) return -1; - return x.CompareTo(y); - } + return x.CompareTo(y); } } diff --git a/API/Comparators/NumericComparer.cs b/API/Comparators/NumericComparer.cs index b40e33e0a2..ae603e71be 100644 --- a/API/Comparators/NumericComparer.cs +++ b/API/Comparators/NumericComparer.cs @@ -1,17 +1,16 @@ using System.Collections; -namespace API.Comparators +namespace API.Comparators; + +public class NumericComparer : IComparer { - public class NumericComparer : IComparer - { - public int Compare(object x, object y) + public int Compare(object x, object y) + { + if((x is string xs) && (y is string ys)) { - if((x is string xs) && (y is string ys)) - { - return StringLogicalComparer.Compare(xs, ys); - } - return -1; + return StringLogicalComparer.Compare(xs, ys); } + return -1; } -} \ No newline at end of file +} diff --git a/API/Comparators/StringLogicalComparer.cs b/API/Comparators/StringLogicalComparer.cs index 67aa722255..805f856236 100644 --- a/API/Comparators/StringLogicalComparer.cs +++ b/API/Comparators/StringLogicalComparer.cs @@ -4,127 +4,126 @@ using static System.Char; -namespace API.Comparators +namespace API.Comparators; + +public static class StringLogicalComparer { - public static class StringLogicalComparer + public static int Compare(string s1, string s2) { - public static int Compare(string s1, string s2) - { - //get rid of special cases - if((s1 == null) && (s2 == null)) return 0; - if(s1 == null) return -1; - if(s2 == null) return 1; + //get rid of special cases + if((s1 == null) && (s2 == null)) return 0; + if(s1 == null) return -1; + if(s2 == null) return 1; - if (string.IsNullOrEmpty(s1) && string.IsNullOrEmpty(s2)) return 0; - if (string.IsNullOrEmpty(s1)) return -1; - if (string.IsNullOrEmpty(s2)) return -1; + if (string.IsNullOrEmpty(s1) && string.IsNullOrEmpty(s2)) return 0; + if (string.IsNullOrEmpty(s1)) return -1; + if (string.IsNullOrEmpty(s2)) return -1; - //WE style, special case - var sp1 = IsLetterOrDigit(s1, 0); - var sp2 = IsLetterOrDigit(s2, 0); - if(sp1 && !sp2) return 1; - if(!sp1 && sp2) return -1; + //WE style, special case + var sp1 = IsLetterOrDigit(s1, 0); + var sp2 = IsLetterOrDigit(s2, 0); + if(sp1 && !sp2) return 1; + if(!sp1 && sp2) return -1; - int i1 = 0, i2 = 0; //current index - while(true) - { - var c1 = IsDigit(s1, i1); - var c2 = IsDigit(s2, i2); - int r; // temp result - if(!c1 && !c2) - { - bool letter1 = IsLetter(s1, i1); - bool letter2 = IsLetter(s2, i2); - if((letter1 && letter2) || (!letter1 && !letter2)) - { - if(letter1 && letter2) - { - r = ToLower(s1[i1]).CompareTo(ToLower(s2[i2])); - } - else - { - r = s1[i1].CompareTo(s2[i2]); - } - if(r != 0) return r; - } - else if(!letter1 && letter2) return -1; - else if(letter1 && !letter2) return 1; - } - else if(c1 && c2) - { - r = CompareNum(s1, ref i1, s2, ref i2); - if(r != 0) return r; - } - else if(c1) - { - return -1; - } - else if(c2) - { - return 1; - } - i1++; - i2++; - if((i1 >= s1.Length) && (i2 >= s2.Length)) - { - return 0; - } - if(i1 >= s1.Length) - { - return -1; - } - if(i2 >= s2.Length) - { - return -1; - } - } - } + int i1 = 0, i2 = 0; //current index + while(true) + { + var c1 = IsDigit(s1, i1); + var c2 = IsDigit(s2, i2); + int r; // temp result + if(!c1 && !c2) + { + bool letter1 = IsLetter(s1, i1); + bool letter2 = IsLetter(s2, i2); + if((letter1 && letter2) || (!letter1 && !letter2)) + { + if(letter1 && letter2) + { + r = ToLower(s1[i1]).CompareTo(ToLower(s2[i2])); + } + else + { + r = s1[i1].CompareTo(s2[i2]); + } + if(r != 0) return r; + } + else if(!letter1 && letter2) return -1; + else if(letter1 && !letter2) return 1; + } + else if(c1 && c2) + { + r = CompareNum(s1, ref i1, s2, ref i2); + if(r != 0) return r; + } + else if(c1) + { + return -1; + } + else if(c2) + { + return 1; + } + i1++; + i2++; + if((i1 >= s1.Length) && (i2 >= s2.Length)) + { + return 0; + } + if(i1 >= s1.Length) + { + return -1; + } + if(i2 >= s2.Length) + { + return -1; + } + } + } - private static int CompareNum(string s1, ref int i1, string s2, ref int i2) - { - int nzStart1 = i1, nzStart2 = i2; // nz = non zero - int end1 = i1, end2 = i2; + private static int CompareNum(string s1, ref int i1, string s2, ref int i2) + { + int nzStart1 = i1, nzStart2 = i2; // nz = non zero + int end1 = i1, end2 = i2; - ScanNumEnd(s1, i1, ref end1, ref nzStart1); - ScanNumEnd(s2, i2, ref end2, ref nzStart2); - var start1 = i1; i1 = end1 - 1; - var start2 = i2; i2 = end2 - 1; + ScanNumEnd(s1, i1, ref end1, ref nzStart1); + ScanNumEnd(s2, i2, ref end2, ref nzStart2); + var start1 = i1; i1 = end1 - 1; + var start2 = i2; i2 = end2 - 1; - var nzLength1 = end1 - nzStart1; - var nzLength2 = end2 - nzStart2; + var nzLength1 = end1 - nzStart1; + var nzLength2 = end2 - nzStart2; - if(nzLength1 < nzLength2) return -1; - if(nzLength1 > nzLength2) return 1; + if(nzLength1 < nzLength2) return -1; + if(nzLength1 > nzLength2) return 1; - for(int j1 = nzStart1,j2 = nzStart2; j1 <= i1; j1++,j2++) - { - var r = s1[j1].CompareTo(s2[j2]); - if(r != 0) return r; - } - // the nz parts are equal - var length1 = end1 - start1; - var length2 = end2 - start2; - if(length1 == length2) return 0; - if(length1 > length2) return -1; - return 1; - } + for(int j1 = nzStart1,j2 = nzStart2; j1 <= i1; j1++,j2++) + { + var r = s1[j1].CompareTo(s2[j2]); + if(r != 0) return r; + } + // the nz parts are equal + var length1 = end1 - start1; + var length2 = end2 - start2; + if(length1 == length2) return 0; + if(length1 > length2) return -1; + return 1; + } - //lookahead - private static void ScanNumEnd(string s, int start, ref int end, ref int nzStart) - { - nzStart = start; - end = start; - var countZeros = true; - while(IsDigit(s, end)) - { - if(countZeros && s[end].Equals('0')) - { - nzStart++; - } - else countZeros = false; - end++; - if(end >= s.Length) break; - } - } + //lookahead + private static void ScanNumEnd(string s, int start, ref int end, ref int nzStart) + { + nzStart = start; + end = start; + var countZeros = true; + while(IsDigit(s, end)) + { + if(countZeros && s[end].Equals('0')) + { + nzStart++; + } + else countZeros = false; + end++; + if(end >= s.Length) break; + } } -} \ No newline at end of file +} diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index ad463411d0..403fdd026f 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -1,34 +1,33 @@ using System.Collections.Immutable; -namespace API.Constants +namespace API.Constants; + +/// +/// Role-based Security +/// +public static class PolicyConstants { /// - /// Role-based Security + /// Admin User. Has all privileges + /// + public const string AdminRole = "Admin"; + /// + /// Non-Admin User. Must be granted privileges by an Admin. + /// + public const string PlebRole = "Pleb"; + /// + /// Used to give a user ability to download files from the server + /// + public const string DownloadRole = "Download"; + /// + /// Used to give a user ability to change their own password + /// + public const string ChangePasswordRole = "Change Password"; + /// + /// Used to give a user ability to bookmark files on the server /// - public static class PolicyConstants - { - /// - /// Admin User. Has all privileges - /// - public const string AdminRole = "Admin"; - /// - /// Non-Admin User. Must be granted privileges by an Admin. - /// - public const string PlebRole = "Pleb"; - /// - /// Used to give a user ability to download files from the server - /// - public const string DownloadRole = "Download"; - /// - /// Used to give a user ability to change their own password - /// - public const string ChangePasswordRole = "Change Password"; - /// - /// Used to give a user ability to bookmark files on the server - /// - public const string BookmarkRole = "Bookmark"; + public const string BookmarkRole = "Bookmark"; - public static readonly ImmutableArray ValidRoles = - ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole); - } + public static readonly ImmutableArray ValidRoles = + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole); } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 8e549d5e16..2aee4b401c 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -26,334 +26,442 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +/// +/// All Account matters +/// +public class AccountController : BaseApiController { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ITokenService _tokenService; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly IAccountService _accountService; + private readonly IEmailService _emailService; + private readonly IHostEnvironment _environment; + private readonly IEventHub _eventHub; + + /// + public AccountController(UserManager userManager, + SignInManager signInManager, + ITokenService tokenService, IUnitOfWork unitOfWork, + ILogger logger, + IMapper mapper, IAccountService accountService, + IEmailService emailService, IHostEnvironment environment, + IEventHub eventHub) + { + _userManager = userManager; + _signInManager = signInManager; + _tokenService = tokenService; + _unitOfWork = unitOfWork; + _logger = logger; + _mapper = mapper; + _accountService = accountService; + _emailService = emailService; + _environment = environment; + _eventHub = eventHub; + } + /// - /// All Account matters + /// Update a user's password /// - public class AccountController : BaseApiController + /// + /// + [AllowAnonymous] + [HttpPost("reset-password")] + public async Task UpdatePassword(ResetPasswordDto resetPasswordDto) { - private readonly UserManager _userManager; - private readonly SignInManager _signInManager; - private readonly ITokenService _tokenService; - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly IAccountService _accountService; - private readonly IEmailService _emailService; - private readonly IHostEnvironment _environment; - private readonly IEventHub _eventHub; - - /// - public AccountController(UserManager userManager, - SignInManager signInManager, - ITokenService tokenService, IUnitOfWork unitOfWork, - ILogger logger, - IMapper mapper, IAccountService accountService, - IEmailService emailService, IHostEnvironment environment, - IEventHub eventHub) - { - _userManager = userManager; - _signInManager = signInManager; - _tokenService = tokenService; - _unitOfWork = unitOfWork; - _logger = logger; - _mapper = mapper; - _accountService = accountService; - _emailService = emailService; - _environment = environment; - _eventHub = eventHub; - } - - /// - /// Update a user's password - /// - /// - /// - [AllowAnonymous] - [HttpPost("reset-password")] - public async Task UpdatePassword(ResetPasswordDto resetPasswordDto) - { - // TODO: Log this request to Audit Table - _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); - - var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName); - if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system - var isAdmin = User.IsInRole(PolicyConstants.AdminRole); - - - if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || isAdmin)) - return Unauthorized("You are not permitted to this operation."); - - if (resetPasswordDto.UserName != User.GetUsername() && !isAdmin) - return Unauthorized("You are not permitted to this operation."); - - if (string.IsNullOrEmpty(resetPasswordDto.OldPassword) && !isAdmin) - return BadRequest(new ApiException(400, "You must enter your existing password to change your account unless you're an admin")); - - // If you're an admin and the username isn't yours, you don't need to validate the password - var isResettingOtherUser = (resetPasswordDto.UserName != User.GetUsername() && isAdmin); - if (!isResettingOtherUser && !await _userManager.CheckPasswordAsync(user, resetPasswordDto.OldPassword)) - { - return BadRequest("Invalid Password"); - } + // TODO: Log this request to Audit Table + _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); - var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); - if (errors.Any()) - { - return BadRequest(errors); - } + var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName); + if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system + var isAdmin = User.IsInRole(PolicyConstants.AdminRole); - _logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName); - return Ok(); + + if (resetPasswordDto.UserName == User.GetUsername() && !(User.IsInRole(PolicyConstants.ChangePasswordRole) || isAdmin)) + return Unauthorized("You are not permitted to this operation."); + + if (resetPasswordDto.UserName != User.GetUsername() && !isAdmin) + return Unauthorized("You are not permitted to this operation."); + + if (string.IsNullOrEmpty(resetPasswordDto.OldPassword) && !isAdmin) + return BadRequest(new ApiException(400, "You must enter your existing password to change your account unless you're an admin")); + + // If you're an admin and the username isn't yours, you don't need to validate the password + var isResettingOtherUser = (resetPasswordDto.UserName != User.GetUsername() && isAdmin); + if (!isResettingOtherUser && !await _userManager.CheckPasswordAsync(user, resetPasswordDto.OldPassword)) + { + return BadRequest("Invalid Password"); } - /// - /// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed - /// - /// - /// - [AllowAnonymous] - [HttpPost("register")] - public async Task> RegisterFirstUser(RegisterDto registerDto) + var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); + if (errors.Any()) { - var admins = await _userManager.GetUsersInRoleAsync("Admin"); - if (admins.Count > 0) return BadRequest("Not allowed"); + return BadRequest(errors); + } + + _logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName); + return Ok(); + } + + /// + /// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed + /// + /// + /// + [AllowAnonymous] + [HttpPost("register")] + public async Task> RegisterFirstUser(RegisterDto registerDto) + { + var admins = await _userManager.GetUsersInRoleAsync("Admin"); + if (admins.Count > 0) return BadRequest("Not allowed"); - try + try + { + var usernameValidation = await _accountService.ValidateUsername(registerDto.Username); + if (usernameValidation.Any()) { - var usernameValidation = await _accountService.ValidateUsername(registerDto.Username); - if (usernameValidation.Any()) - { - return BadRequest(usernameValidation); - } + return BadRequest(usernameValidation); + } - var user = new AppUser() + var user = new AppUser() + { + UserName = registerDto.Username, + Email = registerDto.Email, + UserPreferences = new AppUserPreferences { - UserName = registerDto.Username, - Email = registerDto.Email, - UserPreferences = new AppUserPreferences - { - Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() - }, - ApiKey = HashUtil.ApiKey() - }; + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + }, + ApiKey = HashUtil.ApiKey() + }; - var result = await _userManager.CreateAsync(user, registerDto.Password); - if (!result.Succeeded) return BadRequest(result.Errors); + var result = await _userManager.CreateAsync(user, registerDto.Password); + if (!result.Succeeded) return BadRequest(result.Errors); - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token."); - if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}"); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token."); + if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}"); - var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); - if (!roleResult.Succeeded) return BadRequest(result.Errors); + var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); + if (!roleResult.Succeeded) return BadRequest(result.Errors); - return new UserDto - { - Username = user.UserName, - Email = user.Email, - Token = await _tokenService.CreateToken(user), - RefreshToken = await _tokenService.CreateRefreshToken(user), - ApiKey = user.ApiKey, - Preferences = _mapper.Map(user.UserPreferences) - }; - } - catch (Exception ex) + return new UserDto { - _logger.LogError(ex, "Something went wrong when registering user"); - // We need to manually delete the User as we've already committed - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(registerDto.Username); - _unitOfWork.UserRepository.Delete(user); - await _unitOfWork.CommitAsync(); - } + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Something went wrong when registering user"); + // We need to manually delete the User as we've already committed + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(registerDto.Username); + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); + } - return BadRequest("Something went wrong when registering user"); + return BadRequest("Something went wrong when registering user"); + } + + + /// + /// Perform a login. Will send JWT Token of the logged in user back. + /// + /// + /// + [AllowAnonymous] + [HttpPost("login")] + public async Task> Login(LoginDto loginDto) + { + var user = await _userManager.Users + .Include(u => u.UserPreferences) + .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); + + if (user == null) return Unauthorized("Invalid username"); + + // Check if the user has an email, if not, inform them so they can migrate + var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password); + if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword) + { + _logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName); + return Unauthorized( + "You are missing an email on your account. Please wait while we migrate your account."); } + var result = await _signInManager + .CheckPasswordSignInAsync(user, loginDto.Password, true); - /// - /// Perform a login. Will send JWT Token of the logged in user back. - /// - /// - /// - [AllowAnonymous] - [HttpPost("login")] - public async Task> Login(LoginDto loginDto) + if (result.IsLockedOut) { - var user = await _userManager.Users - .Include(u => u.UserPreferences) - .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); + return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes."); + } - if (user == null) return Unauthorized("Invalid username"); + if (!result.Succeeded) + { + return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct."); + } - // Check if the user has an email, if not, inform them so they can migrate - var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password); - if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword) - { - _logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName); - return Unauthorized( - "You are missing an email on your account. Please wait while we migrate your account."); - } + // Update LastActive on account + user.LastActive = DateTime.Now; + user.UserPreferences ??= new AppUserPreferences + { + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() + }; - var result = await _signInManager - .CheckPasswordSignInAsync(user, loginDto.Password, true); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); - if (result.IsLockedOut) - { - return Unauthorized("You've been locked out from too many authorization attempts. Please wait 10 minutes."); - } + _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); - if (!result.Succeeded) - { - return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct."); - } + var dto = _mapper.Map(user); + dto.Token = await _tokenService.CreateToken(user); + dto.RefreshToken = await _tokenService.CreateRefreshToken(user); + var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName); + pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + dto.Preferences = _mapper.Map(pref); + return dto; + } - // Update LastActive on account - user.LastActive = DateTime.Now; - user.UserPreferences ??= new AppUserPreferences - { - Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() - }; + /// + /// Refreshes the user's JWT token + /// + /// + /// + [AllowAnonymous] + [HttpPost("refresh-token")] + public async Task> RefreshToken([FromBody] TokenRequestDto tokenRequestDto) + { + var token = await _tokenService.ValidateRefreshToken(tokenRequestDto); + if (token == null) + { + return Unauthorized(new { message = "Invalid token" }); + } - _unitOfWork.UserRepository.Update(user); - await _unitOfWork.CommitAsync(); + return Ok(token); + } - _logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive); + /// + /// Get All Roles back. See + /// + /// + [HttpGet("roles")] + public ActionResult> GetRoles() + { + return typeof(PolicyConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(string)) + .ToDictionary(f => f.Name, + f => (string) f.GetValue(null)).Values.ToList(); + } - var dto = _mapper.Map(user); - dto.Token = await _tokenService.CreateToken(user); - dto.RefreshToken = await _tokenService.CreateRefreshToken(user); - var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName); - pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); - dto.Preferences = _mapper.Map(pref); - return dto; - } - /// - /// Refreshes the user's JWT token - /// - /// - /// - [AllowAnonymous] - [HttpPost("refresh-token")] - public async Task> RefreshToken([FromBody] TokenRequestDto tokenRequestDto) + /// + /// Resets the API Key assigned with a user + /// + /// + [HttpPost("reset-api-key")] + public async Task> ResetApiKey() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + user.ApiKey = HashUtil.ApiKey(); + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { - var token = await _tokenService.ValidateRefreshToken(tokenRequestDto); - if (token == null) - { - return Unauthorized(new { message = "Invalid token" }); - } + return Ok(user.ApiKey); + } + + await _unitOfWork.RollbackAsync(); + return BadRequest("Something went wrong, unable to reset key"); + + } + + /// + /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateAccount(UpdateUserDto dto) + { + var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission"); - return Ok(token); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId); + if (user == null) return BadRequest("User does not exist"); + + // Check if username is changing + if (!user.UserName.Equals(dto.Username)) + { + // Validate username change + var errors = await _accountService.ValidateUsername(dto.Username); + if (errors.Any()) return BadRequest("Username already taken"); + user.UserName = dto.Username; + _unitOfWork.UserRepository.Update(user); } - /// - /// Get All Roles back. See - /// - /// - [HttpGet("roles")] - public ActionResult> GetRoles() + if (!user.Email.Equals(dto.Email)) { - return typeof(PolicyConstants) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.FieldType == typeof(string)) - .ToDictionary(f => f.Name, - f => (string) f.GetValue(null)).Values.ToList(); + // Validate username change + var errors = await _accountService.ValidateEmail(dto.Email); + if (errors.Any()) return BadRequest("Email already registered"); + // NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it } + // Update roles + var existingRoles = await _userManager.GetRolesAsync(user); + var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); + if (!hasAdminRole) + { + dto.Roles.Add(PolicyConstants.PlebRole); + } - /// - /// Resets the API Key assigned with a user - /// - /// - [HttpPost("reset-api-key")] - public async Task> ResetApiKey() + if (existingRoles.Except(dto.Roles).Any() || dto.Roles.Except(existingRoles).Any()) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var roles = dto.Roles; - user.ApiKey = HashUtil.ApiKey(); + var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles); + if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + roleResult = await _userManager.AddToRolesAsync(user, roles); + if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + } - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + List libraries; + if (hasAdminRole) + { + _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + user.UserName); + libraries = allLibraries; + } + else + { + // Remove user from all libraries + foreach (var lib in allLibraries) { - return Ok(user.ApiKey); + lib.AppUsers ??= new List(); + lib.AppUsers.Remove(user); } - await _unitOfWork.RollbackAsync(); - return BadRequest("Something went wrong, unable to reset key"); + libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries, LibraryIncludes.AppUser)).ToList(); + } + foreach (var lib in libraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Add(user); } - /// - /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("update")] - public async Task UpdateAccount(UpdateUserDto dto) + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { - var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission"); + await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); + return Ok(); + } - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId); - if (user == null) return BadRequest("User does not exist"); + await _unitOfWork.RollbackAsync(); + return BadRequest("There was an exception when updating the user"); + } - // Check if username is changing - if (!user.UserName.Equals(dto.Username)) - { - // Validate username change - var errors = await _accountService.ValidateUsername(dto.Username); - if (errors.Any()) return BadRequest("Username already taken"); - user.UserName = dto.Username; - _unitOfWork.UserRepository.Update(user); - } + /// + /// Requests the Invite Url for the UserId. Will return error if user is already validated. + /// + /// + /// Include the "https://ip:port/" in the generated link + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("invite-url")] + public async Task> GetInviteUrl(int userId, bool withBaseUrl) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user.EmailConfirmed) + return BadRequest("User is already confirmed"); + if (string.IsNullOrEmpty(user.ConfirmationToken)) + return BadRequest("Manual setup is unable to be completed. Please cancel and recreate the invite."); + + return GenerateEmailLink(user.ConfirmationToken, "confirm-email", user.Email, withBaseUrl); + } - if (!user.Email.Equals(dto.Email)) + + /// + /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no + /// email will be sent. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("invite")] + public async Task> InviteUser(InviteUserDto dto) + { + var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (adminUser == null) return Unauthorized("You need to login"); + _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); + + // Check if there is an existing invite + dto.Email = dto.Email.Trim(); + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) + { + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); + } + + // Create a new user + var user = new AppUser() + { + UserName = dto.Email, + Email = dto.Email, + ApiKey = HashUtil.ApiKey(), + UserPreferences = new AppUserPreferences { - // Validate username change - var errors = await _accountService.ValidateEmail(dto.Email); - if (errors.Any()) return BadRequest("Email already registered"); - // NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it + Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() } + }; + + try + { + var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword); + if (!result.Succeeded) return BadRequest(result.Errors); - // Update roles - var existingRoles = await _userManager.GetRolesAsync(user); + // Assign Roles + var roles = dto.Roles; var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); if (!hasAdminRole) { - dto.Roles.Add(PolicyConstants.PlebRole); + roles.Add(PolicyConstants.PlebRole); } - if (existingRoles.Except(dto.Roles).Any() || dto.Roles.Except(existingRoles).Any()) + foreach (var role in roles) { - var roles = dto.Roles; - - var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles); - if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); - roleResult = await _userManager.AddToRolesAsync(user, roles); - if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + if (!PolicyConstants.ValidRoles.Contains(role)) continue; + var roleResult = await _userManager.AddToRoleAsync(user, role); + if (!roleResult.Succeeded) + return + BadRequest(roleResult.Errors); } - - var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + // Grant access to libraries List libraries; if (hasAdminRole) { _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", user.UserName); - libraries = allLibraries; + libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList(); } else { - // Remove user from all libraries - foreach (var lib in allLibraries) - { - lib.AppUsers ??= new List(); - lib.AppUsers.Remove(user); - } - libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries, LibraryIncludes.AppUser)).ToList(); } @@ -363,415 +471,306 @@ public async Task UpdateAccount(UpdateUserDto dto) lib.AppUsers.Add(user); } - if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) { - await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); - return Ok(); + _logger.LogError("There was an issue generating a token for the email"); + return BadRequest("There was an creating the invite user"); } - await _unitOfWork.RollbackAsync(); - return BadRequest("There was an exception when updating the user"); + user.ConfirmationToken = token; + await _unitOfWork.CommitAsync(); } - - /// - /// Requests the Invite Url for the UserId. Will return error if user is already validated. - /// - /// - /// Include the "https://ip:port/" in the generated link - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("invite-url")] - public async Task> GetInviteUrl(int userId, bool withBaseUrl) + catch (Exception ex) { - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user.EmailConfirmed) - return BadRequest("User is already confirmed"); - if (string.IsNullOrEmpty(user.ConfirmationToken)) - return BadRequest("Manual setup is unable to be completed. Please cancel and recreate the invite."); - - return GenerateEmailLink(user.ConfirmationToken, "confirm-email", user.Email, withBaseUrl); + _logger.LogError(ex, "There was an error during invite user flow, unable to create user. Deleting user for retry"); + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); } - - /// - /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no - /// email will be sent. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("invite")] - public async Task> InviteUser(InviteUserDto dto) + try { - var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (adminUser == null) return Unauthorized("You need to login"); - _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); - - // Check if there is an existing invite - dto.Email = dto.Email.Trim(); - var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); - if (emailValidationErrors.Any()) - { - var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (await _userManager.IsEmailConfirmedAsync(invitedUser)) - return BadRequest($"User is already registered as {invitedUser.UserName}"); - return BadRequest("User is already invited under this email and has yet to accepted invite."); - } - - // Create a new user - var user = new AppUser() - { - UserName = dto.Email, - Email = dto.Email, - ApiKey = HashUtil.ApiKey(), - UserPreferences = new AppUserPreferences - { - Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme() - } - }; - - try + var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email", dto.Email); + _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + _logger.LogCritical("[Invite User]: Token {UserName}: {Token}", user.UserName, user.ConfirmationToken); + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var accessible = await _emailService.CheckIfAccessible(host); + if (accessible) { - var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword); - if (!result.Succeeded) return BadRequest(result.Errors); - - // Assign Roles - var roles = dto.Roles; - var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); - if (!hasAdminRole) + try { - roles.Add(PolicyConstants.PlebRole); - } - - foreach (var role in roles) - { - if (!PolicyConstants.ValidRoles.Contains(role)) continue; - var roleResult = await _userManager.AddToRoleAsync(user, role); - if (!roleResult.Succeeded) - return - BadRequest(roleResult.Errors); - } - - // Grant access to libraries - List libraries; - if (hasAdminRole) - { - _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", - user.UserName); - libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList(); - } - else - { - libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries, LibraryIncludes.AppUser)).ToList(); - } - - foreach (var lib in libraries) - { - lib.AppUsers ??= new List(); - lib.AppUsers.Add(user); + await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() + { + EmailAddress = dto.Email, + InvitingUser = adminUser.UserName, + ServerConfirmationLink = emailLink + }); } - - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - if (string.IsNullOrEmpty(token)) + catch (Exception) { - _logger.LogError("There was an issue generating a token for the email"); - return BadRequest("There was an creating the invite user"); + /* Swallow exception */ } - - user.ConfirmationToken = token; - await _unitOfWork.CommitAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an error during invite user flow, unable to create user. Deleting user for retry"); - _unitOfWork.UserRepository.Delete(user); - await _unitOfWork.CommitAsync(); } - try + return Ok(new InviteUserResponse { - var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email", dto.Email); - _logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - _logger.LogCritical("[Invite User]: Token {UserName}: {Token}", user.UserName, user.ConfirmationToken); - var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); - var accessible = await _emailService.CheckIfAccessible(host); - if (accessible) - { - try - { - await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() - { - EmailAddress = dto.Email, - InvitingUser = adminUser.UserName, - ServerConfirmationLink = emailLink - }); - } - catch (Exception) - { - /* Swallow exception */ - } - } + EmailLink = emailLink, + EmailSent = accessible + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error during invite user flow, unable to send an email"); + } - return Ok(new InviteUserResponse - { - EmailLink = emailLink, - EmailSent = accessible - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an error during invite user flow, unable to send an email"); - } + return BadRequest("There was an error setting up your account. Please check the logs"); + } - return BadRequest("There was an error setting up your account. Please check the logs"); - } + [AllowAnonymous] + [HttpPost("confirm-email")] + public async Task> ConfirmEmail(ConfirmEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - [AllowAnonymous] - [HttpPost("confirm-email")] - public async Task> ConfirmEmail(ConfirmEmailDto dto) + if (user == null) { - var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - - if (user == null) - { - return BadRequest("The email does not match the registered email"); - } + return BadRequest("The email does not match the registered email"); + } - // Validate Password and Username - var validationErrors = new List(); - validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); - validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password)); + // Validate Password and Username + var validationErrors = new List(); + validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); + validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password)); - if (validationErrors.Any()) - { - return BadRequest(validationErrors); - } + if (validationErrors.Any()) + { + return BadRequest(validationErrors); + } - if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); - user.UserName = dto.Username; - user.ConfirmationToken = null; - var errors = await _accountService.ChangeUserPassword(user, dto.Password); - if (errors.Any()) - { - return BadRequest(errors); - } - await _unitOfWork.CommitAsync(); + user.UserName = dto.Username; + user.ConfirmationToken = null; + var errors = await _accountService.ChangeUserPassword(user, dto.Password); + if (errors.Any()) + { + return BadRequest(errors); + } + await _unitOfWork.CommitAsync(); - user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, - AppUserIncludes.UserPreferences); + user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, + AppUserIncludes.UserPreferences); - // Perform Login code - return new UserDto - { - Username = user.UserName, - Email = user.Email, - Token = await _tokenService.CreateToken(user), - RefreshToken = await _tokenService.CreateRefreshToken(user), - ApiKey = user.ApiKey, - Preferences = _mapper.Map(user.UserPreferences) - }; - } + // Perform Login code + return new UserDto + { + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } - [AllowAnonymous] - [HttpPost("confirm-password-reset")] - public async Task> ConfirmForgotPassword(ConfirmPasswordResetDto dto) + [AllowAnonymous] + [HttpPost("confirm-password-reset")] + public async Task> ConfirmForgotPassword(ConfirmPasswordResetDto dto) + { + try { - try + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) { - var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (user == null) - { - return BadRequest("Invalid Details"); - } + return BadRequest("Invalid Details"); + } - var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, - "ResetPassword", dto.Token); - if (!result) return BadRequest("Unable to reset password, your email token is not correct."); + var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, + "ResetPassword", dto.Token); + if (!result) return BadRequest("Unable to reset password, your email token is not correct."); - var errors = await _accountService.ChangeUserPassword(user, dto.Password); - return errors.Any() ? BadRequest(errors) : Ok("Password updated"); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an unexpected error when confirming new password"); - return BadRequest("There was an unexpected error when confirming new password"); - } + var errors = await _accountService.ChangeUserPassword(user, dto.Password); + return errors.Any() ? BadRequest(errors) : Ok("Password updated"); } + catch (Exception ex) + { + _logger.LogError(ex, "There was an unexpected error when confirming new password"); + return BadRequest("There was an unexpected error when confirming new password"); + } + } - /// - /// Will send user a link to update their password to their email or prompt them if not accessible - /// - /// - /// - [AllowAnonymous] - [HttpPost("forgot-password")] - public async Task> ForgotPassword([FromQuery] string email) + /// + /// Will send user a link to update their password to their email or prompt them if not accessible + /// + /// + /// + [AllowAnonymous] + [HttpPost("forgot-password")] + public async Task> ForgotPassword([FromQuery] string email) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); + if (user == null) { - var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); - if (user == null) - { - _logger.LogError("There are no users with email: {Email} but user is requesting password reset", email); - return Ok("An email will be sent to the email if it exists in our database"); - } + _logger.LogError("There are no users with email: {Email} but user is requesting password reset", email); + return Ok("An email will be sent to the email if it exists in our database"); + } - var roles = await _userManager.GetRolesAsync(user); + var roles = await _userManager.GetRolesAsync(user); - if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole)) - return Unauthorized("You are not permitted to this operation."); + if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole)) + return Unauthorized("You are not permitted to this operation."); - var token = await _userManager.GeneratePasswordResetTokenAsync(user); - var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email); - _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - _logger.LogCritical("[Forgot Password]: Token {UserName}: {Token}", user.UserName, token); - var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); - if (await _emailService.CheckIfAccessible(host)) + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email); + _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + _logger.LogCritical("[Forgot Password]: Token {UserName}: {Token}", user.UserName, token); + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + if (await _emailService.CheckIfAccessible(host)) + { + await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() { - await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() - { - EmailAddress = user.Email, - ServerConfirmationLink = emailLink, - InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value - }); - return Ok("Email sent"); - } - - return Ok("Your server is not accessible. The Link to reset your password is in the logs."); + EmailAddress = user.Email, + ServerConfirmationLink = emailLink, + InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value + }); + return Ok("Email sent"); } - [AllowAnonymous] - [HttpPost("confirm-migration-email")] - public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) - { - var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (user == null) return BadRequest("This email is not on system"); + return Ok("Your server is not accessible. The Link to reset your password is in the logs."); + } - if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + [AllowAnonymous] + [HttpPost("confirm-migration-email")] + public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) return BadRequest("This email is not on system"); - await _unitOfWork.CommitAsync(); + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); - user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, - AppUserIncludes.UserPreferences); + await _unitOfWork.CommitAsync(); - // Perform Login code - return new UserDto - { - Username = user.UserName, - Email = user.Email, - Token = await _tokenService.CreateToken(user), - RefreshToken = await _tokenService.CreateRefreshToken(user), - ApiKey = user.ApiKey, - Preferences = _mapper.Map(user.UserPreferences) - }; - } + user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, + AppUserIncludes.UserPreferences); - [HttpPost("resend-confirmation-email")] - public async Task> ResendConfirmationSendEmail([FromQuery] int userId) + // Perform Login code + return new UserDto { - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - if (user == null) return BadRequest("User does not exist"); + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } - if (string.IsNullOrEmpty(user.Email)) - return BadRequest( - "This user needs to migrate. Have them log out and login to trigger a migration flow"); - if (user.EmailConfirmed) return BadRequest("User already confirmed"); + [HttpPost("resend-confirmation-email")] + public async Task> ResendConfirmationSendEmail([FromQuery] int userId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) return BadRequest("User does not exist"); + + if (string.IsNullOrEmpty(user.Email)) + return BadRequest( + "This user needs to migrate. Have them log out and login to trigger a migration flow"); + if (user.EmailConfirmed) return BadRequest("User already confirmed"); + + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var emailLink = GenerateEmailLink(token, "confirm-email", user.Email); + _logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink); + _logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token); + await _emailService.SendMigrationEmail(new EmailMigrationDto() + { + EmailAddress = user.Email, + Username = user.UserName, + ServerConfirmationLink = emailLink, + InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value + }); - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var emailLink = GenerateEmailLink(token, "confirm-email", user.Email); - _logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink); - _logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token); - await _emailService.SendMigrationEmail(new EmailMigrationDto() - { - EmailAddress = user.Email, - Username = user.UserName, - ServerConfirmationLink = emailLink, - InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value - }); + return Ok(emailLink); + } - return Ok(emailLink); - } + private string GenerateEmailLink(string token, string routePart, string email, bool withHost = true) + { + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + if (withHost) return $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; + return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; + } - private string GenerateEmailLink(string token, string routePart, string email, bool withHost = true) + /// + /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow + /// + /// + /// + [AllowAnonymous] + [HttpPost("migrate-email")] + public async Task> MigrateEmail(MigrateUserEmailDto dto) + { + // Check if there is an existing invite + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) { - var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); - if (withHost) return $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; - return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; - } - - /// - /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow - /// - /// - /// - [AllowAnonymous] - [HttpPost("migrate-email")] - public async Task> MigrateEmail(MigrateUserEmailDto dto) - { - // Check if there is an existing invite - var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); - if (emailValidationErrors.Any()) - { - var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (await _userManager.IsEmailConfirmedAsync(invitedUser)) - return BadRequest($"User is already registered as {invitedUser.UserName}"); - - _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite"); - return BadRequest("User is already invited under this email and has yet to accepted invite."); - } + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + _logger.LogInformation("A user is attempting to login, but hasn't accepted email invite"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); + } - var user = await _userManager.Users - .Include(u => u.UserPreferences) - .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); - if (user == null) return BadRequest("Invalid username"); - var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); - if (!validPassword) return BadRequest("Your credentials are not correct"); + var user = await _userManager.Users + .Include(u => u.UserPreferences) + .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); + if (user == null) return BadRequest("Invalid username"); - try - { - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); + if (!validPassword) return BadRequest("Your credentials are not correct"); - user.Email = dto.Email; - if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration"); - _unitOfWork.UserRepository.Update(user); + try + { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - await _unitOfWork.CommitAsync(); + user.Email = dto.Email; + if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration"); + _unitOfWork.UserRepository.Update(user); - return Ok(); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue during email migration. Contact support"); - _unitOfWork.UserRepository.Delete(user); - await _unitOfWork.CommitAsync(); - } + await _unitOfWork.CommitAsync(); - return BadRequest("There was an error setting up your account. Please check the logs"); + return Ok(); } - - private async Task ConfirmEmailToken(string token, AppUser user) + catch (Exception ex) { - var result = await _userManager.ConfirmEmailAsync(user, token); - if (result.Succeeded) return true; + _logger.LogError(ex, "There was an issue during email migration. Contact support"); + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); + } + return BadRequest("There was an error setting up your account. Please check the logs"); + } + private async Task ConfirmEmailToken(string token, AppUser user) + { + var result = await _userManager.ConfirmEmailAsync(user, token); + if (result.Succeeded) return true; - _logger.LogCritical("[Account] Email validation failed"); - if (!result.Errors.Any()) return false; - foreach (var error in result.Errors) - { - _logger.LogCritical("[Account] Email validation error: {Message}", error.Description); - } - return false; + _logger.LogCritical("[Account] Email validation failed"); + if (!result.Errors.Any()) return false; + foreach (var error in result.Errors) + { + _logger.LogCritical("[Account] Email validation error: {Message}", error.Description); } + + return false; + } } diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 045cc63dc2..25bde9ddb8 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -4,27 +4,26 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -namespace API.Controllers +namespace API.Controllers; + +public class AdminController : BaseApiController { - public class AdminController : BaseApiController - { - private readonly UserManager _userManager; + private readonly UserManager _userManager; - public AdminController(UserManager userManager) - { - _userManager = userManager; - } + public AdminController(UserManager userManager) + { + _userManager = userManager; + } - /// - /// Checks if an admin exists on the system. This is essentially a check to validate if the system has been setup. - /// - /// - [AllowAnonymous] - [HttpGet("exists")] - public async Task> AdminExists() - { - var users = await _userManager.GetUsersInRoleAsync("Admin"); - return users.Count > 0; - } + /// + /// Checks if an admin exists on the system. This is essentially a check to validate if the system has been setup. + /// + /// + [AllowAnonymous] + [HttpGet("exists")] + public async Task> AdminExists() + { + var users = await _userManager.GetUsersInRoleAsync("Admin"); + return users.Count > 0; } } diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs index dfedd7a0a1..2ac2b5cce7 100644 --- a/API/Controllers/BaseApiController.cs +++ b/API/Controllers/BaseApiController.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace API.Controllers +namespace API.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class BaseApiController : ControllerBase { - [ApiController] - [Route("api/[controller]")] - [Authorize] - public class BaseApiController : ControllerBase - { - } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index d254af8dc7..dd44b9a7bd 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -13,151 +13,150 @@ using Microsoft.AspNetCore.Mvc; using VersOne.Epub; -namespace API.Controllers +namespace API.Controllers; + +public class BookController : BaseApiController { - public class BookController : BaseApiController - { - private readonly IBookService _bookService; - private readonly IUnitOfWork _unitOfWork; - private readonly ICacheService _cacheService; + private readonly IBookService _bookService; + private readonly IUnitOfWork _unitOfWork; + private readonly ICacheService _cacheService; - public BookController(IBookService bookService, - IUnitOfWork unitOfWork, ICacheService cacheService) - { - _bookService = bookService; - _unitOfWork = unitOfWork; - _cacheService = cacheService; - } + public BookController(IBookService bookService, + IUnitOfWork unitOfWork, ICacheService cacheService) + { + _bookService = bookService; + _unitOfWork = unitOfWork; + _cacheService = cacheService; + } - /// - /// Retrieves information for the PDF and Epub reader - /// - /// This only applies to Epub or PDF files - /// - /// - [HttpGet("{chapterId}/book-info")] - public async Task> GetBookInfo(int chapterId) + /// + /// Retrieves information for the PDF and Epub reader + /// + /// This only applies to Epub or PDF files + /// + /// + [HttpGet("{chapterId}/book-info")] + public async Task> GetBookInfo(int chapterId) + { + var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); + var bookTitle = string.Empty; + switch (dto.SeriesFormat) { - var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); - var bookTitle = string.Empty; - switch (dto.SeriesFormat) + case MangaFormat.Epub: { - case MangaFormat.Epub: - { - var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); - bookTitle = book.Title; - break; - } - case MangaFormat.Pdf: + var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); + using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); + bookTitle = book.Title; + break; + } + case MangaFormat.Pdf: + { + var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); + if (string.IsNullOrEmpty(bookTitle)) { - var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); - if (string.IsNullOrEmpty(bookTitle)) - { - // Override with filename - bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath); - } - - break; + // Override with filename + bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath); } - case MangaFormat.Image: - break; - case MangaFormat.Archive: - break; - case MangaFormat.Unknown: - break; - default: - throw new ArgumentOutOfRangeException(); - } - return Ok(new BookInfoDto() - { - ChapterNumber = dto.ChapterNumber, - VolumeNumber = dto.VolumeNumber, - VolumeId = dto.VolumeId, - BookTitle = bookTitle, - SeriesName = dto.SeriesName, - SeriesFormat = dto.SeriesFormat, - SeriesId = dto.SeriesId, - LibraryId = dto.LibraryId, - IsSpecial = dto.IsSpecial, - Pages = dto.Pages, - }); + break; + } + case MangaFormat.Image: + break; + case MangaFormat.Archive: + break; + case MangaFormat.Unknown: + break; + default: + throw new ArgumentOutOfRangeException(); } - /// - /// This is an entry point to fetch resources from within an epub chapter/book. - /// - /// - /// - /// - [HttpGet("{chapterId}/book-resources")] - [ResponseCache(Duration = 60 * 1, Location = ResponseCacheLocation.Client, NoStore = false)] - [AllowAnonymous] - public async Task GetBookPageResources(int chapterId, [FromQuery] string file) + return Ok(new BookInfoDto() { - if (chapterId <= 0) return BadRequest("Chapter is not valid"); - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); + ChapterNumber = dto.ChapterNumber, + VolumeNumber = dto.VolumeNumber, + VolumeId = dto.VolumeId, + BookTitle = bookTitle, + SeriesName = dto.SeriesName, + SeriesFormat = dto.SeriesFormat, + SeriesId = dto.SeriesId, + LibraryId = dto.LibraryId, + IsSpecial = dto.IsSpecial, + Pages = dto.Pages, + }); + } - var key = BookService.CleanContentKeys(file); - if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book"); + /// + /// This is an entry point to fetch resources from within an epub chapter/book. + /// + /// + /// + /// + [HttpGet("{chapterId}/book-resources")] + [ResponseCache(Duration = 60 * 1, Location = ResponseCacheLocation.Client, NoStore = false)] + [AllowAnonymous] + public async Task GetBookPageResources(int chapterId, [FromQuery] string file) + { + if (chapterId <= 0) return BadRequest("Chapter is not valid"); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); - var bookFile = book.Content.AllFiles[key]; - var content = await bookFile.ReadContentAsBytesAsync(); + var key = BookService.CleanContentKeys(file); + if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book"); - var contentType = BookService.GetContentType(bookFile.ContentType); - return File(content, contentType, $"{chapterId}-{file}"); - } + var bookFile = book.Content.AllFiles[key]; + var content = await bookFile.ReadContentAsBytesAsync(); - /// - /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order - /// this is used to rewrite anchors in the book text so that we always load properly in our reader. - /// - /// This is essentially building the table of contents - /// - /// - [HttpGet("{chapterId}/chapters")] - public async Task>> GetBookChapters(int chapterId) - { - if (chapterId <= 0) return BadRequest("Chapter is not valid"); + var contentType = BookService.GetContentType(bookFile.ContentType); + return File(content, contentType, $"{chapterId}-{file}"); + } - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - try - { - return Ok(await _bookService.GenerateTableOfContents(chapter)); - } - catch (KavitaException ex) - { - return BadRequest(ex.Message); - } + /// + /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order + /// this is used to rewrite anchors in the book text so that we always load properly in our reader. + /// + /// This is essentially building the table of contents + /// + /// + [HttpGet("{chapterId}/chapters")] + public async Task>> GetBookChapters(int chapterId) + { + if (chapterId <= 0) return BadRequest("Chapter is not valid"); + + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + try + { + return Ok(await _bookService.GenerateTableOfContents(chapter)); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); } + } - /// - /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, - /// all css is scoped, etc. - /// - /// - /// - /// - [HttpGet("{chapterId}/book-page")] - public async Task> GetBookPage(int chapterId, [FromQuery] int page) - { - var chapter = await _cacheService.Ensure(chapterId); - var path = _cacheService.GetCachedFile(chapter); + /// + /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, + /// all css is scoped, etc. + /// + /// + /// + /// + [HttpGet("{chapterId}/book-page")] + public async Task> GetBookPage(int chapterId, [FromQuery] int page) + { + var chapter = await _cacheService.Ensure(chapterId); + var path = _cacheService.GetCachedFile(chapter); - var baseUrl = "//" + Request.Host + Request.PathBase + "/api/"; + var baseUrl = "//" + Request.Host + Request.PathBase + "/api/"; - try - { - return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); - } - catch (KavitaException ex) - { - return BadRequest(ex.Message); - } + try + { + return Ok(await _bookService.GetBookPage(page, chapterId, path, baseUrl)); + } + catch (KavitaException ex) + { + return BadRequest(ex.Message); } - } + } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index f030bd1664..b216ed6c70 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -11,182 +11,181 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; -namespace API.Controllers +namespace API.Controllers; + +/// +/// APIs for Collections +/// +public class CollectionController : BaseApiController { + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + + /// + public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _eventHub = eventHub; + } + /// - /// APIs for Collections + /// Return a list of all collection tags on the server /// - public class CollectionController : BaseApiController + /// + [HttpGet] + public async Task> GetAllTags() { - private readonly IUnitOfWork _unitOfWork; - private readonly IEventHub _eventHub; - - /// - public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub) + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + if (isAdmin) { - _unitOfWork = unitOfWork; - _eventHub = eventHub; + return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); } + return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + } + + /// + /// Searches against the collection tags on the DB and returns matches that meet the search criteria. + /// Search strings will be cleaned of certain fields, like % + /// + /// Search term + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("search")] + public async Task> SearchTags(string queryString) + { + queryString ??= ""; + queryString = queryString.Replace(@"%", string.Empty); + if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + + return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString); + } - /// - /// Return a list of all collection tags on the server - /// - /// - [HttpGet] - public async Task> GetAllTags() + /// + /// Updates an existing tag with a new title, promotion status, and summary. + /// UI does not contain controls to update title + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateTagPromotion(CollectionTagDto updatedTag) + { + var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id); + if (existingTag == null) return BadRequest("This tag does not exist"); + + existingTag.Promoted = updatedTag.Promoted; + existingTag.Title = updatedTag.Title.Trim(); + existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title).ToUpper(); + existingTag.Summary = updatedTag.Summary.Trim(); + + if (_unitOfWork.HasChanges()) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (isAdmin) + if (await _unitOfWork.CommitAsync()) { - return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + return Ok("Tag updated successfully"); } - return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); } - - /// - /// Searches against the collection tags on the DB and returns matches that meet the search criteria. - /// Search strings will be cleaned of certain fields, like % - /// - /// Search term - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("search")] - public async Task> SearchTags(string queryString) + else { - queryString ??= ""; - queryString = queryString.Replace(@"%", string.Empty); - if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); - - return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString); + return Ok("Tag updated successfully"); } - /// - /// Updates an existing tag with a new title, promotion status, and summary. - /// UI does not contain controls to update title - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("update")] - public async Task UpdateTagPromotion(CollectionTagDto updatedTag) + return BadRequest("Something went wrong, please try again"); + } + + /// + /// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update-for-series")] + public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) + { + var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(dto.CollectionTagId); + if (tag == null) { - var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id); - if (existingTag == null) return BadRequest("This tag does not exist"); + tag = DbFactory.CollectionTag(0, dto.CollectionTagTitle, String.Empty, false); + _unitOfWork.CollectionTagRepository.Add(tag); + } - existingTag.Promoted = updatedTag.Promoted; - existingTag.Title = updatedTag.Title.Trim(); - existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title).ToUpper(); - existingTag.Summary = updatedTag.Summary.Trim(); - if (_unitOfWork.HasChanges()) - { - if (await _unitOfWork.CommitAsync()) - { - return Ok("Tag updated successfully"); - } - } - else + var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(dto.SeriesIds); + foreach (var metadata in seriesMetadatas) + { + if (!metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture))) { - return Ok("Tag updated successfully"); + metadata.CollectionTags.Add(tag); + _unitOfWork.SeriesMetadataRepository.Update(metadata); } + } - return BadRequest("Something went wrong, please try again"); + if (!_unitOfWork.HasChanges()) return Ok(); + if (await _unitOfWork.CommitAsync()) + { + return Ok(); } + return BadRequest("There was an issue updating series with collection tag"); + } - /// - /// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("update-for-series")] - public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) + /// + /// For a given tag, update the summary if summary has changed and remove a set of series from the tag. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update-series")] + public async Task UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto) + { + try { - var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(dto.CollectionTagId); - if (tag == null) + var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(updateSeriesForTagDto.Tag.Id); + if (tag == null) return BadRequest("Not a valid Tag"); + tag.SeriesMetadatas ??= new List(); + + // Check if Tag has updated (Summary) + if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary)) { - tag = DbFactory.CollectionTag(0, dto.CollectionTagTitle, String.Empty, false); - _unitOfWork.CollectionTagRepository.Add(tag); + tag.Summary = updateSeriesForTagDto.Tag.Summary; + _unitOfWork.CollectionTagRepository.Update(tag); } + tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked; - var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(dto.SeriesIds); - foreach (var metadata in seriesMetadatas) + if (!updateSeriesForTagDto.Tag.CoverImageLocked) { - if (!metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture))) - { - metadata.CollectionTags.Add(tag); - _unitOfWork.SeriesMetadataRepository.Update(metadata); - } + tag.CoverImageLocked = false; + tag.CoverImage = string.Empty; + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); + _unitOfWork.CollectionTagRepository.Update(tag); } - if (!_unitOfWork.HasChanges()) return Ok(); - if (await _unitOfWork.CommitAsync()) + foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove) { - return Ok(); + tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); } - return BadRequest("There was an issue updating series with collection tag"); - } - /// - /// For a given tag, update the summary if summary has changed and remove a set of series from the tag. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("update-series")] - public async Task UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto) - { - try + + if (tag.SeriesMetadatas.Count == 0) { - var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(updateSeriesForTagDto.Tag.Id); - if (tag == null) return BadRequest("Not a valid Tag"); - tag.SeriesMetadatas ??= new List(); - - // Check if Tag has updated (Summary) - if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary)) - { - tag.Summary = updateSeriesForTagDto.Tag.Summary; - _unitOfWork.CollectionTagRepository.Update(tag); - } - - tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked; - - if (!updateSeriesForTagDto.Tag.CoverImageLocked) - { - tag.CoverImageLocked = false; - tag.CoverImage = string.Empty; - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); - _unitOfWork.CollectionTagRepository.Update(tag); - } - - foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove) - { - tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); - } - - - if (tag.SeriesMetadatas.Count == 0) - { - _unitOfWork.CollectionTagRepository.Remove(tag); - } - - if (!_unitOfWork.HasChanges()) return Ok("No updates"); - - if (await _unitOfWork.CommitAsync()) - { - return Ok("Tag updated"); - } + _unitOfWork.CollectionTagRepository.Remove(tag); } - catch (Exception) + + if (!_unitOfWork.HasChanges()) return Ok("No updates"); + + if (await _unitOfWork.CommitAsync()) { - await _unitOfWork.RollbackAsync(); + return Ok("Tag updated"); } + } + catch (Exception) + { + await _unitOfWork.RollbackAsync(); + } - return BadRequest("Something went wrong. Please try again."); - } + return BadRequest("Something went wrong. Please try again."); } } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index fc7e498079..a2fae1b9c7 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -16,210 +16,209 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +/// +/// All APIs related to downloading entities from the system. Requires Download Role or Admin Role. +/// +[Authorize(Policy="RequireDownloadRole")] +public class DownloadController : BaseApiController { + private readonly IUnitOfWork _unitOfWork; + private readonly IArchiveService _archiveService; + private readonly IDirectoryService _directoryService; + private readonly IDownloadService _downloadService; + private readonly IEventHub _eventHub; + private readonly ILogger _logger; + private readonly IBookmarkService _bookmarkService; + private readonly IAccountService _accountService; + private const string DefaultContentType = "application/octet-stream"; + + public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, + IDownloadService downloadService, IEventHub eventHub, ILogger logger, IBookmarkService bookmarkService, + IAccountService accountService) + { + _unitOfWork = unitOfWork; + _archiveService = archiveService; + _directoryService = directoryService; + _downloadService = downloadService; + _eventHub = eventHub; + _logger = logger; + _bookmarkService = bookmarkService; + _accountService = accountService; + } + /// - /// All APIs related to downloading entities from the system. Requires Download Role or Admin Role. + /// For a given volume, return the size in bytes /// - [Authorize(Policy="RequireDownloadRole")] - public class DownloadController : BaseApiController + /// + /// + [HttpGet("volume-size")] + public async Task> GetVolumeSize(int volumeId) { - private readonly IUnitOfWork _unitOfWork; - private readonly IArchiveService _archiveService; - private readonly IDirectoryService _directoryService; - private readonly IDownloadService _downloadService; - private readonly IEventHub _eventHub; - private readonly ILogger _logger; - private readonly IBookmarkService _bookmarkService; - private readonly IAccountService _accountService; - private const string DefaultContentType = "application/octet-stream"; - - public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, - IDownloadService downloadService, IEventHub eventHub, ILogger logger, IBookmarkService bookmarkService, - IAccountService accountService) - { - _unitOfWork = unitOfWork; - _archiveService = archiveService; - _directoryService = directoryService; - _downloadService = downloadService; - _eventHub = eventHub; - _logger = logger; - _bookmarkService = bookmarkService; - _accountService = accountService; - } + var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); + return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); + } - /// - /// For a given volume, return the size in bytes - /// - /// - /// - [HttpGet("volume-size")] - public async Task> GetVolumeSize(int volumeId) - { - var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); - return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); - } + /// + /// For a given chapter, return the size in bytes + /// + /// + /// + [HttpGet("chapter-size")] + public async Task> GetChapterSize(int chapterId) + { + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); + } - /// - /// For a given chapter, return the size in bytes - /// - /// - /// - [HttpGet("chapter-size")] - public async Task> GetChapterSize(int chapterId) - { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); - } + /// + /// For a series, return the size in bytes + /// + /// + /// + [HttpGet("series-size")] + public async Task> GetSeriesSize(int seriesId) + { + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); + } - /// - /// For a series, return the size in bytes - /// - /// - /// - [HttpGet("series-size")] - public async Task> GetSeriesSize(int seriesId) - { - var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); - return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); - } + /// + /// Downloads all chapters within a volume. If the chapters are multiple zips, they will all be zipped up. + /// + /// + /// + [Authorize(Policy="RequireDownloadRole")] + [HttpGet("volume")] + public async Task DownloadVolume(int volumeId) + { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); - /// - /// Downloads all chapters within a volume. If the chapters are multiple zips, they will all be zipped up. - /// - /// - /// - [Authorize(Policy="RequireDownloadRole")] - [HttpGet("volume")] - public async Task DownloadVolume(int volumeId) + var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); + try { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); - - var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); - try - { - return await DownloadFiles(files, $"download_{User.GetUsername()}_v{volumeId}", $"{series.Name} - Volume {volume.Number}.zip"); - } - catch (KavitaException ex) - { - return BadRequest(ex.Message); - } + return await DownloadFiles(files, $"download_{User.GetUsername()}_v{volumeId}", $"{series.Name} - Volume {volume.Number}.zip"); } - - private async Task HasDownloadPermission() + catch (KavitaException ex) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - return await _accountService.HasDownloadPermission(user); + return BadRequest(ex.Message); } + } + + private async Task HasDownloadPermission() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + return await _accountService.HasDownloadPermission(user); + } + + private ActionResult GetFirstFileDownload(IEnumerable files) + { + var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files); + return PhysicalFile(zipFile, contentType, fileDownloadName, true); + } - private ActionResult GetFirstFileDownload(IEnumerable files) + /// + /// Returns the zip for a single chapter. If the chapter contains multiple files, they will be zipped. + /// + /// + /// + [HttpGet("chapter")] + public async Task DownloadChapter(int chapterId) + { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); + try { - var (zipFile, contentType, fileDownloadName) = _downloadService.GetFirstFileDownload(files); - return PhysicalFile(zipFile, contentType, fileDownloadName, true); + return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series.Name} - Chapter {chapter.Number}.zip"); } - - /// - /// Returns the zip for a single chapter. If the chapter contains multiple files, they will be zipped. - /// - /// - /// - [HttpGet("chapter")] - public async Task DownloadChapter(int chapterId) + catch (KavitaException ex) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); - try - { - return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series.Name} - Chapter {chapter.Number}.zip"); - } - catch (KavitaException ex) - { - return BadRequest(ex.Message); - } + return BadRequest(ex.Message); } + } - private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName) + private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName) + { + try { - try - { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 0F, "started")); - if (files.Count == 1) - { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); - return GetFirstFileDownload(files); - } - - var filePath = _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), tempFolder); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); - return PhysicalFile(filePath, DefaultContentType, downloadName, true); - } - catch (Exception ex) + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 0F, "started")); + if (files.Count == 1) { - _logger.LogError(ex, "There was an exception when trying to download files"); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); - throw; + return GetFirstFileDownload(files); } - } - [HttpGet("series")] - public async Task DownloadSeries(int seriesId) + var filePath = _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), tempFolder); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); + return PhysicalFile(filePath, DefaultContentType, downloadName, true); + } + catch (Exception ex) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); - var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - try - { - return await DownloadFiles(files, $"download_{User.GetUsername()}_s{seriesId}", $"{series.Name}.zip"); - } - catch (KavitaException ex) - { - return BadRequest(ex.Message); - } + _logger.LogError(ex, "There was an exception when trying to download files"); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), + Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); + throw; } + } - /// - /// Downloads all bookmarks in a zip for - /// - /// - /// - [HttpPost("bookmarks")] - public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) + [HttpGet("series")] + public async Task DownloadSeries(int seriesId) + { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + try + { + return await DownloadFiles(files, $"download_{User.GetUsername()}_s{seriesId}", $"{series.Name}.zip"); + } + catch (KavitaException ex) { - if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); - if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest("Bookmarks cannot be empty"); + return BadRequest(ex.Message); + } + } - // We know that all bookmarks will be for one single seriesId - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId); + /// + /// Downloads all bookmarks in a zip for + /// + /// + /// + [HttpPost("bookmarks")] + public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) + { + if (!await HasDownloadPermission()) return BadRequest("You do not have permission"); + if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest("Bookmarks cannot be empty"); - var files = await _bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id)); + // We know that all bookmarks will be for one single seriesId + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId); - var filename = $"{series.Name} - Bookmarks.zip"; - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F)); - var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct()); - var filePath = _archiveService.CreateZipForDownload(files, - $"download_{user.Id}_{seriesIds}_bookmarks"); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F)); + var files = await _bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id)); + var filename = $"{series.Name} - Bookmarks.zip"; + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F)); + var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct()); + var filePath = _archiveService.CreateZipForDownload(files, + $"download_{user.Id}_{seriesIds}_bookmarks"); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F)); - return PhysicalFile(filePath, DefaultContentType, filename, true); - } + return PhysicalFile(filePath, DefaultContentType, filename, true); } + } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index f83df2068b..96c27ede77 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -7,147 +7,146 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace API.Controllers +namespace API.Controllers; + +/// +/// Responsible for servicing up images stored in Kavita for entities +/// +[AllowAnonymous] +public class ImageController : BaseApiController { + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + + /// + public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService) + { + _unitOfWork = unitOfWork; + _directoryService = directoryService; + } + + /// + /// Returns cover image for Chapter + /// + /// + /// + [HttpGet("chapter-cover")] + [ResponseCache(CacheProfileName = "Images")] + public async Task GetChapterCoverImage(int chapterId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + /// - /// Responsible for servicing up images stored in Kavita for entities + /// Returns cover image for Volume /// - [AllowAnonymous] - public class ImageController : BaseApiController + /// + /// + [HttpGet("volume-cover")] + [ResponseCache(CacheProfileName = "Images")] + public async Task GetVolumeCoverImage(int volumeId) { - private readonly IUnitOfWork _unitOfWork; - private readonly IDirectoryService _directoryService; - - /// - public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService) - { - _unitOfWork = unitOfWork; - _directoryService = directoryService; - } - - /// - /// Returns cover image for Chapter - /// - /// - /// - [HttpGet("chapter-cover")] - [ResponseCache(CacheProfileName = "Images")] - public async Task GetChapterCoverImage(int chapterId) - { - var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); - var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - - return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); - } - - /// - /// Returns cover image for Volume - /// - /// - /// - [HttpGet("volume-cover")] - [ResponseCache(CacheProfileName = "Images")] - public async Task GetVolumeCoverImage(int volumeId) - { - var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); - var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - - return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); - } - - /// - /// Returns cover image for Series - /// - /// Id of Series - /// - [ResponseCache(CacheProfileName = "Images")] - [HttpGet("series-cover")] - public async Task GetSeriesCoverImage(int seriesId) - { - var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); - var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - - Response.AddCacheHeader(path); - - return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); - } - - /// - /// Returns cover image for Collection Tag - /// - /// - /// - [HttpGet("collection-cover")] - [ResponseCache(CacheProfileName = "Images")] - public async Task GetCollectionCoverImage(int collectionTagId) - { - var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); - var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - - return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); - } - - /// - /// Returns cover image for a Reading List - /// - /// - /// - [HttpGet("readinglist-cover")] - [ResponseCache(CacheProfileName = "Images")] - public async Task GetReadingListCoverImage(int readingListId) - { - var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); - var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - - return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); - } - - /// - /// Returns image for a given bookmark page - /// - /// This request is served unauthenticated, but user must be passed via api key to validate - /// - /// Starts at 0 - /// API Key for user. Needed to authenticate request - /// - [HttpGet("bookmark")] - [ResponseCache(CacheProfileName = "Images")] - public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId); - if (bookmark == null) return BadRequest("Bookmark does not exist"); - - var bookmarkDirectory = - (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; - var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName)); - var format = Path.GetExtension(file.FullName).Replace(".", ""); - - return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName)); - } - - /// - /// Returns a temp coverupload image - /// - /// Filename of file. This is used with upload/upload-by-url - /// - [Authorize(Policy="RequireAdminRole")] - [HttpGet("cover-upload")] - [ResponseCache(CacheProfileName = "Images")] - public ActionResult GetCoverUploadImage(string filename) - { - if (filename.Contains("..")) return BadRequest("Invalid Filename"); - - var path = Path.Join(_directoryService.TempDirectory, filename); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist"); - var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); - - return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); - } + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + + /// + /// Returns cover image for Series + /// + /// Id of Series + /// + [ResponseCache(CacheProfileName = "Images")] + [HttpGet("series-cover")] + public async Task GetSeriesCoverImage(int seriesId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + Response.AddCacheHeader(path); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + + /// + /// Returns cover image for Collection Tag + /// + /// + /// + [HttpGet("collection-cover")] + [ResponseCache(CacheProfileName = "Images")] + public async Task GetCollectionCoverImage(int collectionTagId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + + /// + /// Returns cover image for a Reading List + /// + /// + /// + [HttpGet("readinglist-cover")] + [ResponseCache(CacheProfileName = "Images")] + public async Task GetReadingListCoverImage(int readingListId) + { + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); + } + + /// + /// Returns image for a given bookmark page + /// + /// This request is served unauthenticated, but user must be passed via api key to validate + /// + /// Starts at 0 + /// API Key for user. Needed to authenticate request + /// + [HttpGet("bookmark")] + [ResponseCache(CacheProfileName = "Images")] + public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId); + if (bookmark == null) return BadRequest("Bookmark does not exist"); + + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName)); + var format = Path.GetExtension(file.FullName).Replace(".", ""); + + return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName)); + } + + /// + /// Returns a temp coverupload image + /// + /// Filename of file. This is used with upload/upload-by-url + /// + [Authorize(Policy="RequireAdminRole")] + [HttpGet("cover-upload")] + [ResponseCache(CacheProfileName = "Images")] + public ActionResult GetCoverUploadImage(string filename) + { + if (filename.Contains("..")) return BadRequest("Invalid Filename"); + + var path = Path.Join(_directoryService.TempDirectory, filename); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist"); + var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", ""); + + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 3a387d83e3..031ae12d72 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -22,323 +22,322 @@ using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; -namespace API.Controllers +namespace API.Controllers; + +[Authorize] +public class LibraryController : BaseApiController { - [Authorize] - public class LibraryController : BaseApiController + private readonly IDirectoryService _directoryService; + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly ITaskScheduler _taskScheduler; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private readonly ILibraryWatcher _libraryWatcher; + + public LibraryController(IDirectoryService directoryService, + ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, + IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher) + { + _directoryService = directoryService; + _logger = logger; + _mapper = mapper; + _taskScheduler = taskScheduler; + _unitOfWork = unitOfWork; + _eventHub = eventHub; + _libraryWatcher = libraryWatcher; + } + + /// + /// Creates a new Library. Upon library creation, adds new library to all Admin accounts. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("create")] + public async Task AddLibrary(CreateLibraryDto createLibraryDto) { - private readonly IDirectoryService _directoryService; - private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly ITaskScheduler _taskScheduler; - private readonly IUnitOfWork _unitOfWork; - private readonly IEventHub _eventHub; - private readonly ILibraryWatcher _libraryWatcher; - - public LibraryController(IDirectoryService directoryService, - ILogger logger, IMapper mapper, ITaskScheduler taskScheduler, - IUnitOfWork unitOfWork, IEventHub eventHub, ILibraryWatcher libraryWatcher) + if (await _unitOfWork.LibraryRepository.LibraryExists(createLibraryDto.Name)) { - _directoryService = directoryService; - _logger = logger; - _mapper = mapper; - _taskScheduler = taskScheduler; - _unitOfWork = unitOfWork; - _eventHub = eventHub; - _libraryWatcher = libraryWatcher; + return BadRequest("Library name already exists. Please choose a unique name to the server."); } - /// - /// Creates a new Library. Upon library creation, adds new library to all Admin accounts. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("create")] - public async Task AddLibrary(CreateLibraryDto createLibraryDto) + var library = new Library { - if (await _unitOfWork.LibraryRepository.LibraryExists(createLibraryDto.Name)) - { - return BadRequest("Library name already exists. Please choose a unique name to the server."); - } + Name = createLibraryDto.Name, + Type = createLibraryDto.Type, + Folders = createLibraryDto.Folders.Select(x => new FolderPath {Path = x}).ToList() + }; - var library = new Library - { - Name = createLibraryDto.Name, - Type = createLibraryDto.Type, - Folders = createLibraryDto.Folders.Select(x => new FolderPath {Path = x}).ToList() - }; - - _unitOfWork.LibraryRepository.Add(library); + _unitOfWork.LibraryRepository.Add(library); - var admins = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).ToList(); - foreach (var admin in admins) - { - admin.Libraries ??= new List(); - admin.Libraries.Add(library); - } + var admins = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).ToList(); + foreach (var admin in admins) + { + admin.Libraries ??= new List(); + admin.Libraries.Add(library); + } - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue. Please try again."); + if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue. Please try again."); - _logger.LogInformation("Created a new library: {LibraryName}", library.Name); - await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); - await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, - MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); - return Ok(); - } + _logger.LogInformation("Created a new library: {LibraryName}", library.Name); + await _libraryWatcher.RestartWatching(); + _taskScheduler.ScanLibrary(library.Id); + await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, + MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); + return Ok(); + } - /// - /// Returns a list of directories for a given path. If path is empty, returns root drives. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("list")] - public ActionResult> GetDirectories(string path) + /// + /// Returns a list of directories for a given path. If path is empty, returns root drives. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("list")] + public ActionResult> GetDirectories(string path) + { + if (string.IsNullOrEmpty(path)) { - if (string.IsNullOrEmpty(path)) + return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto() { - return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto() - { - Name = d, - FullPath = d - })); - } - - if (!Directory.Exists(path)) return BadRequest("This is not a valid path"); - - return Ok(_directoryService.ListDirectory(path)); + Name = d, + FullPath = d + })); } - [HttpGet] - public async Task>> GetLibraries() - { - return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()); - } + if (!Directory.Exists(path)) return BadRequest("This is not a valid path"); - [HttpGet("jump-bar")] - public async Task>> GetJumpBar(int libraryId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) return BadRequest("User does not have access to library"); + return Ok(_directoryService.ListDirectory(path)); + } - return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId)); - } + [HttpGet] + public async Task>> GetLibraries() + { + return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()); + } + [HttpGet("jump-bar")] + public async Task>> GetJumpBar(int libraryId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) return BadRequest("User does not have access to library"); - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("grant-access")] - public async Task> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(updateLibraryForUserDto.Username); - if (user == null) return BadRequest("Could not validate user"); + return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId)); + } - var libraryString = string.Join(",", updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); - _logger.LogInformation("Granting user {UserName} access to: {Libraries}", updateLibraryForUserDto.Username, libraryString); - var allLibraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); - foreach (var library in allLibraries) - { - library.AppUsers ??= new List(); - var libraryContainsUser = library.AppUsers.Any(u => u.UserName == user.UserName); - var libraryIsSelected = updateLibraryForUserDto.SelectedLibraries.Any(l => l.Id == library.Id); - if (libraryContainsUser && !libraryIsSelected) - { - // Remove - library.AppUsers.Remove(user); - } - else if (!libraryContainsUser && libraryIsSelected) - { - library.AppUsers.Add(user); - } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("grant-access")] + public async Task> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(updateLibraryForUserDto.Username); + if (user == null) return BadRequest("Could not validate user"); - } + var libraryString = string.Join(",", updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); + _logger.LogInformation("Granting user {UserName} access to: {Libraries}", updateLibraryForUserDto.Username, libraryString); - if (!_unitOfWork.HasChanges()) + var allLibraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); + foreach (var library in allLibraries) + { + library.AppUsers ??= new List(); + var libraryContainsUser = library.AppUsers.Any(u => u.UserName == user.UserName); + var libraryIsSelected = updateLibraryForUserDto.SelectedLibraries.Any(l => l.Id == library.Id); + if (libraryContainsUser && !libraryIsSelected) { - _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); - return Ok(_mapper.Map(user)); + // Remove + library.AppUsers.Remove(user); } - - if (await _unitOfWork.CommitAsync()) + else if (!libraryContainsUser && libraryIsSelected) { - _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); - return Ok(_mapper.Map(user)); + library.AppUsers.Add(user); } - - return BadRequest("There was a critical issue. Please try again."); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("scan")] - public ActionResult Scan(int libraryId, bool force = false) + if (!_unitOfWork.HasChanges()) { - _taskScheduler.ScanLibrary(libraryId, force); - return Ok(); + _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); + return Ok(_mapper.Map(user)); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("refresh-metadata")] - public ActionResult RefreshMetadata(int libraryId, bool force = true) + if (await _unitOfWork.CommitAsync()) { - _taskScheduler.RefreshMetadata(libraryId, force); - return Ok(); + _logger.LogInformation("Added: {SelectedLibraries} to {Username}",libraryString, updateLibraryForUserDto.Username); + return Ok(_mapper.Map(user)); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze")] - public ActionResult Analyze(int libraryId) - { - _taskScheduler.AnalyzeFilesForLibrary(libraryId, true); - return Ok(); - } - [HttpGet("libraries")] - public async Task>> GetLibrariesForUser() - { - return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(User.GetUsername())); - } + return BadRequest("There was a critical issue. Please try again."); + } - /// - /// Given a valid path, will invoke either a Scan Series or Scan Library. If the folder does not exist within Kavita, the request will be ignored - /// - /// - /// - [AllowAnonymous] - [HttpPost("scan-folder")] - public async Task ScanFolder(ScanFolderDto dto) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(dto.ApiKey); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - // Validate user has Admin privileges - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (!isAdmin) return BadRequest("API key must belong to an admin"); - if (dto.FolderPath.Contains("..")) return BadRequest("Invalid Path"); + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("scan")] + public ActionResult Scan(int libraryId, bool force = false) + { + _taskScheduler.ScanLibrary(libraryId, force); + return Ok(); + } - dto.FolderPath = Services.Tasks.Scanner.Parser.Parser.NormalizePath(dto.FolderPath); + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("refresh-metadata")] + public ActionResult RefreshMetadata(int libraryId, bool force = true) + { + _taskScheduler.RefreshMetadata(libraryId, force); + return Ok(); + } - var libraryFolder = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()) - .SelectMany(l => l.Folders) - .Distinct() - .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("analyze")] + public ActionResult Analyze(int libraryId) + { + _taskScheduler.AnalyzeFilesForLibrary(libraryId, true); + return Ok(); + } - var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, - new List() {dto.FolderPath}); + [HttpGet("libraries")] + public async Task>> GetLibrariesForUser() + { + return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(User.GetUsername())); + } - _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); + /// + /// Given a valid path, will invoke either a Scan Series or Scan Library. If the folder does not exist within Kavita, the request will be ignored + /// + /// + /// + [AllowAnonymous] + [HttpPost("scan-folder")] + public async Task ScanFolder(ScanFolderDto dto) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(dto.ApiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + // Validate user has Admin privileges + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + if (!isAdmin) return BadRequest("API key must belong to an admin"); + if (dto.FolderPath.Contains("..")) return BadRequest("Invalid Path"); - return Ok(); - } + dto.FolderPath = Services.Tasks.Scanner.Parser.Parser.NormalizePath(dto.FolderPath); - [Authorize(Policy = "RequireAdminRole")] - [HttpDelete("delete")] - public async Task> DeleteLibrary(int libraryId) - { - var username = User.GetUsername(); - _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username); - var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); - var seriesIds = series.Select(x => x.Id).ToArray(); - var chapterIds = - await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(seriesIds); - - try - { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); - if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) - { - // TODO: Figure out how to cancel a job - _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); - return BadRequest( - "You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete"); - } - _unitOfWork.LibraryRepository.Delete(library); - await _unitOfWork.CommitAsync(); + var libraryFolder = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()) + .SelectMany(l => l.Folders) + .Distinct() + .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); - if (chapterIds.Any()) - { - await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - await _unitOfWork.CommitAsync(); - _taskScheduler.CleanupChapters(chapterIds); - } + var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, + new List() {dto.FolderPath}); - await _libraryWatcher.RestartWatching(); + _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); - foreach (var seriesId in seriesIds) - { - await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, - MessageFactory.SeriesRemovedEvent(seriesId, string.Empty, libraryId), false); - } + return Ok(); + } - await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, - MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); - return Ok(true); + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("delete")] + public async Task> DeleteLibrary(int libraryId) + { + var username = User.GetUsername(); + _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username); + var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); + var seriesIds = series.Select(x => x.Id).ToArray(); + var chapterIds = + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(seriesIds); + + try + { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) + { + // TODO: Figure out how to cancel a job + _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); + return BadRequest( + "You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete"); } - catch (Exception ex) + _unitOfWork.LibraryRepository.Delete(library); + await _unitOfWork.CommitAsync(); + + if (chapterIds.Any()) { - _logger.LogError(ex, "There was a critical error trying to delete the library"); - await _unitOfWork.RollbackAsync(); - return Ok(false); + await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + await _unitOfWork.CommitAsync(); + _taskScheduler.CleanupChapters(chapterIds); } - } - /// - /// Updates an existing Library with new name, folders, and/or type. - /// - /// Any folder or type change will invoke a scan. - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("update")] - public async Task UpdateLibrary(UpdateLibraryDto libraryForUserDto) - { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id, LibraryIncludes.Folders); + await _libraryWatcher.RestartWatching(); + + foreach (var seriesId in seriesIds) + { + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(seriesId, string.Empty, libraryId), false); + } - var originalFolders = library.Folders.Select(x => x.Path).ToList(); + await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, + MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); + return Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was a critical error trying to delete the library"); + await _unitOfWork.RollbackAsync(); + return Ok(false); + } + } - library.Name = libraryForUserDto.Name; - library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList(); + /// + /// Updates an existing Library with new name, folders, and/or type. + /// + /// Any folder or type change will invoke a scan. + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateLibrary(UpdateLibraryDto libraryForUserDto) + { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id, LibraryIncludes.Folders); - var typeUpdate = library.Type != libraryForUserDto.Type; - library.Type = libraryForUserDto.Type; + var originalFolders = library.Folders.Select(x => x.Path).ToList(); - _unitOfWork.LibraryRepository.Update(library); + library.Name = libraryForUserDto.Name; + library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList(); - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library."); - if (originalFolders.Count != libraryForUserDto.Folders.Count() || typeUpdate) - { - await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); - } + var typeUpdate = library.Type != libraryForUserDto.Type; + library.Type = libraryForUserDto.Type; - return Ok(); + _unitOfWork.LibraryRepository.Update(library); + if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library."); + if (originalFolders.Count != libraryForUserDto.Folders.Count() || typeUpdate) + { + await _libraryWatcher.RestartWatching(); + _taskScheduler.ScanLibrary(library.Id); } - [HttpGet("search")] - public async Task> Search(string queryString) - { - queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); + return Ok(); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); + } - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + [HttpGet("search")] + public async Task> Search(string queryString) + { + queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty); - var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + // Get libraries user has access to + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList(); - return Ok(series); - } + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - [HttpGet("type")] - public async Task> GetLibraryType(int libraryId) - { - return Ok(await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId)); - } + var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString); + + return Ok(series); + } + + [HttpGet("type")] + public async Task> GetLibraryType(int libraryId) + { + return Ok(await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId)); } } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index 4a12097107..39f3969855 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -7,44 +7,43 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +public class PluginController : BaseApiController { - public class PluginController : BaseApiController - { - private readonly IUnitOfWork _unitOfWork; - private readonly ITokenService _tokenService; - private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly ITokenService _tokenService; + private readonly ILogger _logger; - public PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger logger) - { - _unitOfWork = unitOfWork; - _tokenService = tokenService; - _logger = logger; - } + public PluginController(IUnitOfWork unitOfWork, ITokenService tokenService, ILogger logger) + { + _unitOfWork = unitOfWork; + _tokenService = tokenService; + _logger = logger; + } - /// - /// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token. - /// - /// This API is not fully built out and may require more information in later releases - /// API key which will be used to authenticate and return a valid user token back - /// Name of the Plugin - /// - [AllowAnonymous] - [HttpPost("authenticate")] - public async Task> Authenticate([Required] string apiKey, [Required] string pluginName) + /// + /// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token. + /// + /// This API is not fully built out and may require more information in later releases + /// API key which will be used to authenticate and return a valid user token back + /// Name of the Plugin + /// + [AllowAnonymous] + [HttpPost("authenticate")] + public async Task> Authenticate([Required] string apiKey, [Required] string pluginName) + { + // NOTE: In order to log information about plugins, we need some Plugin Description information for each request + // Should log into access table so we can tell the user + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId <= 0) return Unauthorized(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + _logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user.UserName, userId); + return new UserDto { - // NOTE: In order to log information about plugins, we need some Plugin Description information for each request - // Should log into access table so we can tell the user - var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - if (userId <= 0) return Unauthorized(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - _logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user.UserName, userId); - return new UserDto - { - Username = user.UserName, - Token = await _tokenService.CreateToken(user), - ApiKey = user.ApiKey, - }; - } + Username = user.UserName, + Token = await _tokenService.CreateToken(user), + ApiKey = user.ApiKey, + }; } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 113f285707..110b1a2a19 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -17,756 +17,755 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +/// +/// For all things regarding reading, mainly focusing on non-Book related entities +/// +public class ReaderController : BaseApiController { + private readonly ICacheService _cacheService; + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IReaderService _readerService; + private readonly IBookmarkService _bookmarkService; + private readonly IAccountService _accountService; + + /// + public ReaderController(ICacheService cacheService, + IUnitOfWork unitOfWork, ILogger logger, + IReaderService readerService, IBookmarkService bookmarkService, + IAccountService accountService) + { + _cacheService = cacheService; + _unitOfWork = unitOfWork; + _logger = logger; + _readerService = readerService; + _bookmarkService = bookmarkService; + _accountService = accountService; + } + /// - /// For all things regarding reading, mainly focusing on non-Book related entities + /// Returns the PDF for the chapterId. /// - public class ReaderController : BaseApiController + /// + /// + [HttpGet("pdf")] + [ResponseCache(CacheProfileName = "Hour")] + public async Task GetPdf(int chapterId) { - private readonly ICacheService _cacheService; - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IReaderService _readerService; - private readonly IBookmarkService _bookmarkService; - private readonly IAccountService _accountService; - - /// - public ReaderController(ICacheService cacheService, - IUnitOfWork unitOfWork, ILogger logger, - IReaderService readerService, IBookmarkService bookmarkService, - IAccountService accountService) - { - _cacheService = cacheService; - _unitOfWork = unitOfWork; - _logger = logger; - _readerService = readerService; - _bookmarkService = bookmarkService; - _accountService = accountService; - } + var chapter = await _cacheService.Ensure(chapterId); + if (chapter == null) return BadRequest("There was an issue finding pdf file for reading"); - /// - /// Returns the PDF for the chapterId. - /// - /// - /// - [HttpGet("pdf")] - [ResponseCache(CacheProfileName = "Hour")] - public async Task GetPdf(int chapterId) - { - var chapter = await _cacheService.Ensure(chapterId); - if (chapter == null) return BadRequest("There was an issue finding pdf file for reading"); - - // Validate the user has access to the PDF - var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id, - await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername())); - if (series == null) return BadRequest("Invalid Access"); + // Validate the user has access to the PDF + var series = await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapter.Id, + await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername())); + if (series == null) return BadRequest("Invalid Access"); - try - { + try + { - var path = _cacheService.GetCachedFile(chapter); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should."); + var path = _cacheService.GetCachedFile(chapter); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should."); - return PhysicalFile(path, "application/pdf", Path.GetFileName(path), true); - } - catch (Exception) - { - _cacheService.CleanupChapters(new []{ chapterId }); - throw; - } + return PhysicalFile(path, "application/pdf", Path.GetFileName(path), true); } - - /// - /// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading. - /// - /// - /// - /// - [HttpGet("image")] - [ResponseCache(CacheProfileName = "Hour")] - [AllowAnonymous] - public async Task GetImage(int chapterId, int page) - { - if (page < 0) page = 0; - var chapter = await _cacheService.Ensure(chapterId); - if (chapter == null) return BadRequest("There was an issue finding image file for reading"); - - try - { - var path = _cacheService.GetCachedPagePath(chapter, page); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache."); - var format = Path.GetExtension(path).Replace(".", ""); - - return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true); - } - catch (Exception) - { - _cacheService.CleanupChapters(new []{ chapterId }); - throw; - } + catch (Exception) + { + _cacheService.CleanupChapters(new []{ chapterId }); + throw; } + } - /// - /// Returns an image for a given bookmark series. Side effect: This will cache the bookmark images for reading. - /// - /// - /// Api key for the user the bookmarks are on - /// - /// We must use api key as bookmarks could be leaked to other users via the API - /// - [HttpGet("bookmark-image")] - [ResponseCache(CacheProfileName = "Hour")] - [AllowAnonymous] - public async Task GetBookmarkImage(int seriesId, string apiKey, int page) - { - if (page < 0) page = 0; - var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - - // NOTE: I'm not sure why I need this flow here - var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); - if (page > totalPages) - { - page = totalPages; - } + /// + /// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading. + /// + /// + /// + /// + [HttpGet("image")] + [ResponseCache(CacheProfileName = "Hour")] + [AllowAnonymous] + public async Task GetImage(int chapterId, int page) + { + if (page < 0) page = 0; + var chapter = await _cacheService.Ensure(chapterId); + if (chapter == null) return BadRequest("There was an issue finding image file for reading"); - try - { - var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); - var format = Path.GetExtension(path).Replace(".", ""); + try + { + var path = _cacheService.GetCachedPagePath(chapter, page); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache."); + var format = Path.GetExtension(path).Replace(".", ""); - return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); - } - catch (Exception) - { - _cacheService.CleanupBookmarks(new []{ seriesId }); - throw; - } + return PhysicalFile(path, "image/" + format, Path.GetFileName(path), true); } - - /// - /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading. - /// - /// - /// - [HttpGet("chapter-info")] - public async Task> GetChapterInfo(int chapterId) + catch (Exception) { - if (chapterId <= 0) return null; // This can happen occasionally from UI, we should just ignore - var chapter = await _cacheService.Ensure(chapterId); - if (chapter == null) return BadRequest("Could not find Chapter"); - - var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); - if (dto == null) return BadRequest("Please perform a scan on this series or library and try again"); - var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); - - var info = new ChapterInfoDto() - { - ChapterNumber = dto.ChapterNumber, - VolumeNumber = dto.VolumeNumber, - VolumeId = dto.VolumeId, - FileName = Path.GetFileName(mangaFile.FilePath), - SeriesName = dto.SeriesName, - SeriesFormat = dto.SeriesFormat, - SeriesId = dto.SeriesId, - LibraryId = dto.LibraryId, - IsSpecial = dto.IsSpecial, - Pages = dto.Pages, - ChapterTitle = dto.ChapterTitle ?? string.Empty, - Subtitle = string.Empty, - Title = dto.SeriesName - }; - - if (info.ChapterTitle is {Length: > 0}) { - info.Title += " - " + info.ChapterTitle; - } + _cacheService.CleanupChapters(new []{ chapterId }); + throw; + } + } - if (info.IsSpecial && dto.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) - { - info.Subtitle = info.FileName; - } else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) - { - info.Subtitle = _readerService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; - } - else - { - info.Subtitle = "Volume " + info.VolumeNumber; - if (!info.ChapterNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) - { - info.Subtitle += " " + _readerService.FormatChapterName(info.LibraryType, true, true) + - info.ChapterNumber; - } - } + /// + /// Returns an image for a given bookmark series. Side effect: This will cache the bookmark images for reading. + /// + /// + /// Api key for the user the bookmarks are on + /// + /// We must use api key as bookmarks could be leaked to other users via the API + /// + [HttpGet("bookmark-image")] + [ResponseCache(CacheProfileName = "Hour")] + [AllowAnonymous] + public async Task GetBookmarkImage(int seriesId, string apiKey, int page) + { + if (page < 0) page = 0; + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - return Ok(info); + // NOTE: I'm not sure why I need this flow here + var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); + if (page > totalPages) + { + page = totalPages; } - /// - /// Returns various information about all bookmark files for a Series. Side effect: This will cache the bookmark images for reading. - /// - /// Series Id for all bookmarks - /// - [HttpGet("bookmark-info")] - public async Task> GetBookmarkInfo(int seriesId) + try { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var totalPages = await _cacheService.CacheBookmarkForSeries(user.Id, seriesId); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); + var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); + var format = Path.GetExtension(path).Replace(".", ""); - return Ok(new BookmarkInfoDto() - { - SeriesName = series.Name, - SeriesFormat = series.Format, - SeriesId = series.Id, - LibraryId = series.LibraryId, - Pages = totalPages, - }); + return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); } - - - /// - /// Marks a Series as read. All volumes and chapters will be marked as read during this process. - /// - /// - /// - [HttpPost("mark-read")] - public async Task MarkRead(MarkReadDto markReadDto) + catch (Exception) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); + _cacheService.CleanupBookmarks(new []{ seriesId }); + throw; + } + } - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); + /// + /// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading. + /// + /// + /// + [HttpGet("chapter-info")] + public async Task> GetChapterInfo(int chapterId) + { + if (chapterId <= 0) return null; // This can happen occasionally from UI, we should just ignore + var chapter = await _cacheService.Ensure(chapterId); + if (chapter == null) return BadRequest("Could not find Chapter"); - return Ok(); - } + var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); + if (dto == null) return BadRequest("Please perform a scan on this series or library and try again"); + var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); + var info = new ChapterInfoDto() + { + ChapterNumber = dto.ChapterNumber, + VolumeNumber = dto.VolumeNumber, + VolumeId = dto.VolumeId, + FileName = Path.GetFileName(mangaFile.FilePath), + SeriesName = dto.SeriesName, + SeriesFormat = dto.SeriesFormat, + SeriesId = dto.SeriesId, + LibraryId = dto.LibraryId, + IsSpecial = dto.IsSpecial, + Pages = dto.Pages, + ChapterTitle = dto.ChapterTitle ?? string.Empty, + Subtitle = string.Empty, + Title = dto.SeriesName + }; + + if (info.ChapterTitle is {Length: > 0}) { + info.Title += " - " + info.ChapterTitle; + } - /// - /// Marks a Series as Unread. All volumes and chapters will be marked as unread during this process. - /// - /// - /// - [HttpPost("mark-unread")] - public async Task MarkUnread(MarkReadDto markReadDto) + if (info.IsSpecial && dto.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) + { + info.Subtitle = info.FileName; + } else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId); + info.Subtitle = _readerService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; + } + else + { + info.Subtitle = "Volume " + info.VolumeNumber; + if (!info.ChapterNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) + { + info.Subtitle += " " + _readerService.FormatChapterName(info.LibraryType, true, true) + + info.ChapterNumber; + } + } - if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); + return Ok(info); + } - return Ok(); - } + /// + /// Returns various information about all bookmark files for a Series. Side effect: This will cache the bookmark images for reading. + /// + /// Series Id for all bookmarks + /// + [HttpGet("bookmark-info")] + public async Task> GetBookmarkInfo(int seriesId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var totalPages = await _cacheService.CacheBookmarkForSeries(user.Id, seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); - /// - /// Marks all chapters within a volume as unread - /// - /// - /// - [HttpPost("mark-volume-unread")] - public async Task MarkVolumeAsUnread(MarkVolumeReadDto markVolumeReadDto) + return Ok(new BookmarkInfoDto() { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + SeriesName = series.Name, + SeriesFormat = series.Format, + SeriesId = series.Id, + LibraryId = series.LibraryId, + Pages = totalPages, + }); + } - var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); - await _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); - _unitOfWork.UserRepository.Update(user); + /// + /// Marks a Series as read. All volumes and chapters will be marked as read during this process. + /// + /// + /// + [HttpPost("mark-read")] + public async Task MarkRead(MarkReadDto markReadDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); - return BadRequest("Could not save progress"); - } + return Ok(); + } - /// - /// Marks all chapters within a volume as Read - /// - /// - /// - [HttpPost("mark-volume-read")] - public async Task MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); - await _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); + /// + /// Marks a Series as Unread. All volumes and chapters will be marked as unread during this process. + /// + /// + /// + [HttpPost("mark-unread")] + public async Task MarkUnread(MarkReadDto markReadDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId); - _unitOfWork.UserRepository.Update(user); + if (!await _unitOfWork.CommitAsync()) return BadRequest("There was an issue saving progress"); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + return Ok(); + } - return BadRequest("Could not save progress"); - } + /// + /// Marks all chapters within a volume as unread + /// + /// + /// + [HttpPost("mark-volume-unread")] + public async Task MarkVolumeAsUnread(MarkVolumeReadDto markVolumeReadDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + + var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); + await _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); + _unitOfWork.UserRepository.Update(user); - /// - /// Marks all chapters within a list of volumes as Read. All volumes must belong to the same Series. - /// - /// - /// - [HttpPost("mark-multiple-read")] - public async Task MarkMultipleAsRead(MarkVolumesReadDto dto) + if (await _unitOfWork.CommitAsync()) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - user.Progresses ??= new List(); + return Ok(); + } - var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); - foreach (var chapterId in dto.ChapterIds) - { - chapterIds.Add(chapterId); - } - var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); - await _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters); + return BadRequest("Could not save progress"); + } - _unitOfWork.UserRepository.Update(user); + /// + /// Marks all chapters within a volume as Read + /// + /// + /// + [HttpPost("mark-volume-read")] + public async Task MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); + await _readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters); - return BadRequest("Could not save progress"); - } + _unitOfWork.UserRepository.Update(user); - /// - /// Marks all chapters within a list of volumes as Unread. All volumes must belong to the same Series. - /// - /// - /// - [HttpPost("mark-multiple-unread")] - public async Task MarkMultipleAsUnread(MarkVolumesReadDto dto) + if (await _unitOfWork.CommitAsync()) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - user.Progresses ??= new List(); + return Ok(); + } - var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); - foreach (var chapterId in dto.ChapterIds) - { - chapterIds.Add(chapterId); - } - var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); - await _readerService.MarkChaptersAsUnread(user, dto.SeriesId, chapters); + return BadRequest("Could not save progress"); + } - _unitOfWork.UserRepository.Update(user); - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + /// + /// Marks all chapters within a list of volumes as Read. All volumes must belong to the same Series. + /// + /// + /// + [HttpPost("mark-multiple-read")] + public async Task MarkMultipleAsRead(MarkVolumesReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); - return BadRequest("Could not save progress"); + var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); + foreach (var chapterId in dto.ChapterIds) + { + chapterIds.Add(chapterId); } + var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); + await _readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters); - /// - /// Marks all chapters within a list of series as Read. - /// - /// - /// - [HttpPost("mark-multiple-series-read")] - public async Task MarkMultipleSeriesAsRead(MarkMultipleSeriesAsReadDto dto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - user.Progresses ??= new List(); + _unitOfWork.UserRepository.Update(user); - var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); - foreach (var volume in volumes) - { - await _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); - } + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } - _unitOfWork.UserRepository.Update(user); + return BadRequest("Could not save progress"); + } - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + /// + /// Marks all chapters within a list of volumes as Unread. All volumes must belong to the same Series. + /// + /// + /// + [HttpPost("mark-multiple-unread")] + public async Task MarkMultipleAsUnread(MarkVolumesReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); - return BadRequest("Could not save progress"); + var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); + foreach (var chapterId in dto.ChapterIds) + { + chapterIds.Add(chapterId); } + var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds); + await _readerService.MarkChaptersAsUnread(user, dto.SeriesId, chapters); - /// - /// Marks all chapters within a list of series as Unread. - /// - /// - /// - [HttpPost("mark-multiple-series-unread")] - public async Task MarkMultipleSeriesAsUnread(MarkMultipleSeriesAsReadDto dto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - user.Progresses ??= new List(); + _unitOfWork.UserRepository.Update(user); - var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); - foreach (var volume in volumes) - { - await _readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters); - } + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } - _unitOfWork.UserRepository.Update(user); + return BadRequest("Could not save progress"); + } - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + /// + /// Marks all chapters within a list of series as Read. + /// + /// + /// + [HttpPost("mark-multiple-series-read")] + public async Task MarkMultipleSeriesAsRead(MarkMultipleSeriesAsReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); - return BadRequest("Could not save progress"); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); + foreach (var volume in volumes) + { + await _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); } - /// - /// Returns Progress (page number) for a chapter for the logged in user - /// - /// - /// - [HttpGet("get-progress")] - public async Task> GetProgress(int chapterId) + _unitOfWork.UserRepository.Update(user); + + if (await _unitOfWork.CommitAsync()) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var progressBookmark = new ProgressDto() - { - PageNum = 0, - ChapterId = chapterId, - VolumeId = 0, - SeriesId = 0 - }; - if (user.Progresses == null) return Ok(progressBookmark); - var progress = user.Progresses.FirstOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId); - - if (progress != null) - { - progressBookmark.SeriesId = progress.SeriesId; - progressBookmark.VolumeId = progress.VolumeId; - progressBookmark.PageNum = progress.PagesRead; - progressBookmark.BookScrollId = progress.BookScrollId; - } - return Ok(progressBookmark); + return Ok(); } - /// - /// Save page against Chapter for logged in user - /// - /// - /// - [HttpPost("progress")] - public async Task BookmarkProgress(ProgressDto progressDto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + return BadRequest("Could not save progress"); + } - if (await _readerService.SaveReadingProgress(progressDto, user.Id)) return Ok(true); + /// + /// Marks all chapters within a list of series as Unread. + /// + /// + /// + [HttpPost("mark-multiple-series-unread")] + public async Task MarkMultipleSeriesAsUnread(MarkMultipleSeriesAsReadDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); - return BadRequest("Could not save progress"); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); + foreach (var volume in volumes) + { + await _readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters); } - /// - /// Continue point is the chapter which you should start reading again from. If there is no progress on a series, then the first chapter will be returned (non-special unless only specials). - /// Otherwise, loop through the chapters and volumes in order to find the next chapter which has progress. - /// - /// - [HttpGet("continue-point")] - public async Task> GetContinuePoint(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + _unitOfWork.UserRepository.Update(user); - return Ok(await _readerService.GetContinuePoint(seriesId, userId)); + if (await _unitOfWork.CommitAsync()) + { + return Ok(); } - /// - /// Returns if the user has reading progress on the Series - /// - /// - /// - [HttpGet("has-progress")] - public async Task> HasProgress(int seriesId) + return BadRequest("Could not save progress"); + } + + /// + /// Returns Progress (page number) for a chapter for the logged in user + /// + /// + /// + [HttpGet("get-progress")] + public async Task> GetProgress(int chapterId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + var progressBookmark = new ProgressDto() + { + PageNum = 0, + ChapterId = chapterId, + VolumeId = 0, + SeriesId = 0 + }; + if (user.Progresses == null) return Ok(progressBookmark); + var progress = user.Progresses.FirstOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId); + + if (progress != null) { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId)); + progressBookmark.SeriesId = progress.SeriesId; + progressBookmark.VolumeId = progress.VolumeId; + progressBookmark.PageNum = progress.PagesRead; + progressBookmark.BookScrollId = progress.BookScrollId; } + return Ok(progressBookmark); + } - /// - /// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read. - /// - /// This is built for Tachiyomi and is not expected to be called by any other place - /// - [Obsolete("Deprecated. Use 'Tachiyomi/mark-chapter-until-as-read'")] - [HttpPost("mark-chapter-until-as-read")] - public async Task> MarkChaptersUntilAsRead(int seriesId, float chapterNumber) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - user.Progresses ??= new List(); + /// + /// Save page against Chapter for logged in user + /// + /// + /// + [HttpPost("progress")] + public async Task BookmarkProgress(ProgressDto progressDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - // Tachiyomi sends chapter 0.0f when there's no chapters read. - // Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it - if (chapterNumber == 0.0f) return true; + if (await _readerService.SaveReadingProgress(progressDto, user.Id)) return Ok(true); - if (chapterNumber < 1.0f) - { - // This is a hack to track volume number. We need to map it back by x100 - var volumeNumber = int.Parse($"{chapterNumber * 100f}"); - await _readerService.MarkVolumesUntilAsRead(user, seriesId, volumeNumber); - } - else - { - await _readerService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber); - } + return BadRequest("Could not save progress"); + } + /// + /// Continue point is the chapter which you should start reading again from. If there is no progress on a series, then the first chapter will be returned (non-special unless only specials). + /// Otherwise, loop through the chapters and volumes in order to find the next chapter which has progress. + /// + /// + [HttpGet("continue-point")] + public async Task> GetContinuePoint(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - _unitOfWork.UserRepository.Update(user); + return Ok(await _readerService.GetContinuePoint(seriesId, userId)); + } - if (!_unitOfWork.HasChanges()) return Ok(true); - if (await _unitOfWork.CommitAsync()) return Ok(true); + /// + /// Returns if the user has reading progress on the Series + /// + /// + /// + [HttpGet("has-progress")] + public async Task> HasProgress(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId)); + } - await _unitOfWork.RollbackAsync(); - return Ok(false); - } + /// + /// Marks every chapter that is sorted below the passed number as Read. This will not mark any specials as read. + /// + /// This is built for Tachiyomi and is not expected to be called by any other place + /// + [Obsolete("Deprecated. Use 'Tachiyomi/mark-chapter-until-as-read'")] + [HttpPost("mark-chapter-until-as-read")] + public async Task> MarkChaptersUntilAsRead(int seriesId, float chapterNumber) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); + user.Progresses ??= new List(); + // Tachiyomi sends chapter 0.0f when there's no chapters read. + // Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it + if (chapterNumber == 0.0f) return true; - /// - /// Returns a list of bookmarked pages for a given Chapter - /// - /// - /// - [HttpGet("get-bookmarks")] - public async Task>> GetBookmarks(int chapterId) + if (chapterNumber < 1.0f) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok(Array.Empty()); - return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId)); + // This is a hack to track volume number. We need to map it back by x100 + var volumeNumber = int.Parse($"{chapterNumber * 100f}"); + await _readerService.MarkVolumesUntilAsRead(user, seriesId, volumeNumber); } - - /// - /// Returns a list of all bookmarked pages for a User - /// - /// - [HttpGet("get-all-bookmarks")] - public async Task>> GetAllBookmarks() + else { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok(Array.Empty()); - return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id)); + await _readerService.MarkChaptersUntilAsRead(user, seriesId, chapterNumber); } - /// - /// Removes all bookmarks for all chapters linked to a Series - /// - /// - /// - [HttpPost("remove-bookmarks")] - public async Task RemoveBookmarks(RemoveBookmarkForSeriesDto dto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok("Nothing to remove"); - try - { - var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == dto.SeriesId).ToList(); - user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList(); - _unitOfWork.UserRepository.Update(user); + _unitOfWork.UserRepository.Update(user); - if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) - { - try - { - await _bookmarkService.DeleteBookmarkFiles(bookmarksToRemove); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue cleaning up old bookmarks"); - } - return Ok(); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception when trying to clear bookmarks"); - await _unitOfWork.RollbackAsync(); - } + if (!_unitOfWork.HasChanges()) return Ok(true); + if (await _unitOfWork.CommitAsync()) return Ok(true); - return BadRequest("Could not clear bookmarks"); - } + await _unitOfWork.RollbackAsync(); + return Ok(false); + } + + + /// + /// Returns a list of bookmarked pages for a given Chapter + /// + /// + /// + [HttpGet("get-bookmarks")] + public async Task>> GetBookmarks(int chapterId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok(Array.Empty()); + return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId)); + } + + /// + /// Returns a list of all bookmarked pages for a User + /// + /// + [HttpGet("get-all-bookmarks")] + public async Task>> GetAllBookmarks() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok(Array.Empty()); + return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id)); + } + + /// + /// Removes all bookmarks for all chapters linked to a Series + /// + /// + /// + [HttpPost("remove-bookmarks")] + public async Task RemoveBookmarks(RemoveBookmarkForSeriesDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok("Nothing to remove"); - /// - /// Removes all bookmarks for all chapters linked to a Series - /// - /// - /// - [HttpPost("bulk-remove-bookmarks")] - public async Task BulkRemoveBookmarks(BulkRemoveBookmarkForSeriesDto dto) + try { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok("Nothing to remove"); + var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == dto.SeriesId).ToList(); + user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList(); + _unitOfWork.UserRepository.Update(user); - try + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { - foreach (var seriesId in dto.SeriesIds) + try { - var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == seriesId).ToList(); - user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != seriesId).ToList(); - _unitOfWork.UserRepository.Update(user); await _bookmarkService.DeleteBookmarkFiles(bookmarksToRemove); } - - - if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) + catch (Exception ex) { - return Ok(); + _logger.LogError(ex, "There was an issue cleaning up old bookmarks"); } + return Ok(); } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception when trying to clear bookmarks"); - await _unitOfWork.RollbackAsync(); - } - - return BadRequest("Could not clear bookmarks"); } - - /// - /// Returns all bookmarked pages for a given volume - /// - /// - /// - [HttpGet("get-volume-bookmarks")] - public async Task>> GetBookmarksForVolume(int volumeId) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok(Array.Empty()); - return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId)); + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to clear bookmarks"); + await _unitOfWork.RollbackAsync(); } - /// - /// Returns all bookmarked pages for a given series - /// - /// - /// - [HttpGet("get-series-bookmarks")] - public async Task>> GetBookmarksForSeries(int seriesId) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user.Bookmarks == null) return Ok(Array.Empty()); + return BadRequest("Could not clear bookmarks"); + } - return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId)); - } + /// + /// Removes all bookmarks for all chapters linked to a Series + /// + /// + /// + [HttpPost("bulk-remove-bookmarks")] + public async Task BulkRemoveBookmarks(BulkRemoveBookmarkForSeriesDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok("Nothing to remove"); - /// - /// Bookmarks a page against a Chapter - /// - /// - /// - [HttpPost("bookmark")] - public async Task BookmarkPage(BookmarkDto bookmarkDto) + try { - // Don't let user save past total pages. - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user == null) return new UnauthorizedResult(); - - if (!await _accountService.HasBookmarkPermission(user)) - return BadRequest("You do not have permission to bookmark"); + foreach (var seriesId in dto.SeriesIds) + { + var bookmarksToRemove = user.Bookmarks.Where(bmk => bmk.SeriesId == seriesId).ToList(); + user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != seriesId).ToList(); + _unitOfWork.UserRepository.Update(user); + await _bookmarkService.DeleteBookmarkFiles(bookmarksToRemove); + } - bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page); - var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); - if (chapter == null) return BadRequest("Could not find cached image. Reload and try again."); - var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); - if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { - BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); return Ok(); } - - return BadRequest("Could not save bookmark"); } - - /// - /// Removes a bookmarked page for a Chapter - /// - /// - /// - [HttpPost("unbookmark")] - public async Task UnBookmarkPage(BookmarkDto bookmarkDto) + catch (Exception ex) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); - if (user == null) return new UnauthorizedResult(); - if (user.Bookmarks == null) return Ok(); + _logger.LogError(ex, "There was an exception when trying to clear bookmarks"); + await _unitOfWork.RollbackAsync(); + } - if (!await _accountService.HasBookmarkPermission(user)) - return BadRequest("You do not have permission to unbookmark"); + return BadRequest("Could not clear bookmarks"); + } - if (await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) - { - BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); - return Ok(); - } + /// + /// Returns all bookmarked pages for a given volume + /// + /// + /// + [HttpGet("get-volume-bookmarks")] + public async Task>> GetBookmarksForVolume(int volumeId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok(Array.Empty()); + return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId)); + } - return BadRequest("Could not remove bookmark"); - } + /// + /// Returns all bookmarked pages for a given series + /// + /// + /// + [HttpGet("get-series-bookmarks")] + public async Task>> GetBookmarksForSeries(int seriesId) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user.Bookmarks == null) return Ok(Array.Empty()); + + return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId)); + } + + /// + /// Bookmarks a page against a Chapter + /// + /// + /// + [HttpPost("bookmark")] + public async Task BookmarkPage(BookmarkDto bookmarkDto) + { + // Don't let user save past total pages. + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user == null) return new UnauthorizedResult(); - /// - /// Returns the next logical chapter from the series. - /// - /// - /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02 - /// - /// - /// - /// - /// chapter id for next manga - [HttpGet("next-chapter")] - public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, userId); + if (!await _accountService.HasBookmarkPermission(user)) + return BadRequest("You do not have permission to bookmark"); + + bookmarkDto.Page = await _readerService.CapPageToChapter(bookmarkDto.ChapterId, bookmarkDto.Page); + var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); + if (chapter == null) return BadRequest("Could not find cached image. Reload and try again."); + var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); + + if (await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) + { + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); + return Ok(); } + return BadRequest("Could not save bookmark"); + } - /// - /// Returns the previous logical chapter from the series. - /// - /// - /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02 - /// - /// - /// - /// - /// chapter id for next manga - [HttpGet("prev-chapter")] - public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId); + /// + /// Removes a bookmarked page for a Chapter + /// + /// + /// + [HttpPost("unbookmark")] + public async Task UnBookmarkPage(BookmarkDto bookmarkDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); + if (user == null) return new UnauthorizedResult(); + if (user.Bookmarks == null) return Ok(); + + if (!await _accountService.HasBookmarkPermission(user)) + return BadRequest("You do not have permission to unbookmark"); + + if (await _bookmarkService.RemoveBookmarkPage(user, bookmarkDto)) + { + BackgroundJob.Enqueue(() => _cacheService.CleanupBookmarkCache(bookmarkDto.SeriesId)); + return Ok(); } - /// - /// For the current user, returns an estimate on how long it would take to finish reading the series. - /// - /// For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases. - /// - /// - [HttpGet("time-left")] - public async Task> GetEstimateToCompletion(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - - // Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers - var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId); - if (series.Format == MangaFormat.Epub) - { - var chapters = - await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(progress.Select(p => p.ChapterId).ToList()); - // Word count - var progressCount = chapters.Sum(c => c.WordCount); - var wordsLeft = series.WordCount - progressCount; - return _readerService.GetTimeEstimate(wordsLeft, 0, true); - } + return BadRequest("Could not remove bookmark"); + } + + /// + /// Returns the next logical chapter from the series. + /// + /// + /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02 + /// + /// + /// + /// + /// chapter id for next manga + [HttpGet("next-chapter")] + public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return await _readerService.GetNextChapterIdAsync(seriesId, volumeId, currentChapterId, userId); + } + + + /// + /// Returns the previous logical chapter from the series. + /// + /// + /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02 + /// + /// + /// + /// + /// chapter id for next manga + [HttpGet("prev-chapter")] + public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId); + } - var progressPageCount = progress.Sum(p => p.PagesRead); - var pagesLeft = series.Pages - progressPageCount; - return _readerService.GetTimeEstimate(0, pagesLeft, false); + /// + /// For the current user, returns an estimate on how long it would take to finish reading the series. + /// + /// For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases. + /// + /// + [HttpGet("time-left")] + public async Task> GetEstimateToCompletion(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + + // Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers + var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId); + if (series.Format == MangaFormat.Epub) + { + var chapters = + await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(progress.Select(p => p.ChapterId).ToList()); + // Word count + var progressCount = chapters.Sum(c => c.WordCount); + var wordsLeft = series.WordCount - progressCount; + return _readerService.GetTimeEstimate(wordsLeft, 0, true); } + var progressPageCount = progress.Sum(p => p.PagesRead); + var pagesLeft = series.Pages - progressPageCount; + return _readerService.GetTimeEstimate(0, pagesLeft, false); } + } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 5f2b61ff0a..3ed67d84d6 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -13,483 +13,482 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace API.Controllers +namespace API.Controllers; + +[Authorize] +public class ReadingListController : BaseApiController { - [Authorize] - public class ReadingListController : BaseApiController + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private readonly IReadingListService _readingListService; + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + + public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService) { - private readonly IUnitOfWork _unitOfWork; - private readonly IEventHub _eventHub; - private readonly IReadingListService _readingListService; - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + _unitOfWork = unitOfWork; + _eventHub = eventHub; + _readingListService = readingListService; + } - public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService) - { - _unitOfWork = unitOfWork; - _eventHub = eventHub; - _readingListService = readingListService; - } + /// + /// Fetches a single Reading List + /// + /// + /// + [HttpGet] + public async Task>> GetList(int readingListId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId)); + } - /// - /// Fetches a single Reading List - /// - /// - /// - [HttpGet] - public async Task>> GetList(int readingListId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, userId)); - } + /// + /// Returns reading lists (paginated) for a given user. + /// + /// Defaults to true + /// + [HttpPost("lists")] + public async Task>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted, + userParams); + Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages); - /// - /// Returns reading lists (paginated) for a given user. - /// - /// Defaults to true - /// - [HttpPost("lists")] - public async Task>> GetListsForUser([FromQuery] UserParams userParams, [FromQuery] bool includePromoted = true) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, includePromoted, - userParams); - Response.AddPaginationHeader(items.CurrentPage, items.PageSize, items.TotalCount, items.TotalPages); + return Ok(items); + } - return Ok(items); - } + /// + /// Returns all Reading Lists the user has access to that have a series within it. + /// + /// + /// + [HttpGet("lists-for-series")] + public async Task>> GetListsForSeries(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true); - /// - /// Returns all Reading Lists the user has access to that have a series within it. - /// - /// - /// - [HttpGet("lists-for-series")] - public async Task>> GetListsForSeries(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true); + return Ok(items); + } + + /// + /// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress + /// + /// This call is expensive + /// + /// + [HttpGet("items")] + public async Task>> GetListForUser(int readingListId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); + return Ok(items); + } - return Ok(items); - } - /// - /// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress - /// - /// This call is expensive - /// - /// - [HttpGet("items")] - public async Task>> GetListForUser(int readingListId) + /// + /// Updates an items position + /// + /// + /// + [HttpPost("update-position")] + public async Task UpdateListItemPosition(UpdateReadingListPosition dto) + { + // Make sure UI buffers events + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); - return Ok(items); + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } + if (await _readingListService.UpdateReadingListItemPosition(dto)) return Ok("Updated"); - /// - /// Updates an items position - /// - /// - /// - [HttpPost("update-position")] - public async Task UpdateListItemPosition(UpdateReadingListPosition dto) - { - // Make sure UI buffers events - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } - - if (await _readingListService.UpdateReadingListItemPosition(dto)) return Ok("Updated"); + return BadRequest("Couldn't update position"); + } - return BadRequest("Couldn't update position"); + /// + /// Deletes a list item from the list. Will reorder all item positions afterwards + /// + /// + /// + [HttpPost("delete-item")] + public async Task DeleteListItem(UpdateReadingListPosition dto) + { + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - /// - /// Deletes a list item from the list. Will reorder all item positions afterwards - /// - /// - /// - [HttpPost("delete-item")] - public async Task DeleteListItem(UpdateReadingListPosition dto) + if (await _readingListService.DeleteReadingListItem(dto)) { - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } + return Ok("Updated"); + } - if (await _readingListService.DeleteReadingListItem(dto)) - { - return Ok("Updated"); - } + return BadRequest("Couldn't delete item"); + } - return BadRequest("Couldn't delete item"); + /// + /// Removes all entries that are fully read from the reading list + /// + /// + /// + [HttpPost("remove-read")] + public async Task DeleteReadFromList([FromQuery] int readingListId) + { + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - /// - /// Removes all entries that are fully read from the reading list - /// - /// - /// - [HttpPost("remove-read")] - public async Task DeleteReadFromList([FromQuery] int readingListId) + if (await _readingListService.RemoveFullyReadItems(readingListId, user)) { - var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } + return Ok("Updated"); + } - if (await _readingListService.RemoveFullyReadItems(readingListId, user)) - { - return Ok("Updated"); - } + return BadRequest("Could not remove read items"); + } - return BadRequest("Could not remove read items"); + /// + /// Deletes a reading list + /// + /// + /// + [HttpDelete] + public async Task DeleteList([FromQuery] int readingListId) + { + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - /// - /// Deletes a reading list - /// - /// - /// - [HttpDelete] - public async Task DeleteList([FromQuery] int readingListId) - { - var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } + if (await _readingListService.DeleteReadingList(readingListId, user)) return Ok("List was deleted"); + + return BadRequest("There was an issue deleting reading list"); + } - if (await _readingListService.DeleteReadingList(readingListId, user)) return Ok("List was deleted"); + /// + /// Creates a new List with a unique title. Returns the new ReadingList back + /// + /// + /// + [HttpPost("create")] + public async Task> CreateList(CreateReadingListDto dto) + { - return BadRequest("There was an issue deleting reading list"); - } + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingListsWithItems); - /// - /// Creates a new List with a unique title. Returns the new ReadingList back - /// - /// - /// - [HttpPost("create")] - public async Task> CreateList(CreateReadingListDto dto) + // When creating, we need to make sure Title is unique + var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); + if (hasExisting) { + return BadRequest("A list of this name already exists"); + } - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingListsWithItems); + var readingList = DbFactory.ReadingList(dto.Title, string.Empty, false); + user.ReadingLists.Add(readingList); - // When creating, we need to make sure Title is unique - var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); - if (hasExisting) - { - return BadRequest("A list of this name already exists"); - } + if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list"); - var readingList = DbFactory.ReadingList(dto.Title, string.Empty, false); - user.ReadingLists.Add(readingList); + await _unitOfWork.CommitAsync(); - if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list"); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(user.Id, dto.Title)); + } - await _unitOfWork.CommitAsync(); + /// + /// Update the properties (title, summary) of a reading list + /// + /// + /// + [HttpPost("update")] + public async Task UpdateList(UpdateReadingListDto dto) + { + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); + if (readingList == null) return BadRequest("List does not exist"); - return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(user.Id, dto.Title)); + var user = await _readingListService.UserHasReadingListAccess(readingList.Id, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } - /// - /// Update the properties (title, summary) of a reading list - /// - /// - /// - [HttpPost("update")] - public async Task UpdateList(UpdateReadingListDto dto) + if (!string.IsNullOrEmpty(dto.Title)) { - var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); - if (readingList == null) return BadRequest("List does not exist"); + readingList.Title = dto.Title; // Should I check if this is unique? + readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); + } + if (!string.IsNullOrEmpty(dto.Title)) + { + readingList.Summary = dto.Summary; + } - var user = await _readingListService.UserHasReadingListAccess(readingList.Id, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } + readingList.Promoted = dto.Promoted; - if (!string.IsNullOrEmpty(dto.Title)) - { - readingList.Title = dto.Title; // Should I check if this is unique? - readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); - } - if (!string.IsNullOrEmpty(dto.Title)) - { - readingList.Summary = dto.Summary; - } + readingList.CoverImageLocked = dto.CoverImageLocked; + + if (!dto.CoverImageLocked) + { + readingList.CoverImageLocked = false; + readingList.CoverImage = string.Empty; + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); + _unitOfWork.ReadingListRepository.Update(readingList); + } - readingList.Promoted = dto.Promoted; - readingList.CoverImageLocked = dto.CoverImageLocked; - if (!dto.CoverImageLocked) - { - readingList.CoverImageLocked = false; - readingList.CoverImage = string.Empty; - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); - _unitOfWork.ReadingListRepository.Update(readingList); - } + _unitOfWork.ReadingListRepository.Update(readingList); + + if (await _unitOfWork.CommitAsync()) + { + return Ok("Updated"); + } + return BadRequest("Could not update reading list"); + } + /// + /// Adds all chapters from a Series to a reading list + /// + /// + /// + [HttpPost("update-by-series")] + public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) + { + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + } + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + var chapterIdsForSeries = + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); + // If there are adds, tell tracking this has been modified + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) + { _unitOfWork.ReadingListRepository.Update(readingList); + } - if (await _unitOfWork.CommitAsync()) + try + { + if (_unitOfWork.HasChanges()) { + await _unitOfWork.CommitAsync(); return Ok("Updated"); } - return BadRequest("Could not update reading list"); } - - /// - /// Adds all chapters from a Series to a reading list - /// - /// - /// - [HttpPost("update-by-series")] - public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) + catch { - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } + await _unitOfWork.RollbackAsync(); + } - var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); - var chapterIdsForSeries = - await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); + return Ok("Nothing to do"); + } - // If there are adds, tell tracking this has been modified - if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) - { - _unitOfWork.ReadingListRepository.Update(readingList); - } - try - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - return Ok("Updated"); - } - } - catch - { - await _unitOfWork.RollbackAsync(); - } + /// + /// Adds all chapters from a list of volumes and chapters to a reading list + /// + /// + /// + [HttpPost("update-by-multiple")] + public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) + { + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + } + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); - return Ok("Nothing to do"); + var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); + foreach (var chapterId in dto.ChapterIds) + { + chapterIds.Add(chapterId); } + // If there are adds, tell tracking this has been modified + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIds, readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); + } - /// - /// Adds all chapters from a list of volumes and chapters to a reading list - /// - /// - /// - [HttpPost("update-by-multiple")] - public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) + try { - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) + if (_unitOfWork.HasChanges()) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + await _unitOfWork.CommitAsync(); + return Ok("Updated"); } - var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + } + catch + { + await _unitOfWork.RollbackAsync(); + } - var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds); - foreach (var chapterId in dto.ChapterIds) - { - chapterIds.Add(chapterId); - } + return Ok("Nothing to do"); + } + + /// + /// Adds all chapters from a list of series to a reading list + /// + /// + /// + [HttpPost("update-by-multiple-series")] + public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) + { + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + } + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); + + var ids = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray()); + foreach (var seriesId in ids.Keys) + { // If there are adds, tell tracking this has been modified - if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIds, readingList)) + if (await _readingListService.AddChaptersToReadingList(seriesId, ids[seriesId], readingList)) { _unitOfWork.ReadingListRepository.Update(readingList); } - - try - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - return Ok("Updated"); - } - } - catch - { - await _unitOfWork.RollbackAsync(); - } - - return Ok("Nothing to do"); } - /// - /// Adds all chapters from a list of series to a reading list - /// - /// - /// - [HttpPost("update-by-multiple-series")] - public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) + try { - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) + if (_unitOfWork.HasChanges()) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + await _unitOfWork.CommitAsync(); + return Ok("Updated"); } - var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); + } + catch + { + await _unitOfWork.RollbackAsync(); + } - var ids = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray()); + return Ok("Nothing to do"); + } - foreach (var seriesId in ids.Keys) - { - // If there are adds, tell tracking this has been modified - if (await _readingListService.AddChaptersToReadingList(seriesId, ids[seriesId], readingList)) - { - _unitOfWork.ReadingListRepository.Update(readingList); - } - } + [HttpPost("update-by-volume")] + public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) + { + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); + } + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); - try - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - return Ok("Updated"); - } - } - catch - { - await _unitOfWork.RollbackAsync(); - } + var chapterIdsForVolume = + (await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); - return Ok("Nothing to do"); + // If there are adds, tell tracking this has been modified + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) + { + _unitOfWork.ReadingListRepository.Update(readingList); } - [HttpPost("update-by-volume")] - public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) + try { - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) + if (_unitOfWork.HasChanges()) { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } - var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); - - var chapterIdsForVolume = - (await _unitOfWork.ChapterRepository.GetChaptersAsync(dto.VolumeId)).Select(c => c.Id).ToList(); - - // If there are adds, tell tracking this has been modified - if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForVolume, readingList)) - { - _unitOfWork.ReadingListRepository.Update(readingList); + await _unitOfWork.CommitAsync(); + return Ok("Updated"); } + } + catch + { + await _unitOfWork.RollbackAsync(); + } - try - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - return Ok("Updated"); - } - } - catch - { - await _unitOfWork.RollbackAsync(); - } + return Ok("Nothing to do"); + } - return Ok("Nothing to do"); + [HttpPost("update-by-chapter")] + public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) + { + var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); + if (user == null) + { + return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); } + var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); + if (readingList == null) return BadRequest("Reading List does not exist"); - [HttpPost("update-by-chapter")] - public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) + // If there are adds, tell tracking this has been modified + if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, new List() { dto.ChapterId }, readingList)) { - var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); - if (user == null) - { - return BadRequest("You do not have permissions on this reading list or the list doesn't exist"); - } - var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); - if (readingList == null) return BadRequest("Reading List does not exist"); - - // If there are adds, tell tracking this has been modified - if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, new List() { dto.ChapterId }, readingList)) - { - _unitOfWork.ReadingListRepository.Update(readingList); - } + _unitOfWork.ReadingListRepository.Update(readingList); + } - try - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - return Ok("Updated"); - } - } - catch + try + { + if (_unitOfWork.HasChanges()) { - await _unitOfWork.RollbackAsync(); + await _unitOfWork.CommitAsync(); + return Ok("Updated"); } - - return Ok("Nothing to do"); + } + catch + { + await _unitOfWork.RollbackAsync(); } + return Ok("Nothing to do"); + } - /// - /// Returns the next chapter within the reading list - /// - /// - /// - /// Chapter Id for next item, -1 if nothing exists - [HttpGet("next-chapter")] - public async Task> GetNextChapter(int currentChapterId, int readingListId) - { - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); - var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); - if (readingListItem == null) return BadRequest("Id does not exist"); - var index = items.IndexOf(readingListItem) + 1; - if (items.Count > index) - { - return items[index].ChapterId; - } - return Ok(-1); + /// + /// Returns the next chapter within the reading list + /// + /// + /// + /// Chapter Id for next item, -1 if nothing exists + [HttpGet("next-chapter")] + public async Task> GetNextChapter(int currentChapterId, int readingListId) + { + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); + var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); + if (readingListItem == null) return BadRequest("Id does not exist"); + var index = items.IndexOf(readingListItem) + 1; + if (items.Count > index) + { + return items[index].ChapterId; } - /// - /// Returns the prev chapter within the reading list - /// - /// - /// - /// Chapter Id for next item, -1 if nothing exists - [HttpGet("prev-chapter")] - public async Task> GetPrevChapter(int currentChapterId, int readingListId) - { - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); - var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); - if (readingListItem == null) return BadRequest("Id does not exist"); - var index = items.IndexOf(readingListItem) - 1; - if (0 <= index) - { - return items[index].ChapterId; - } + return Ok(-1); + } - return Ok(-1); + /// + /// Returns the prev chapter within the reading list + /// + /// + /// + /// Chapter Id for next item, -1 if nothing exists + [HttpGet("prev-chapter")] + public async Task> GetPrevChapter(int currentChapterId, int readingListId) + { + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(readingListId)).ToList(); + var readingListItem = items.SingleOrDefault(rl => rl.ChapterId == currentChapterId); + if (readingListItem == null) return BadRequest("Id does not exist"); + var index = items.IndexOf(readingListItem) - 1; + if (0 <= index) + { + return items[index].ChapterId; } + + return Ok(-1); } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 6f458b6b89..9be716f9c0 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -19,480 +19,479 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +public class SeriesController : BaseApiController { - public class SeriesController : BaseApiController + private readonly ILogger _logger; + private readonly ITaskScheduler _taskScheduler; + private readonly IUnitOfWork _unitOfWork; + private readonly ISeriesService _seriesService; + + + public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService) + { + _logger = logger; + _taskScheduler = taskScheduler; + _unitOfWork = unitOfWork; + _seriesService = seriesService; + } + + [HttpPost] + public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) { - private readonly ILogger _logger; - private readonly ITaskScheduler _taskScheduler; - private readonly IUnitOfWork _unitOfWork; - private readonly ISeriesService _seriesService; + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series for library"); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } - public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService) + /// + /// Fetches a Series for a given Id + /// + /// Series Id to fetch details for + /// + /// Throws an exception if the series Id does exist + [HttpGet("{seriesId:int}")] + public async Task> GetSeries(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + try { - _logger = logger; - _taskScheduler = taskScheduler; - _unitOfWork = unitOfWork; - _seriesService = seriesService; + return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId)); } - - [HttpPost] - public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) + catch (Exception e) { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); - - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series for library"); + _logger.LogError(e, "There was an issue fetching {SeriesId}", seriesId); + throw new KavitaException("This series does not exist"); + } - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + } - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("{seriesId}")] + public async Task> DeleteSeries(int seriesId) + { + var username = User.GetUsername(); + _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); - return Ok(series); - } + return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId})); + } - /// - /// Fetches a Series for a given Id - /// - /// Series Id to fetch details for - /// - /// Throws an exception if the series Id does exist - [HttpGet("{seriesId:int}")] - public async Task> GetSeries(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - try - { - return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId)); - } - catch (Exception e) - { - _logger.LogError(e, "There was an issue fetching {SeriesId}", seriesId); - throw new KavitaException("This series does not exist"); - } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("delete-multiple")] + public async Task DeleteMultipleSeries(DeleteSeriesDto dto) + { + var username = User.GetUsername(); + _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", dto.SeriesIds, username); - } + if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(); - [Authorize(Policy = "RequireAdminRole")] - [HttpDelete("{seriesId}")] - public async Task> DeleteSeries(int seriesId) - { - var username = User.GetUsername(); - _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); + return BadRequest("There was an issue deleting the series requested"); + } - return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId})); - } + /// + /// Returns All volumes for a series with progress information and Chapters + /// + /// + /// + [HttpGet("volumes")] + public async Task>> GetVolumes(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)); + } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("delete-multiple")] - public async Task DeleteMultipleSeries(DeleteSeriesDto dto) - { - var username = User.GetUsername(); - _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", dto.SeriesIds, username); + [HttpGet("volume")] + public async Task> GetVolume(int volumeId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId)); + } - if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok(); + [HttpGet("chapter")] + public async Task> GetChapter(int chapterId) + { + return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId)); + } - return BadRequest("There was an issue deleting the series requested"); - } + [HttpGet("chapter-metadata")] + public async Task> GetChapterMetadata(int chapterId) + { + return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); + } - /// - /// Returns All volumes for a series with progress information and Chapters - /// - /// - /// - [HttpGet("volumes")] - public async Task>> GetVolumes(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)); - } - [HttpGet("volume")] - public async Task> GetVolume(int volumeId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId)); - } + [HttpPost("update-rating")] + public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); + if (!await _seriesService.UpdateRating(user, updateSeriesRatingDto)) return BadRequest("There was a critical error."); + return Ok(); + } - [HttpGet("chapter")] - public async Task> GetChapter(int chapterId) - { - return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId)); - } + [HttpPost("update")] + public async Task UpdateSeries(UpdateSeriesDto updateSeries) + { + _logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name); - [HttpGet("chapter-metadata")] - public async Task> GetChapterMetadata(int chapterId) - { - return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); - } + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); + if (series == null) return BadRequest("Series does not exist"); - [HttpPost("update-rating")] - public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) + var seriesExists = + await _unitOfWork.SeriesRepository.DoesSeriesNameExistInLibrary(updateSeries.Name.Trim(), series.LibraryId, + series.Format); + if (series.Name != updateSeries.Name && seriesExists) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); - if (!await _seriesService.UpdateRating(user, updateSeriesRatingDto)) return BadRequest("There was a critical error."); - return Ok(); + return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library."); } - [HttpPost("update")] - public async Task UpdateSeries(UpdateSeriesDto updateSeries) + series.Name = updateSeries.Name.Trim(); + series.NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name); + if (!string.IsNullOrEmpty(updateSeries.SortName.Trim())) { - _logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name); + series.SortName = updateSeries.SortName.Trim(); + } - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(updateSeries.Id); + series.LocalizedName = updateSeries.LocalizedName.Trim(); + series.NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(series.LocalizedName); - if (series == null) return BadRequest("Series does not exist"); + series.NameLocked = updateSeries.NameLocked; + series.SortNameLocked = updateSeries.SortNameLocked; + series.LocalizedNameLocked = updateSeries.LocalizedNameLocked; - var seriesExists = - await _unitOfWork.SeriesRepository.DoesSeriesNameExistInLibrary(updateSeries.Name.Trim(), series.LibraryId, - series.Format); - if (series.Name != updateSeries.Name && seriesExists) - { - return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library."); - } - - series.Name = updateSeries.Name.Trim(); - series.NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name); - if (!string.IsNullOrEmpty(updateSeries.SortName.Trim())) - { - series.SortName = updateSeries.SortName.Trim(); - } - series.LocalizedName = updateSeries.LocalizedName.Trim(); - series.NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(series.LocalizedName); - - series.NameLocked = updateSeries.NameLocked; - series.SortNameLocked = updateSeries.SortNameLocked; - series.LocalizedNameLocked = updateSeries.LocalizedNameLocked; + var needsRefreshMetadata = false; + // This is when you hit Reset + if (series.CoverImageLocked && !updateSeries.CoverImageLocked) + { + // Trigger a refresh when we are moving from a locked image to a non-locked + needsRefreshMetadata = true; + series.CoverImage = string.Empty; + series.CoverImageLocked = updateSeries.CoverImageLocked; + } + _unitOfWork.SeriesRepository.Update(series); - var needsRefreshMetadata = false; - // This is when you hit Reset - if (series.CoverImageLocked && !updateSeries.CoverImageLocked) + if (await _unitOfWork.CommitAsync()) + { + if (needsRefreshMetadata) { - // Trigger a refresh when we are moving from a locked image to a non-locked - needsRefreshMetadata = true; - series.CoverImage = string.Empty; - series.CoverImageLocked = updateSeries.CoverImageLocked; + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); } + return Ok(); + } - _unitOfWork.SeriesRepository.Update(series); - - if (await _unitOfWork.CommitAsync()) - { - if (needsRefreshMetadata) - { - _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); - } - return Ok(); - } + return BadRequest("There was an error with updating the series"); + } - return BadRequest("There was an error with updating the series"); - } + [HttpPost("recently-added")] + public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); - [HttpPost("recently-added")] - public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var series = - await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series"); - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series"); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } - return Ok(series); - } + [HttpPost("recently-updated-series")] + public async Task>> GetRecentlyAddedChapters() + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId)); + } - [HttpPost("recently-updated-series")] - public async Task>> GetRecentlyAddedChapters() - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId)); - } + [HttpPost("all")] + public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); - [HttpPost("all")] - public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series"); - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series"); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } - return Ok(series); - } + /// + /// Fetches series that are on deck aka have progress on them. + /// + /// + /// + /// Default of 0 meaning all libraries + /// + [HttpPost("on-deck")] + public async Task>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); - /// - /// Fetches series that are on deck aka have progress on them. - /// - /// - /// - /// Default of 0 meaning all libraries - /// - [HttpPost("on-deck")] - public async Task>> GetOnDeck(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList); + Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); + return Ok(pagedList); + } - return Ok(pagedList); - } + /// + /// Runs a Cover Image Generation task + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("refresh-metadata")] + public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) + { + _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); + return Ok(); + } - /// - /// Runs a Cover Image Generation task - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("refresh-metadata")] - public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) - { - _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); - return Ok(); - } + /// + /// Scan a series and force each file to be updated. This should be invoked via the User, hence why we force. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("scan")] + public ActionResult ScanSeries(RefreshSeriesDto refreshSeriesDto) + { + _taskScheduler.ScanSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); + return Ok(); + } - /// - /// Scan a series and force each file to be updated. This should be invoked via the User, hence why we force. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("scan")] - public ActionResult ScanSeries(RefreshSeriesDto refreshSeriesDto) - { - _taskScheduler.ScanSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); - return Ok(); - } + /// + /// Run a file analysis on the series. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("analyze")] + public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto) + { + _taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); + return Ok(); + } - /// - /// Run a file analysis on the series. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze")] - public ActionResult AnalyzeSeries(RefreshSeriesDto refreshSeriesDto) - { - _taskScheduler.AnalyzeFilesForSeries(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); - return Ok(); - } + /// + /// Returns metadata for a given series + /// + /// + /// + [HttpGet("metadata")] + public async Task> GetSeriesMetadata(int seriesId) + { + var metadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); + return Ok(metadata); + } - /// - /// Returns metadata for a given series - /// - /// - /// - [HttpGet("metadata")] - public async Task> GetSeriesMetadata(int seriesId) + /// + /// Update series metadata + /// + /// + /// + [HttpPost("metadata")] + public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) + { + if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto)) { - var metadata = await _unitOfWork.SeriesRepository.GetSeriesMetadata(seriesId); - return Ok(metadata); + return Ok("Successfully updated"); } - /// - /// Update series metadata - /// - /// - /// - [HttpPost("metadata")] - public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto) - { - if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto)) - { - return Ok("Successfully updated"); - } + return BadRequest("Could not update metadata"); + } - return BadRequest("Could not update metadata"); - } + /// + /// Returns all Series grouped by the passed Collection Id with Pagination. + /// + /// Collection Id to pull series from + /// Pagination information + /// + [HttpGet("series-by-collection")] + public async Task>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams); - /// - /// Returns all Series grouped by the passed Collection Id with Pagination. - /// - /// Collection Id to pull series from - /// Pagination information - /// - [HttpGet("series-by-collection")] - public async Task>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, userParams); + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest("Could not get series for collection"); - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest("Could not get series for collection"); + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } - return Ok(series); - } + /// + /// Fetches Series for a set of Ids. This will check User for permission access and filter out any Ids that don't exist or + /// the user does not have access to. + /// + /// + [HttpPost("series-by-ids")] + public async Task>> GetAllSeriesById(SeriesByIdsDto dto) + { + if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds"); + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); + } - /// - /// Fetches Series for a set of Ids. This will check User for permission access and filter out any Ids that don't exist or - /// the user does not have access to. - /// - /// - [HttpPost("series-by-ids")] - public async Task>> GetAllSeriesById(SeriesByIdsDto dto) - { - if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds"); - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); - } + /// + /// Get the age rating for the enum value + /// + /// + /// + [HttpGet("age-rating")] + public ActionResult GetAgeRating(int ageRating) + { + var val = (AgeRating) ageRating; - /// - /// Get the age rating for the enum value - /// - /// - /// - [HttpGet("age-rating")] - public ActionResult GetAgeRating(int ageRating) - { - var val = (AgeRating) ageRating; + return Ok(val.ToDescription()); + } - return Ok(val.ToDescription()); - } + /// + /// Get a special DTO for Series Detail page. + /// + /// + /// + /// Do not rely on this API externally. May change without hesitation. + [HttpGet("series-detail")] + public async Task> GetSeriesDetailBreakdown(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return await _seriesService.GetSeriesDetail(seriesId, userId); + } - /// - /// Get a special DTO for Series Detail page. - /// - /// - /// - /// Do not rely on this API externally. May change without hesitation. - [HttpGet("series-detail")] - public async Task> GetSeriesDetailBreakdown(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return await _seriesService.GetSeriesDetail(seriesId, userId); - } + /// + /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-mangafile")] + public async Task> GetSeriesForMangaFile(int mangaFileId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId)); + } - /// - /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), - /// then null is returned - /// - /// - /// - [HttpGet("series-for-mangafile")] - public async Task> GetSeriesForMangaFile(int mangaFileId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesForMangaFile(mangaFileId, userId)); - } + /// + /// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI), + /// then null is returned + /// + /// + /// + [HttpGet("series-for-chapter")] + public async Task> GetSeriesForChapter(int chapterId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId)); + } - /// - /// Returns the series for the Chapter id. If the user does not have access (shouldn't happen by the UI), - /// then null is returned - /// - /// - /// - [HttpGet("series-for-chapter")] - public async Task> GetSeriesForChapter(int chapterId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, userId)); - } + /// + /// Fetches the related series for a given series + /// + /// + /// Type of Relationship to pull back + /// + [HttpGet("related")] + public async Task>> GetRelatedSeries(int seriesId, RelationKind relation) + { + // Send back a custom DTO with each type or maybe sorted in some way + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation)); + } - /// - /// Fetches the related series for a given series - /// - /// - /// Type of Relationship to pull back - /// - [HttpGet("related")] - public async Task>> GetRelatedSeries(int seriesId, RelationKind relation) - { - // Send back a custom DTO with each type or maybe sorted in some way - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetSeriesForRelationKind(userId, seriesId, relation)); - } + /// + /// Returns all related series against the passed series Id + /// + /// + /// + [HttpGet("all-related")] + public async Task> GetAllRelatedSeries(int seriesId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId)); + } - /// - /// Returns all related series against the passed series Id - /// - /// - /// - [HttpGet("all-related")] - public async Task> GetAllRelatedSeries(int seriesId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId)); - } + /// + /// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series. + /// + /// + /// + [Authorize(Policy="RequireAdminRole")] + [HttpPost("update-related")] + public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related); - /// - /// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series. - /// - /// - /// - [Authorize(Policy="RequireAdminRole")] - [HttpPost("update-related")] - public async Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto) - { - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Related); + UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation); + UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character); + UpdateRelationForKind(dto.Contains, series.Relations.Where(r => r.RelationKind == RelationKind.Contains).ToList(), series, RelationKind.Contains); + UpdateRelationForKind(dto.Others, series.Relations.Where(r => r.RelationKind == RelationKind.Other).ToList(), series, RelationKind.Other); + UpdateRelationForKind(dto.SideStories, series.Relations.Where(r => r.RelationKind == RelationKind.SideStory).ToList(), series, RelationKind.SideStory); + UpdateRelationForKind(dto.SpinOffs, series.Relations.Where(r => r.RelationKind == RelationKind.SpinOff).ToList(), series, RelationKind.SpinOff); + UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting); + UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion); + UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi); + UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel); + UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel); - UpdateRelationForKind(dto.Adaptations, series.Relations.Where(r => r.RelationKind == RelationKind.Adaptation).ToList(), series, RelationKind.Adaptation); - UpdateRelationForKind(dto.Characters, series.Relations.Where(r => r.RelationKind == RelationKind.Character).ToList(), series, RelationKind.Character); - UpdateRelationForKind(dto.Contains, series.Relations.Where(r => r.RelationKind == RelationKind.Contains).ToList(), series, RelationKind.Contains); - UpdateRelationForKind(dto.Others, series.Relations.Where(r => r.RelationKind == RelationKind.Other).ToList(), series, RelationKind.Other); - UpdateRelationForKind(dto.SideStories, series.Relations.Where(r => r.RelationKind == RelationKind.SideStory).ToList(), series, RelationKind.SideStory); - UpdateRelationForKind(dto.SpinOffs, series.Relations.Where(r => r.RelationKind == RelationKind.SpinOff).ToList(), series, RelationKind.SpinOff); - UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting); - UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion); - UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi); - UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel); - UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel); + if (!_unitOfWork.HasChanges()) return Ok(); + if (await _unitOfWork.CommitAsync()) return Ok(); - if (!_unitOfWork.HasChanges()) return Ok(); - if (await _unitOfWork.CommitAsync()) return Ok(); + return BadRequest("There was an issue updating relationships"); + } - return BadRequest("There was an issue updating relationships"); + // TODO: Move this to a Service and Unit Test it + private void UpdateRelationForKind(ICollection dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) + { + foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId))) + { + // If the seriesId isn't in dto, it means we've removed or reclassified + series.Relations.Remove(adaptation); } - // TODO: Move this to a Service and Unit Test it - private void UpdateRelationForKind(ICollection dtoTargetSeriesIds, IEnumerable adaptations, Series series, RelationKind kind) + // At this point, we only have things to add + foreach (var targetSeriesId in dtoTargetSeriesIds) { - foreach (var adaptation in adaptations.Where(adaptation => !dtoTargetSeriesIds.Contains(adaptation.TargetSeriesId))) - { - // If the seriesId isn't in dto, it means we've removed or reclassified - series.Relations.Remove(adaptation); - } + // This ensures we don't allow any duplicates to be added + if (series.Relations.SingleOrDefault(r => + r.RelationKind == kind && r.TargetSeriesId == targetSeriesId) != + null) continue; - // At this point, we only have things to add - foreach (var targetSeriesId in dtoTargetSeriesIds) + series.Relations.Add(new SeriesRelation() { - // This ensures we don't allow any duplicates to be added - if (series.Relations.SingleOrDefault(r => - r.RelationKind == kind && r.TargetSeriesId == targetSeriesId) != - null) continue; - - series.Relations.Add(new SeriesRelation() - { - Series = series, - SeriesId = series.Id, - TargetSeriesId = targetSeriesId, - RelationKind = kind - }); - _unitOfWork.SeriesRepository.Update(series); - } + Series = series, + SeriesId = series.Id, + TargetSeriesId = targetSeriesId, + RelationKind = kind + }); + _unitOfWork.SeriesRepository.Update(series); } } } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 161541a243..799f47bcac 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -8,6 +8,7 @@ using API.DTOs.Stats; using API.DTOs.Update; using API.Extensions; +using API.Logging; using API.Services; using API.Services.Tasks; using Hangfire; @@ -20,143 +21,141 @@ using Microsoft.Extensions.Logging; using TaskScheduler = System.Threading.Tasks.TaskScheduler; -namespace API.Controllers +namespace API.Controllers; + +[Authorize(Policy = "RequireAdminRole")] +public class ServerController : BaseApiController { - [Authorize(Policy = "RequireAdminRole")] - public class ServerController : BaseApiController + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly ILogger _logger; + private readonly IBackupService _backupService; + private readonly IArchiveService _archiveService; + private readonly IVersionUpdaterService _versionUpdaterService; + private readonly IStatsService _statsService; + private readonly ICleanupService _cleanupService; + private readonly IEmailService _emailService; + private readonly IBookmarkService _bookmarkService; + + public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, + IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, + ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService) { - private readonly IHostApplicationLifetime _applicationLifetime; - private readonly ILogger _logger; - private readonly IConfiguration _config; - private readonly IBackupService _backupService; - private readonly IArchiveService _archiveService; - private readonly IVersionUpdaterService _versionUpdaterService; - private readonly IStatsService _statsService; - private readonly ICleanupService _cleanupService; - private readonly IEmailService _emailService; - private readonly IBookmarkService _bookmarkService; - - public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config, - IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, - ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService) - { - _applicationLifetime = applicationLifetime; - _logger = logger; - _config = config; - _backupService = backupService; - _archiveService = archiveService; - _versionUpdaterService = versionUpdaterService; - _statsService = statsService; - _cleanupService = cleanupService; - _emailService = emailService; - _bookmarkService = bookmarkService; - } + _applicationLifetime = applicationLifetime; + _logger = logger; + _backupService = backupService; + _archiveService = archiveService; + _versionUpdaterService = versionUpdaterService; + _statsService = statsService; + _cleanupService = cleanupService; + _emailService = emailService; + _bookmarkService = bookmarkService; + } - /// - /// Attempts to Restart the server. Does not work, will shutdown the instance. - /// - /// - [HttpPost("restart")] - public ActionResult RestartServer() - { - _logger.LogInformation("{UserName} is restarting server from admin dashboard", User.GetUsername()); + /// + /// Attempts to Restart the server. Does not work, will shutdown the instance. + /// + /// + [HttpPost("restart")] + public ActionResult RestartServer() + { + _logger.LogInformation("{UserName} is restarting server from admin dashboard", User.GetUsername()); - _applicationLifetime.StopApplication(); - return Ok(); - } + _applicationLifetime.StopApplication(); + return Ok(); + } - /// - /// Performs an ad-hoc cleanup of Cache - /// - /// - [HttpPost("clear-cache")] - public ActionResult ClearCache() - { - _logger.LogInformation("{UserName} is clearing cache of server from admin dashboard", User.GetUsername()); - _cleanupService.CleanupCacheDirectory(); + /// + /// Performs an ad-hoc cleanup of Cache + /// + /// + [HttpPost("clear-cache")] + public ActionResult ClearCache() + { + _logger.LogInformation("{UserName} is clearing cache of server from admin dashboard", User.GetUsername()); + _cleanupService.CleanupCacheDirectory(); - return Ok(); - } + return Ok(); + } - /// - /// Performs an ad-hoc backup of the Database - /// - /// - [HttpPost("backup-db")] - public ActionResult BackupDatabase() - { - _logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername()); - RecurringJob.Trigger("backup"); - return Ok(); - } + /// + /// Performs an ad-hoc backup of the Database + /// + /// + [HttpPost("backup-db")] + public ActionResult BackupDatabase() + { + _logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername()); + RecurringJob.Trigger("backup"); + return Ok(); + } - /// - /// Returns non-sensitive information about the current system - /// - /// - [HttpGet("server-info")] - public async Task> GetVersion() - { - return Ok(await _statsService.GetServerInfo()); - } + /// + /// Returns non-sensitive information about the current system + /// + /// + [HttpGet("server-info")] + public async Task> GetVersion() + { + return Ok(await _statsService.GetServerInfo()); + } - /// - /// Triggers the scheduling of the convert bookmarks job. Only one job will run at a time. - /// - /// - [HttpPost("convert-bookmarks")] - public ActionResult ScheduleConvertBookmarks() - { - BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP()); - return Ok(); - } + /// + /// Triggers the scheduling of the convert bookmarks job. Only one job will run at a time. + /// + /// + [HttpPost("convert-bookmarks")] + public ActionResult ScheduleConvertBookmarks() + { + BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP()); + return Ok(); + } - [HttpGet("logs")] - public ActionResult GetLogs() + [HttpGet("logs")] + public ActionResult GetLogs() + { + var files = _backupService.GetLogFiles(); + try { - var files = _backupService.GetLogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName()); - try - { - var zipPath = _archiveService.CreateZipForDownload(files, "logs"); - return PhysicalFile(zipPath, "application/zip", Path.GetFileName(zipPath), true); - } - catch (KavitaException ex) - { - return BadRequest(ex.Message); - } + var zipPath = _archiveService.CreateZipForDownload(files, "logs"); + return PhysicalFile(zipPath, "application/zip", Path.GetFileName(zipPath), true); } - - /// - /// Checks for updates, if no updates that are > current version installed, returns null - /// - [HttpGet("check-update")] - public async Task> CheckForUpdates() + catch (KavitaException ex) { - return Ok(await _versionUpdaterService.CheckForUpdate()); + return BadRequest(ex.Message); } + } - [HttpGet("changelog")] - public async Task>> GetChangelog() - { - return Ok(await _versionUpdaterService.GetAllReleases()); - } + /// + /// Checks for updates, if no updates that are > current version installed, returns null + /// + [HttpGet("check-update")] + public async Task> CheckForUpdates() + { + return Ok(await _versionUpdaterService.CheckForUpdate()); + } - /// - /// Is this server accessible to the outside net - /// - /// - [HttpGet("accessible")] - [AllowAnonymous] - public async Task> IsServerAccessible() - { - return await _emailService.CheckIfAccessible(Request.Host.ToString()); - } + [HttpGet("changelog")] + public async Task>> GetChangelog() + { + return Ok(await _versionUpdaterService.GetAllReleases()); + } - [HttpGet("jobs")] - public ActionResult> GetJobs() - { - var recurringJobs = Hangfire.JobStorage.Current.GetConnection().GetRecurringJobs().Select( - dto => + /// + /// Is this server accessible to the outside net + /// + /// + [HttpGet("accessible")] + [AllowAnonymous] + public async Task> IsServerAccessible() + { + return await _emailService.CheckIfAccessible(Request.Host.ToString()); + } + + [HttpGet("jobs")] + public ActionResult> GetJobs() + { + var recurringJobs = JobStorage.Current.GetConnection().GetRecurringJobs().Select( + dto => new JobDto() { Id = dto.Id, Title = dto.Id.Replace('-', ' '), @@ -165,10 +164,9 @@ public ActionResult> GetJobs() LastExecution = dto.LastExecution, }); - // For now, let's just do something simple - //var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs("default", 0, int.MaxValue); - return Ok(recurringJobs); + // For now, let's just do something simple + //var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs("default", 0, int.MaxValue); + return Ok(recurringJobs); - } } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index e1a758775e..fa3b3321e7 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -9,6 +9,7 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; +using API.Logging; using API.Services; using API.Services.Tasks.Scanner; using AutoMapper; @@ -20,285 +21,284 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +public class SettingsController : BaseApiController { - public class SettingsController : BaseApiController + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly ITaskScheduler _taskScheduler; + private readonly IDirectoryService _directoryService; + private readonly IMapper _mapper; + private readonly IEmailService _emailService; + private readonly ILibraryWatcher _libraryWatcher; + + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, + IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher) { - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - private readonly ITaskScheduler _taskScheduler; - private readonly IDirectoryService _directoryService; - private readonly IMapper _mapper; - private readonly IEmailService _emailService; - private readonly ILibraryWatcher _libraryWatcher; - - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher) - { - _logger = logger; - _unitOfWork = unitOfWork; - _taskScheduler = taskScheduler; - _directoryService = directoryService; - _mapper = mapper; - _emailService = emailService; - _libraryWatcher = libraryWatcher; - } + _logger = logger; + _unitOfWork = unitOfWork; + _taskScheduler = taskScheduler; + _directoryService = directoryService; + _mapper = mapper; + _emailService = emailService; + _libraryWatcher = libraryWatcher; + } - [AllowAnonymous] - [HttpGet("base-url")] - public async Task> GetBaseUrl() - { - var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - return Ok(settingsDto.BaseUrl); - } + [AllowAnonymous] + [HttpGet("base-url")] + public async Task> GetBaseUrl() + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settingsDto.BaseUrl); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet] + public async Task> GetSettings() + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settingsDto); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset")] + public async Task> ResetSettings() + { + _logger.LogInformation("{UserName} is resetting Server Settings", User.GetUsername()); + + return await UpdateSettings(_mapper.Map(Seed.DefaultSettings)); + } - [Authorize(Policy = "RequireAdminRole")] - [HttpGet] - public async Task> GetSettings() + /// + /// Resets the email service url + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset-email-url")] + public async Task> ResetEmailServiceUrlSettings() + { + _logger.LogInformation("{UserName} is resetting Email Service Url Setting", User.GetUsername()); + var emailSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl); + emailSetting.Value = EmailService.DefaultApiUrl; + _unitOfWork.SettingsRepository.Update(emailSetting); + + if (!await _unitOfWork.CommitAsync()) { - var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - return Ok(settingsDto); + await _unitOfWork.RollbackAsync(); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("reset")] - public async Task> ResetSettings() - { - _logger.LogInformation("{UserName} is resetting Server Settings", User.GetUsername()); + return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); + } - return await UpdateSettings(_mapper.Map(Seed.DefaultSettings)); - } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("test-email-url")] + public async Task> TestEmailServiceUrl(TestEmailDto dto) + { + return Ok(await _emailService.TestConnectivity(dto.Url)); + } - /// - /// Resets the email service url - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("reset-email-url")] - public async Task> ResetEmailServiceUrlSettings() - { - _logger.LogInformation("{UserName} is resetting Email Service Url Setting", User.GetUsername()); - var emailSetting = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl); - emailSetting.Value = EmailService.DefaultApiUrl; - _unitOfWork.SettingsRepository.Update(emailSetting); - if (!await _unitOfWork.CommitAsync()) - { - await _unitOfWork.RollbackAsync(); - } - return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); + [Authorize(Policy = "RequireAdminRole")] + [HttpPost] + public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) + { + _logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername()); + + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + var updateBookmarks = false; + var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + + var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; + if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && + !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + { + bookmarkDirectory = _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("test-email-url")] - public async Task> TestEmailServiceUrl(TestEmailDto dto) + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) { - return Ok(await _emailService.TestConnectivity(dto.Url)); + bookmarkDirectory = _directoryService.BookmarkDirectory; } + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + } - [Authorize(Policy = "RequireAdminRole")] - [HttpPost] - public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) - { - _logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername()); + if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.Port + string.Empty; + // Port is managed in appSetting.json + Configuration.Port = updateSettingsDto.Port; + _unitOfWork.SettingsRepository.Update(setting); + } - // We do not allow CacheDirectory changes, so we will ignore. - var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); - var updateBookmarks = false; - var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) + { + var path = !updateSettingsDto.BaseUrl.StartsWith("/") + ? $"/{updateSettingsDto.BaseUrl}" + : updateSettingsDto.BaseUrl; + path = !path.EndsWith("/") + ? $"{path}/" + : path; + setting.Value = path; + _unitOfWork.SettingsRepository.Update(setting); + } - var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; - if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && - !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + if (setting.Key == ServerSettingKey.LoggingLevel && updateSettingsDto.LoggingLevel + string.Empty != setting.Value) { - bookmarkDirectory = _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); + setting.Value = updateSettingsDto.LoggingLevel + string.Empty; + LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); + _unitOfWork.SettingsRepository.Update(setting); } - if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + if (setting.Key == ServerSettingKey.EnableOpds && updateSettingsDto.EnableOpds + string.Empty != setting.Value) { - bookmarkDirectory = _directoryService.BookmarkDirectory; + setting.Value = updateSettingsDto.EnableOpds + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); } - foreach (var setting in currentSettings) + if (setting.Key == ServerSettingKey.ConvertBookmarkToWebP && updateSettingsDto.ConvertBookmarkToWebP + string.Empty != setting.Value) { - if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) - { - setting.Value = updateSettingsDto.TaskBackup; - _unitOfWork.SettingsRepository.Update(setting); - } + setting.Value = updateSettingsDto.ConvertBookmarkToWebP + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } - if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) - { - setting.Value = updateSettingsDto.TaskScan; - _unitOfWork.SettingsRepository.Update(setting); - } - if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // Validate new directory can be used + if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) { - setting.Value = updateSettingsDto.Port + string.Empty; - // Port is managed in appSetting.json - Configuration.Port = updateSettingsDto.Port; - _unitOfWork.SettingsRepository.Update(setting); + return BadRequest("Bookmark Directory does not have correct permissions for Kavita to use"); } - if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) - { - var path = !updateSettingsDto.BaseUrl.StartsWith("/") - ? $"/{updateSettingsDto.BaseUrl}" - : updateSettingsDto.BaseUrl; - path = !path.EndsWith("/") - ? $"{path}/" - : path; - setting.Value = path; - _unitOfWork.SettingsRepository.Update(setting); - } + originalBookmarkDirectory = setting.Value; + // Normalize the path deliminators. Just to look nice in DB, no functionality + setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); + _unitOfWork.SettingsRepository.Update(setting); + updateBookmarks = true; - if (setting.Key == ServerSettingKey.LoggingLevel && updateSettingsDto.LoggingLevel + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.LoggingLevel + string.Empty; - Configuration.LogLevel = updateSettingsDto.LoggingLevel; - _unitOfWork.SettingsRepository.Update(setting); - } + } - if (setting.Key == ServerSettingKey.EnableOpds && updateSettingsDto.EnableOpds + string.Empty != setting.Value) + if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + if (!updateSettingsDto.AllowStatCollection) { - setting.Value = updateSettingsDto.EnableOpds + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); + _taskScheduler.CancelStatsTasks(); } - - if (setting.Key == ServerSettingKey.ConvertBookmarkToWebP && updateSettingsDto.ConvertBookmarkToWebP + string.Empty != setting.Value) + else { - setting.Value = updateSettingsDto.ConvertBookmarkToWebP + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); + await _taskScheduler.ScheduleStatsTasks(); } + } + if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } - if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + if (setting.Key == ServerSettingKey.TotalBackups && updateSettingsDto.TotalBackups + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) { - // Validate new directory can be used - if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) - { - return BadRequest("Bookmark Directory does not have correct permissions for Kavita to use"); - } - - originalBookmarkDirectory = setting.Value; - // Normalize the path deliminators. Just to look nice in DB, no functionality - setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); - _unitOfWork.SettingsRepository.Update(setting); - updateBookmarks = true; - + return BadRequest("Total Backups must be between 1 and 30"); } + setting.Value = updateSettingsDto.TotalBackups + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } - if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - if (!updateSettingsDto.AllowStatCollection) - { - _taskScheduler.CancelStatsTasks(); - } - else - { - await _taskScheduler.ScheduleStatsTasks(); - } - } + if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) + { + setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; + FlurlHttp.ConfigureClient(setting.Value, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } + _unitOfWork.SettingsRepository.Update(setting); + } - if (setting.Key == ServerSettingKey.TotalBackups && updateSettingsDto.TotalBackups + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) - { - return BadRequest("Total Backups must be between 1 and 30"); - } - setting.Value = updateSettingsDto.TotalBackups + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } + if (setting.Key == ServerSettingKey.EnableFolderWatching && updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); - if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) + if (updateSettingsDto.EnableFolderWatching) { - setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; - FlurlHttp.ConfigureClient(setting.Value, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - - _unitOfWork.SettingsRepository.Update(setting); + await _libraryWatcher.StartWatching(); } - - if (setting.Key == ServerSettingKey.EnableFolderWatching && updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) + else { - setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - - if (updateSettingsDto.EnableFolderWatching) - { - await _libraryWatcher.StartWatching(); - } - else - { - _libraryWatcher.StopWatching(); - } + _libraryWatcher.StopWatching(); } } + } - if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); + if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); - try - { - await _unitOfWork.CommitAsync(); + try + { + await _unitOfWork.CommitAsync(); - if (updateBookmarks) - { - _directoryService.ExistOrCreate(bookmarkDirectory); - _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); - _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); - } - } - catch (Exception ex) + if (updateBookmarks) { - _logger.LogError(ex, "There was an exception when updating server settings"); - await _unitOfWork.RollbackAsync(); - return BadRequest("There was a critical issue. Please try again."); + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); } - - - _logger.LogInformation("Server Settings updated"); - await _taskScheduler.ScheduleTasks(); - return Ok(updateSettingsDto); } - - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("task-frequencies")] - public ActionResult> GetTaskFrequencies() + catch (Exception ex) { - return Ok(CronConverter.Options); + _logger.LogError(ex, "There was an exception when updating server settings"); + await _unitOfWork.RollbackAsync(); + return BadRequest("There was a critical issue. Please try again."); } - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("library-types")] - public ActionResult> GetLibraryTypes() - { - return Ok(Enum.GetValues().Select(t => t.ToDescription())); - } - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("log-levels")] - public ActionResult> GetLogLevels() - { - return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"}); - } + _logger.LogInformation("Server Settings updated"); + await _taskScheduler.ScheduleTasks(); + return Ok(updateSettingsDto); + } - [HttpGet("opds-enabled")] - public async Task> GetOpdsEnabled() - { - var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - return Ok(settingsDto.EnableOpds); - } + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("task-frequencies")] + public ActionResult> GetTaskFrequencies() + { + return Ok(CronConverter.Options); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("library-types")] + public ActionResult> GetLibraryTypes() + { + return Ok(Enum.GetValues().Select(t => t.ToDescription())); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("log-levels")] + public ActionResult> GetLogLevels() + { + return Ok(new [] {"Trace", "Debug", "Information", "Warning", "Critical"}); + } + + [HttpGet("opds-enabled")] + public async Task> GetOpdsEnabled() + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settingsDto.EnableOpds); } } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index c7def14083..28593c6213 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -12,298 +12,297 @@ using Microsoft.Extensions.Logging; using NetVips; -namespace API.Controllers +namespace API.Controllers; + +/// +/// +/// +[Authorize(Policy = "RequireAdminRole")] +public class UploadController : BaseApiController { + private readonly IUnitOfWork _unitOfWork; + private readonly IImageService _imageService; + private readonly ILogger _logger; + private readonly ITaskScheduler _taskScheduler; + private readonly IDirectoryService _directoryService; + private readonly IEventHub _eventHub; + + /// + public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, + ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _imageService = imageService; + _logger = logger; + _taskScheduler = taskScheduler; + _directoryService = directoryService; + _eventHub = eventHub; + } + /// - /// + /// This stores a file (image) in temp directory for use in a cover image replacement flow. + /// This is automatically cleaned up. /// + /// Escaped url to download from + /// filename [Authorize(Policy = "RequireAdminRole")] - public class UploadController : BaseApiController + [HttpPost("upload-by-url")] + public async Task> GetImageFromFile(UploadUrlDto dto) { - private readonly IUnitOfWork _unitOfWork; - private readonly IImageService _imageService; - private readonly ILogger _logger; - private readonly ITaskScheduler _taskScheduler; - private readonly IDirectoryService _directoryService; - private readonly IEventHub _eventHub; - - /// - public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, - ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub) + var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); + var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", ""); + try { - _unitOfWork = unitOfWork; - _imageService = imageService; - _logger = logger; - _taskScheduler = taskScheduler; - _directoryService = directoryService; - _eventHub = eventHub; - } + var path = await dto.Url + .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); - /// - /// This stores a file (image) in temp directory for use in a cover image replacement flow. - /// This is automatically cleaned up. - /// - /// Escaped url to download from - /// filename - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("upload-by-url")] - public async Task> GetImageFromFile(UploadUrlDto dto) - { - var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); - var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", ""); - try - { - var path = await dto.Url - .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}"); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) + return BadRequest($"Could not download file"); - if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) - return BadRequest($"Could not download file"); + if (!await _imageService.IsImage(path)) return BadRequest("Url does not return a valid image"); - if (!await _imageService.IsImage(path)) return BadRequest("Url does not return a valid image"); + return $"coverupload_{dateString}.{format}"; + } + catch (FlurlHttpException ex) + { + // Unauthorized + if (ex.StatusCode == 401) + return BadRequest("The server requires authentication to load the url externally"); + } - return $"coverupload_{dateString}.{format}"; - } - catch (FlurlHttpException ex) - { - // Unauthorized - if (ex.StatusCode == 401) - return BadRequest("The server requires authentication to load the url externally"); - } + return BadRequest("Unable to download image, please use another url or upload by file"); + } - return BadRequest("Unable to download image, please use another url or upload by file"); + /// + /// Replaces series cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("series")] + public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + return BadRequest("You must pass a url to use"); } - /// - /// Replaces series cover image and locks it with a base64 encoded image - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] - [HttpPost("series")] - public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) + try { - // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. - // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest("You must pass a url to use"); - } + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, ImageService.GetSeriesFormat(uploadFileDto.Id)); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); - try + if (!string.IsNullOrEmpty(filePath)) { - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, ImageService.GetSeriesFormat(uploadFileDto.Id)); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); - - if (!string.IsNullOrEmpty(filePath)) - { - series.CoverImage = filePath; - series.CoverImageLocked = true; - _unitOfWork.SeriesRepository.Update(series); - } - - if (_unitOfWork.HasChanges()) - { - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); - await _unitOfWork.CommitAsync(); - return Ok(); - } - + series.CoverImage = filePath; + series.CoverImageLocked = true; + _unitOfWork.SeriesRepository.Update(series); } - catch (Exception e) + + if (_unitOfWork.HasChanges()) { - _logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id); - await _unitOfWork.RollbackAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); + await _unitOfWork.CommitAsync(); + return Ok(); } - return BadRequest("Unable to save cover image to Series"); } - - /// - /// Replaces collection tag cover image and locks it with a base64 encoded image - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] - [HttpPost("collection")] - public async Task UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) + catch (Exception e) { - // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. - // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest("You must pass a url to use"); - } - - try - { - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); - - if (!string.IsNullOrEmpty(filePath)) - { - tag.CoverImage = filePath; - tag.CoverImageLocked = true; - _unitOfWork.CollectionTagRepository.Update(tag); - } - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); - return Ok(); - } + _logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } - } - catch (Exception e) - { - _logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id); - await _unitOfWork.RollbackAsync(); - } + return BadRequest("Unable to save cover image to Series"); + } - return BadRequest("Unable to save cover image to Collection Tag"); + /// + /// Replaces collection tag cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("collection")] + public async Task UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + return BadRequest("You must pass a url to use"); } - /// - /// Replaces reading list cover image and locks it with a base64 encoded image - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] - [HttpPost("reading-list")] - public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) + try { - // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. - // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest("You must pass a url to use"); - } + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); + var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); - try + if (!string.IsNullOrEmpty(filePath)) { - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); - var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); - - if (!string.IsNullOrEmpty(filePath)) - { - readingList.CoverImage = filePath; - readingList.CoverImageLocked = true; - _unitOfWork.ReadingListRepository.Update(readingList); - } - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); - return Ok(); - } - + tag.CoverImage = filePath; + tag.CoverImageLocked = true; + _unitOfWork.CollectionTagRepository.Update(tag); } - catch (Exception e) + + if (_unitOfWork.HasChanges()) { - _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id); - await _unitOfWork.RollbackAsync(); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false); + return Ok(); } - return BadRequest("Unable to save cover image to Reading List"); + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); } - /// - /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. - /// - /// - /// - [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] - [HttpPost("chapter")] - public async Task UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) + return BadRequest("Unable to save cover image to Collection Tag"); + } + + /// + /// Replaces reading list cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("reading-list")] + public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) { - // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. - // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest("You must pass a url to use"); - } + return BadRequest("You must pass a url to use"); + } - try - { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); - - if (!string.IsNullOrEmpty(filePath)) - { - chapter.CoverImage = filePath; - chapter.CoverImageLocked = true; - _unitOfWork.ChapterRepository.Update(chapter); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); - volume.CoverImage = chapter.CoverImage; - _unitOfWork.VolumeRepository.Update(volume); - } - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); - await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); - return Ok(); - } + try + { + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); + if (!string.IsNullOrEmpty(filePath)) + { + readingList.CoverImage = filePath; + readingList.CoverImageLocked = true; + _unitOfWork.ReadingListRepository.Update(readingList); } - catch (Exception e) + + if (_unitOfWork.HasChanges()) { - _logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id); - await _unitOfWork.RollbackAsync(); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false); + return Ok(); } - return BadRequest("Unable to save cover image to Chapter"); } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Unable to save cover image to Reading List"); + } - /// - /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. - /// - /// Does not use Url property - /// - [Authorize(Policy = "RequireAdminRole")] - [HttpPost("reset-chapter-lock")] - public async Task ResetChapterLock(UploadFileDto uploadFileDto) + /// + /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(8_000_000)] + [HttpPost("chapter")] + public async Task UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) { - try + return BadRequest("You must pass a url to use"); + } + + try + { + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); + + if (!string.IsNullOrEmpty(filePath)) { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); - var originalFile = chapter.CoverImage; - chapter.CoverImage = string.Empty; - chapter.CoverImageLocked = false; + chapter.CoverImage = filePath; + chapter.CoverImageLocked = true; _unitOfWork.ChapterRepository.Update(chapter); var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - System.IO.File.Delete(originalFile); - _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); - return Ok(); - } + } + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); + return Ok(); } - catch (Exception e) + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest("Unable to save cover image to Chapter"); + } + + /// + /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. + /// + /// Does not use Url property + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset-chapter-lock")] + public async Task ResetChapterLock(UploadFileDto uploadFileDto) + { + try + { + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); + var originalFile = chapter.CoverImage; + chapter.CoverImage = string.Empty; + chapter.CoverImageLocked = false; + _unitOfWork.ChapterRepository.Update(chapter); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); + volume.CoverImage = chapter.CoverImage; + _unitOfWork.VolumeRepository.Update(volume); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); + + if (_unitOfWork.HasChanges()) { - _logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id); - await _unitOfWork.RollbackAsync(); + await _unitOfWork.CommitAsync(); + System.IO.File.Delete(originalFile); + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + return Ok(); } - return BadRequest("Unable to resetting cover lock for Chapter"); + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); } + return BadRequest("Unable to resetting cover lock for Chapter"); } + } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index f74fac133a..43011af633 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -13,112 +13,111 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace API.Controllers +namespace API.Controllers; + +[Authorize] +public class UsersController : BaseApiController { - [Authorize] - public class UsersController : BaseApiController + private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly IEventHub _eventHub; + + public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub) { - private readonly IUnitOfWork _unitOfWork; - private readonly IMapper _mapper; - private readonly IEventHub _eventHub; + _unitOfWork = unitOfWork; + _mapper = mapper; + _eventHub = eventHub; + } - public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub) - { - _unitOfWork = unitOfWork; - _mapper = mapper; - _eventHub = eventHub; - } + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("delete-user")] + public async Task DeleteUser(string username) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); + _unitOfWork.UserRepository.Delete(user); - [Authorize(Policy = "RequireAdminRole")] - [HttpDelete("delete-user")] - public async Task DeleteUser(string username) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); - _unitOfWork.UserRepository.Delete(user); + if (await _unitOfWork.CommitAsync()) return Ok(); - if (await _unitOfWork.CommitAsync()) return Ok(); + return BadRequest("Could not delete the user."); + } - return BadRequest("Could not delete the user."); - } + [Authorize(Policy = "RequireAdminRole")] + [HttpGet] + public async Task>> GetUsers() + { + return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync()); + } - [Authorize(Policy = "RequireAdminRole")] - [HttpGet] - public async Task>> GetUsers() - { - return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync()); - } + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("pending")] + public async Task>> GetPendingUsers() + { + return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync()); + } - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("pending")] - public async Task>> GetPendingUsers() - { - return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync()); - } + [HttpGet("has-reading-progress")] + public async Task> HasReadingProgress(int libraryId) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId)); + } - [HttpGet("has-reading-progress")] - public async Task> HasReadingProgress(int libraryId) - { - var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); - return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId)); - } + [HttpGet("has-library-access")] + public async Task> HasLibraryAccess(int libraryId) + { + var libs = await _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(User.GetUsername()); + return Ok(libs.Any(x => x.Id == libraryId)); + } - [HttpGet("has-library-access")] - public async Task> HasLibraryAccess(int libraryId) + [HttpPost("update-preferences")] + public async Task> UpdatePreferences(UserPreferencesDto preferencesDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), + AppUserIncludes.UserPreferences); + var existingPreferences = user.UserPreferences; + + existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; + existingPreferences.ScalingOption = preferencesDto.ScalingOption; + existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; + existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; + existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; + existingPreferences.ReaderMode = preferencesDto.ReaderMode; + existingPreferences.LayoutMode = preferencesDto.LayoutMode; + existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; + existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; + existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; + existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; + existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; + existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; + existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; + preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; + existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; + existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; + existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; + existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; + existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); + existingPreferences.LayoutMode = preferencesDto.LayoutMode; + existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; + + _unitOfWork.UserRepository.Update(existingPreferences); + + if (await _unitOfWork.CommitAsync()) { - var libs = await _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(User.GetUsername()); - return Ok(libs.Any(x => x.Id == libraryId)); + await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); + return Ok(preferencesDto); } - [HttpPost("update-preferences")] - public async Task> UpdatePreferences(UserPreferencesDto preferencesDto) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), - AppUserIncludes.UserPreferences); - var existingPreferences = user.UserPreferences; - - existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; - existingPreferences.ScalingOption = preferencesDto.ScalingOption; - existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; - existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; - existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; - existingPreferences.ReaderMode = preferencesDto.ReaderMode; - existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; - existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; - existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; - existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; - existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; - existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; - preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); - existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; - existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode; - existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode; - existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; - existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; - existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); - existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; - - _unitOfWork.UserRepository.Update(existingPreferences); - - if (await _unitOfWork.CommitAsync()) - { - await _eventHub.SendMessageToAsync(MessageFactory.UserUpdate, MessageFactory.UserUpdateEvent(user.Id, user.UserName), user.Id); - return Ok(preferencesDto); - } - - return BadRequest("There was an issue saving preferences."); - } + return BadRequest("There was an issue saving preferences."); + } - [HttpGet("get-preferences")] - public async Task> GetPreferences() - { - return _mapper.Map( - await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername())); + [HttpGet("get-preferences")] + public async Task> GetPreferences() + { + return _mapper.Map( + await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername())); - } } } diff --git a/API/DTOs/Account/LoginDto.cs b/API/DTOs/Account/LoginDto.cs index a21e9868f4..44ccc5fc5a 100644 --- a/API/DTOs/Account/LoginDto.cs +++ b/API/DTOs/Account/LoginDto.cs @@ -1,8 +1,7 @@ -namespace API.DTOs.Account +namespace API.DTOs.Account; + +public class LoginDto { - public class LoginDto - { - public string Username { get; init; } - public string Password { get; set; } - } + public string Username { get; init; } + public string Password { get; set; } } diff --git a/API/DTOs/Account/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs index 563aad9f4b..9fa42d8ac1 100644 --- a/API/DTOs/Account/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -1,23 +1,22 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs.Account +namespace API.DTOs.Account; + +public class ResetPasswordDto { - public class ResetPasswordDto - { - /// - /// The Username of the User - /// - [Required] - public string UserName { get; init; } - /// - /// The new password - /// - [Required] - [StringLength(32, MinimumLength = 6)] - public string Password { get; init; } - /// - /// The old, existing password. If an admin is performing the change, this is not required. Otherwise, it is. - /// - public string OldPassword { get; init; } - } + /// + /// The Username of the User + /// + [Required] + public string UserName { get; init; } + /// + /// The new password + /// + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; init; } + /// + /// The old, existing password. If an admin is performing the change, this is not required. Otherwise, it is. + /// + public string OldPassword { get; init; } } diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index beccf26d0a..60e08b5540 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -5,89 +5,88 @@ using API.Entities.Enums; using API.Entities.Interfaces; -namespace API.DTOs +namespace API.DTOs; + +/// +/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying +/// file (abstracted from type). +/// +public class ChapterDto : IHasReadTimeEstimate { + public int Id { get; init; } + /// + /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// + public string Range { get; init; } + /// + /// Smallest number of the Range. + /// + public string Number { get; init; } + /// + /// Total number of pages in all MangaFiles + /// + public int Pages { get; init; } + /// + /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename + /// + public bool IsSpecial { get; init; } + /// + /// Used for books/specials to display custom title. For non-specials/books, will be set to + /// + public string Title { get; set; } + /// + /// The files that represent this Chapter + /// + public ICollection Files { get; init; } + /// + /// Calculated at API time. Number of pages read for this Chapter for logged in user. + /// + public int PagesRead { get; set; } + /// + /// If the Cover Image is locked for this entity + /// + public bool CoverImageLocked { get; set; } + /// + /// Volume Id this Chapter belongs to + /// + public int VolumeId { get; init; } + /// + /// When chapter was created + /// + public DateTime Created { get; init; } + /// + /// When the chapter was released. + /// + /// Metadata field + public DateTime ReleaseDate { get; init; } + /// + /// Title of the Chapter/Issue + /// + /// Metadata field + public string TitleName { get; set; } + /// + /// Summary of the Chapter + /// + /// This is not set normally, only for Series Detail + public string Summary { get; init; } + /// + /// Age Rating for the issue/chapter + /// + public AgeRating AgeRating { get; init; } /// - /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying - /// file (abstracted from type). - /// - public class ChapterDto : IHasReadTimeEstimate - { - public int Id { get; init; } - /// - /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". - /// - public string Range { get; init; } - /// - /// Smallest number of the Range. - /// - public string Number { get; init; } - /// - /// Total number of pages in all MangaFiles - /// - public int Pages { get; init; } - /// - /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename - /// - public bool IsSpecial { get; init; } - /// - /// Used for books/specials to display custom title. For non-specials/books, will be set to - /// - public string Title { get; set; } - /// - /// The files that represent this Chapter - /// - public ICollection Files { get; init; } - /// - /// Calculated at API time. Number of pages read for this Chapter for logged in user. - /// - public int PagesRead { get; set; } - /// - /// If the Cover Image is locked for this entity - /// - public bool CoverImageLocked { get; set; } - /// - /// Volume Id this Chapter belongs to - /// - public int VolumeId { get; init; } - /// - /// When chapter was created - /// - public DateTime Created { get; init; } - /// - /// When the chapter was released. - /// - /// Metadata field - public DateTime ReleaseDate { get; init; } - /// - /// Title of the Chapter/Issue - /// - /// Metadata field - public string TitleName { get; set; } - /// - /// Summary of the Chapter - /// - /// This is not set normally, only for Series Detail - public string Summary { get; init; } - /// - /// Age Rating for the issue/chapter - /// - public AgeRating AgeRating { get; init; } - /// - /// Total words in a Chapter (books only) - /// - public long WordCount { get; set; } = 0L; + /// Total words in a Chapter (books only) + /// + public long WordCount { get; set; } = 0L; - /// - /// Formatted Volume title ie) Volume 2. - /// - /// Only available when fetched from Series Detail API - public string VolumeTitle { get; set; } = string.Empty; - /// - public int MinHoursToRead { get; set; } - /// - public int MaxHoursToRead { get; set; } - /// - public int AvgHoursToRead { get; set; } - } + /// + /// Formatted Volume title ie) Volume 2. + /// + /// Only available when fetched from Series Detail API + public string VolumeTitle { get; set; } = string.Empty; + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public int AvgHoursToRead { get; set; } } diff --git a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs index ac28e81cb1..7b9ebc94d6 100644 --- a/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagBulkAddDto.cs @@ -1,18 +1,17 @@ using System.Collections.Generic; -namespace API.DTOs.CollectionTags +namespace API.DTOs.CollectionTags; + +public class CollectionTagBulkAddDto { - public class CollectionTagBulkAddDto - { - /// - /// Collection Tag Id - /// - /// Can be 0 which then will use Title to create a tag - public int CollectionTagId { get; init; } - public string CollectionTagTitle { get; init; } - /// - /// Series Ids to add onto Collection Tag - /// - public IEnumerable SeriesIds { get; init; } - } + /// + /// Collection Tag Id + /// + /// Can be 0 which then will use Title to create a tag + public int CollectionTagId { get; init; } + public string CollectionTagTitle { get; init; } + /// + /// Series Ids to add onto Collection Tag + /// + public IEnumerable SeriesIds { get; init; } } diff --git a/API/DTOs/CollectionTags/CollectionTagDto.cs b/API/DTOs/CollectionTags/CollectionTagDto.cs index 490e7d1adb..8cb68cc062 100644 --- a/API/DTOs/CollectionTags/CollectionTagDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagDto.cs @@ -1,15 +1,14 @@ -namespace API.DTOs.CollectionTags +namespace API.DTOs.CollectionTags; + +public class CollectionTagDto { - public class CollectionTagDto - { - public int Id { get; set; } - public string Title { get; set; } - public string Summary { get; set; } - public bool Promoted { get; set; } - /// - /// The cover image string. This is used on Frontend to show or hide the Cover Image - /// - public string CoverImage { get; set; } - public bool CoverImageLocked { get; set; } - } + public int Id { get; set; } + public string Title { get; set; } + public string Summary { get; set; } + public bool Promoted { get; set; } + /// + /// The cover image string. This is used on Frontend to show or hide the Cover Image + /// + public string CoverImage { get; set; } + public bool CoverImageLocked { get; set; } } diff --git a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs index 1a844ee18e..2381df285a 100644 --- a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs +++ b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; -namespace API.DTOs.CollectionTags +namespace API.DTOs.CollectionTags; + +public class UpdateSeriesForTagDto { - public class UpdateSeriesForTagDto - { - public CollectionTagDto Tag { get; init; } - public IEnumerable SeriesIdsToRemove { get; init; } - } + public CollectionTagDto Tag { get; init; } + public IEnumerable SeriesIdsToRemove { get; init; } } diff --git a/API/DTOs/CreateLibraryDto.cs b/API/DTOs/CreateLibraryDto.cs index f9aa14639d..151bcfeba6 100644 --- a/API/DTOs/CreateLibraryDto.cs +++ b/API/DTOs/CreateLibraryDto.cs @@ -2,16 +2,15 @@ using System.ComponentModel.DataAnnotations; using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs; + +public class CreateLibraryDto { - public class CreateLibraryDto - { - [Required] - public string Name { get; init; } - [Required] - public LibraryType Type { get; init; } - [Required] - [MinLength(1)] - public IEnumerable Folders { get; init; } - } -} \ No newline at end of file + [Required] + public string Name { get; init; } + [Required] + public LibraryType Type { get; init; } + [Required] + [MinLength(1)] + public IEnumerable Folders { get; init; } +} diff --git a/API/DTOs/DeleteSeriesDto.cs b/API/DTOs/DeleteSeriesDto.cs index 6908c21ac7..a363d05689 100644 --- a/API/DTOs/DeleteSeriesDto.cs +++ b/API/DTOs/DeleteSeriesDto.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -namespace API.DTOs +namespace API.DTOs; + +public class DeleteSeriesDto { - public class DeleteSeriesDto - { - public IList SeriesIds { get; set; } - } + public IList SeriesIds { get; set; } } diff --git a/API/DTOs/Downloads/DownloadBookmarkDto.cs b/API/DTOs/Downloads/DownloadBookmarkDto.cs index b1158ff23d..d70cd25ac3 100644 --- a/API/DTOs/Downloads/DownloadBookmarkDto.cs +++ b/API/DTOs/Downloads/DownloadBookmarkDto.cs @@ -2,11 +2,10 @@ using System.ComponentModel.DataAnnotations; using API.DTOs.Reader; -namespace API.DTOs.Downloads +namespace API.DTOs.Downloads; + +public class DownloadBookmarkDto { - public class DownloadBookmarkDto - { - [Required] - public IEnumerable Bookmarks { get; set; } - } + [Required] + public IEnumerable Bookmarks { get; set; } } diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index 892f9e6b96..2e09a0b725 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -3,101 +3,100 @@ using API.Entities; using API.Entities.Enums; -namespace API.DTOs.Filtering +namespace API.DTOs.Filtering; + +public class FilterDto { - public class FilterDto - { - /// - /// The type of Formats you want to be returned. An empty list will return all formats back - /// - public IList Formats { get; init; } = new List(); + /// + /// The type of Formats you want to be returned. An empty list will return all formats back + /// + public IList Formats { get; init; } = new List(); - /// - /// The progress you want to be returned. This can be bitwise manipulated. Defaults to all applicable states. - /// - public ReadStatus ReadStatus { get; init; } = new ReadStatus(); + /// + /// The progress you want to be returned. This can be bitwise manipulated. Defaults to all applicable states. + /// + public ReadStatus ReadStatus { get; init; } = new ReadStatus(); - /// - /// A list of library ids to restrict search to. Defaults to all libraries by passing empty list - /// - public IList Libraries { get; init; } = new List(); - /// - /// A list of Genre ids to restrict search to. Defaults to all genres by passing an empty list - /// - public IList Genres { get; init; } = new List(); - /// - /// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list - /// - public IList Writers { get; init; } = new List(); - /// - /// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list - /// - public IList Penciller { get; init; } = new List(); - /// - /// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list - /// - public IList Inker { get; init; } = new List(); - /// - /// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list - /// - public IList Colorist { get; init; } = new List(); - /// - /// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list - /// - public IList Letterer { get; init; } = new List(); - /// - /// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list - /// - public IList CoverArtist { get; init; } = new List(); - /// - /// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list - /// - public IList Editor { get; init; } = new List(); - /// - /// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list - /// - public IList Publisher { get; init; } = new List(); - /// - /// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list - /// - public IList Character { get; init; } = new List(); - /// - /// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list - /// - public IList Translators { get; init; } = new List(); - /// - /// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list - /// - public IList CollectionTags { get; init; } = new List(); - /// - /// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list - /// - public IList Tags { get; init; } = new List(); - /// - /// Will return back everything with the rating and above - /// - /// - public int Rating { get; init; } - /// - /// Sorting Options for a query. Defaults to null, which uses the queries natural sorting order - /// - public SortOptions SortOptions { get; set; } = null; - /// - /// Age Ratings. Empty list will return everything back - /// - public IList AgeRating { get; init; } = new List(); - /// - /// Languages (ISO 639-1 code) to filter by. Empty list will return everything back - /// - public IList Languages { get; init; } = new List(); - /// - /// Publication statuses to filter by. Empty list will return everything back - /// - public IList PublicationStatus { get; init; } = new List(); + /// + /// A list of library ids to restrict search to. Defaults to all libraries by passing empty list + /// + public IList Libraries { get; init; } = new List(); + /// + /// A list of Genre ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Genres { get; init; } = new List(); + /// + /// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list + /// + public IList Writers { get; init; } = new List(); + /// + /// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list + /// + public IList Penciller { get; init; } = new List(); + /// + /// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list + /// + public IList Inker { get; init; } = new List(); + /// + /// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list + /// + public IList Colorist { get; init; } = new List(); + /// + /// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list + /// + public IList Letterer { get; init; } = new List(); + /// + /// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list + /// + public IList CoverArtist { get; init; } = new List(); + /// + /// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list + /// + public IList Editor { get; init; } = new List(); + /// + /// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list + /// + public IList Publisher { get; init; } = new List(); + /// + /// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list + /// + public IList Character { get; init; } = new List(); + /// + /// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list + /// + public IList Translators { get; init; } = new List(); + /// + /// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list + /// + public IList CollectionTags { get; init; } = new List(); + /// + /// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list + /// + public IList Tags { get; init; } = new List(); + /// + /// Will return back everything with the rating and above + /// + /// + public int Rating { get; init; } + /// + /// Sorting Options for a query. Defaults to null, which uses the queries natural sorting order + /// + public SortOptions SortOptions { get; set; } = null; + /// + /// Age Ratings. Empty list will return everything back + /// + public IList AgeRating { get; init; } = new List(); + /// + /// Languages (ISO 639-1 code) to filter by. Empty list will return everything back + /// + public IList Languages { get; init; } = new List(); + /// + /// Publication statuses to filter by. Empty list will return everything back + /// + public IList PublicationStatus { get; init; } = new List(); - /// - /// An optional name string to filter by. Empty string will ignore. - /// - public string SeriesNameQuery { get; init; } = string.Empty; - } + /// + /// An optional name string to filter by. Empty string will ignore. + /// + public string SeriesNameQuery { get; init; } = string.Empty; } diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 9289cfa21c..4226acbd7f 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -2,17 +2,16 @@ using System.Collections.Generic; using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs; + +public class LibraryDto { - public class LibraryDto - { - public int Id { get; init; } - public string Name { get; init; } - /// - /// Last time Library was scanned - /// - public DateTime LastScanned { get; init; } - public LibraryType Type { get; init; } - public ICollection Folders { get; init; } - } + public int Id { get; init; } + public string Name { get; init; } + /// + /// Last time Library was scanned + /// + public DateTime LastScanned { get; init; } + public LibraryType Type { get; init; } + public ICollection Folders { get; init; } } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index a3e9c2713d..d20da8eb58 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -1,15 +1,14 @@ using System; using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs; + +public class MangaFileDto { - public class MangaFileDto - { - public int Id { get; init; } - public string FilePath { get; init; } - public int Pages { get; init; } - public MangaFormat Format { get; init; } - public DateTime Created { get; init; } + public int Id { get; init; } + public string FilePath { get; init; } + public int Pages { get; init; } + public MangaFormat Format { get; init; } + public DateTime Created { get; init; } - } } diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 8215cebc22..e8aa2834ce 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -1,19 +1,18 @@ using System; using System.Collections.Generic; -namespace API.DTOs +namespace API.DTOs; + +/// +/// Represents a member of a Kavita server. +/// +public class MemberDto { - /// - /// Represents a member of a Kavita server. - /// - public class MemberDto - { - public int Id { get; init; } - public string Username { get; init; } - public string Email { get; init; } - public DateTime Created { get; init; } - public DateTime LastActive { get; init; } - public IEnumerable Libraries { get; init; } - public IEnumerable Roles { get; init; } - } + public int Id { get; init; } + public string Username { get; init; } + public string Email { get; init; } + public DateTime Created { get; init; } + public DateTime LastActive { get; init; } + public IEnumerable Libraries { get; init; } + public IEnumerable Roles { get; init; } } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index 2c3add1953..cea8638d36 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,56 +1,55 @@ using System.Collections.Generic; using API.Entities.Enums; -namespace API.DTOs.Metadata +namespace API.DTOs.Metadata; + +/// +/// Exclusively metadata about a given chapter +/// +public class ChapterMetadataDto { - /// - /// Exclusively metadata about a given chapter - /// - public class ChapterMetadataDto - { - public int Id { get; set; } - public int ChapterId { get; set; } - public string Title { get; set; } - public ICollection Writers { get; set; } = new List(); - public ICollection CoverArtists { get; set; } = new List(); - public ICollection Publishers { get; set; } = new List(); - public ICollection Characters { get; set; } = new List(); - public ICollection Pencillers { get; set; } = new List(); - public ICollection Inkers { get; set; } = new List(); - public ICollection Colorists { get; set; } = new List(); - public ICollection Letterers { get; set; } = new List(); - public ICollection Editors { get; set; } = new List(); - public ICollection Translators { get; set; } = new List(); + public int Id { get; set; } + public int ChapterId { get; set; } + public string Title { get; set; } + public ICollection Writers { get; set; } = new List(); + public ICollection CoverArtists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); - public ICollection Genres { get; set; } = new List(); + public ICollection Genres { get; set; } = new List(); - /// - /// Collection of all Tags from underlying chapters for a Series - /// - public ICollection Tags { get; set; } = new List(); - public AgeRating AgeRating { get; set; } - public string ReleaseDate { get; set; } - public PublicationStatus PublicationStatus { get; set; } - /// - /// Summary for the Chapter/Issue - /// - public string Summary { get; set; } - /// - /// Language for the Chapter/Issue - /// - public string Language { get; set; } - /// - /// Number in the TotalCount of issues - /// - public int Count { get; set; } - /// - /// Total number of issues for the series - /// - public int TotalCount { get; set; } - /// - /// Number of Words for this chapter. Only applies to Epub - /// - public long WordCount { get; set; } + /// + /// Collection of all Tags from underlying chapters for a Series + /// + public ICollection Tags { get; set; } = new List(); + public AgeRating AgeRating { get; set; } + public string ReleaseDate { get; set; } + public PublicationStatus PublicationStatus { get; set; } + /// + /// Summary for the Chapter/Issue + /// + public string Summary { get; set; } + /// + /// Language for the Chapter/Issue + /// + public string Language { get; set; } + /// + /// Number in the TotalCount of issues + /// + public int Count { get; set; } + /// + /// Total number of issues for the series + /// + public int TotalCount { get; set; } + /// + /// Number of Words for this chapter. Only applies to Epub + /// + public long WordCount { get; set; } - } } diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs index e6ea03130f..21d02273dd 100644 --- a/API/DTOs/Metadata/GenreTagDto.cs +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -1,8 +1,7 @@ -namespace API.DTOs.Metadata +namespace API.DTOs.Metadata; + +public class GenreTagDto { - public class GenreTagDto - { - public int Id { get; set; } - public string Title { get; set; } - } + public int Id { get; set; } + public string Title { get; set; } } diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs index efbffe8ace..20f8897a82 100644 --- a/API/DTOs/OPDS/Feed.cs +++ b/API/DTOs/OPDS/Feed.cs @@ -2,61 +2,60 @@ using System.Collections.Generic; using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +/// +/// +/// +[XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")] +public class Feed { - /// - /// - /// - [XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")] - public class Feed - { - [XmlElement("updated")] - public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); + [XmlElement("updated")] + public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); - [XmlElement("id")] - public string Id { get; set; } + [XmlElement("id")] + public string Id { get; set; } - [XmlElement("title")] - public string Title { get; set; } + [XmlElement("title")] + public string Title { get; set; } - [XmlElement("icon")] - public string Icon { get; set; } = "/favicon.ico"; + [XmlElement("icon")] + public string Icon { get; set; } = "/favicon.ico"; - [XmlElement("author")] - public FeedAuthor Author { get; set; } = new FeedAuthor() - { - Name = "Kavita", - Uri = "https://kavitareader.com" - }; + [XmlElement("author")] + public FeedAuthor Author { get; set; } = new FeedAuthor() + { + Name = "Kavita", + Uri = "https://kavitareader.com" + }; - [XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] - public int? Total { get; set; } = null; + [XmlElement("totalResults", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] + public int? Total { get; set; } = null; - [XmlElement("itemsPerPage", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] - public int? ItemsPerPage { get; set; } = null; + [XmlElement("itemsPerPage", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] + public int? ItemsPerPage { get; set; } = null; - [XmlElement("startIndex", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] - public int? StartIndex { get; set; } = null; + [XmlElement("startIndex", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] + public int? StartIndex { get; set; } = null; - [XmlElement("link")] - public List Links { get; set; } = new List() ; + [XmlElement("link")] + public List Links { get; set; } = new List() ; - [XmlElement("entry")] - public List Entries { get; set; } = new List(); + [XmlElement("entry")] + public List Entries { get; set; } = new List(); - public bool ShouldSerializeTotal() - { - return Total.HasValue; - } + public bool ShouldSerializeTotal() + { + return Total.HasValue; + } - public bool ShouldSerializeItemsPerPage() - { - return ItemsPerPage.HasValue; - } + public bool ShouldSerializeItemsPerPage() + { + return ItemsPerPage.HasValue; + } - public bool ShouldSerializeStartIndex() - { - return StartIndex.HasValue; - } + public bool ShouldSerializeStartIndex() + { + return StartIndex.HasValue; } } diff --git a/API/DTOs/OPDS/FeedAuthor.cs b/API/DTOs/OPDS/FeedAuthor.cs index ec0446d738..1fd3e6cd2f 100644 --- a/API/DTOs/OPDS/FeedAuthor.cs +++ b/API/DTOs/OPDS/FeedAuthor.cs @@ -1,12 +1,11 @@ using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public class FeedAuthor { - public class FeedAuthor - { - [XmlElement("name")] - public string Name { get; set; } - [XmlElement("uri")] - public string Uri { get; set; } - } + [XmlElement("name")] + public string Name { get; set; } + [XmlElement("uri")] + public string Uri { get; set; } } diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/FeedEntry.cs index 9d2621dfd0..43b00e1cd7 100644 --- a/API/DTOs/OPDS/FeedEntry.cs +++ b/API/DTOs/OPDS/FeedEntry.cs @@ -2,50 +2,49 @@ using System.Collections.Generic; using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public class FeedEntry { - public class FeedEntry - { - [XmlElement("updated")] - public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); - - [XmlElement("id")] - public string Id { get; set; } - - [XmlElement("title")] - public string Title { get; set; } - - [XmlElement("summary")] - public string Summary { get; set; } - - /// - /// Represents Size of the Entry - /// Tag: , ElementName = "dcterms:extent" - /// 2 MB - /// - [XmlElement("extent", Namespace = "http://purl.org/dc/terms/")] - public string Extent { get; set; } - - /// - /// Format of the file - /// https://dublincore.org/specifications/dublin-core/dcmi-terms/ - /// - [XmlElement("format", Namespace = "http://purl.org/dc/terms/format")] - public string Format { get; set; } - - [XmlElement("language", Namespace = "http://purl.org/dc/terms/")] - public string Language { get; set; } - - [XmlElement("content")] - public FeedEntryContent Content { get; set; } - - [XmlElement("link")] - public List Links = new List(); - - // [XmlElement("author")] - // public List Authors = new List(); - - // [XmlElement("category")] - // public List Categories = new List(); - } + [XmlElement("updated")] + public string Updated { get; init; } = DateTime.UtcNow.ToString("s"); + + [XmlElement("id")] + public string Id { get; set; } + + [XmlElement("title")] + public string Title { get; set; } + + [XmlElement("summary")] + public string Summary { get; set; } + + /// + /// Represents Size of the Entry + /// Tag: , ElementName = "dcterms:extent" + /// 2 MB + /// + [XmlElement("extent", Namespace = "http://purl.org/dc/terms/")] + public string Extent { get; set; } + + /// + /// Format of the file + /// https://dublincore.org/specifications/dublin-core/dcmi-terms/ + /// + [XmlElement("format", Namespace = "http://purl.org/dc/terms/format")] + public string Format { get; set; } + + [XmlElement("language", Namespace = "http://purl.org/dc/terms/")] + public string Language { get; set; } + + [XmlElement("content")] + public FeedEntryContent Content { get; set; } + + [XmlElement("link")] + public List Links = new List(); + + // [XmlElement("author")] + // public List Authors = new List(); + + // [XmlElement("category")] + // public List Categories = new List(); } diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/FeedEntryContent.cs index d965cc3f4a..3e95ce6430 100644 --- a/API/DTOs/OPDS/FeedEntryContent.cs +++ b/API/DTOs/OPDS/FeedEntryContent.cs @@ -1,12 +1,11 @@ using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public class FeedEntryContent { - public class FeedEntryContent - { - [XmlAttribute("type")] - public string Type = "text"; - [XmlText] - public string Text; - } + [XmlAttribute("type")] + public string Type = "text"; + [XmlText] + public string Text; } diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs index 1589109ad3..b4ed730a8b 100644 --- a/API/DTOs/OPDS/FeedLink.cs +++ b/API/DTOs/OPDS/FeedLink.cs @@ -1,33 +1,32 @@ using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public class FeedLink { - public class FeedLink - { - /// - /// Relation on the Link - /// - [XmlAttribute("rel")] - public string Rel { get; set; } + /// + /// Relation on the Link + /// + [XmlAttribute("rel")] + public string Rel { get; set; } - /// - /// Should be any of the types here - /// - [XmlAttribute("type")] - public string Type { get; set; } + /// + /// Should be any of the types here + /// + [XmlAttribute("type")] + public string Type { get; set; } - [XmlAttribute("href")] - public string Href { get; set; } + [XmlAttribute("href")] + public string Href { get; set; } - [XmlAttribute("title")] - public string Title { get; set; } + [XmlAttribute("title")] + public string Title { get; set; } - [XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")] - public int TotalPages { get; set; } + [XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")] + public int TotalPages { get; set; } - public bool ShouldSerializeTotalPages() - { - return TotalPages > 0; - } + public bool ShouldSerializeTotalPages() + { + return TotalPages > 0; } } diff --git a/API/DTOs/OPDS/FeedLinkRelation.cs b/API/DTOs/OPDS/FeedLinkRelation.cs index 9702dd9433..4c9ee2c94c 100644 --- a/API/DTOs/OPDS/FeedLinkRelation.cs +++ b/API/DTOs/OPDS/FeedLinkRelation.cs @@ -1,24 +1,23 @@ -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public static class FeedLinkRelation { - public static class FeedLinkRelation - { - public const string Debug = "debug"; - public const string Search = "search"; - public const string Self = "self"; - public const string Start = "start"; - public const string Next = "next"; - public const string Prev = "prev"; - public const string Alternate = "alternate"; - public const string SubSection = "subsection"; - public const string Related = "related"; - public const string Image = "http://opds-spec.org/image"; - public const string Thumbnail = "http://opds-spec.org/image/thumbnail"; - /// - /// This will allow for a download to occur - /// - public const string Acquisition = "http://opds-spec.org/acquisition/open-access"; + public const string Debug = "debug"; + public const string Search = "search"; + public const string Self = "self"; + public const string Start = "start"; + public const string Next = "next"; + public const string Prev = "prev"; + public const string Alternate = "alternate"; + public const string SubSection = "subsection"; + public const string Related = "related"; + public const string Image = "http://opds-spec.org/image"; + public const string Thumbnail = "http://opds-spec.org/image/thumbnail"; + /// + /// This will allow for a download to occur + /// + public const string Acquisition = "http://opds-spec.org/acquisition/open-access"; #pragma warning disable S1075 - public const string Stream = "http://vaemendis.net/opds-pse/stream"; + public const string Stream = "http://vaemendis.net/opds-pse/stream"; #pragma warning restore S1075 - } } diff --git a/API/DTOs/OPDS/FeedLinkType.cs b/API/DTOs/OPDS/FeedLinkType.cs index 2119a6f80d..6ae48bd52b 100644 --- a/API/DTOs/OPDS/FeedLinkType.cs +++ b/API/DTOs/OPDS/FeedLinkType.cs @@ -1,11 +1,10 @@ -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public static class FeedLinkType { - public static class FeedLinkType - { - public const string Atom = "application/atom+xml"; - public const string AtomSearch = "application/opensearchdescription+xml"; - public const string AtomNavigation = "application/atom+xml;profile=opds-catalog;kind=navigation"; - public const string AtomAcquisition = "application/atom+xml;profile=opds-catalog;kind=acquisition"; - public const string Image = "image/jpeg"; - } + public const string Atom = "application/atom+xml"; + public const string AtomSearch = "application/opensearchdescription+xml"; + public const string AtomNavigation = "application/atom+xml;profile=opds-catalog;kind=navigation"; + public const string AtomAcquisition = "application/atom+xml;profile=opds-catalog;kind=acquisition"; + public const string Image = "image/jpeg"; } diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/OpenSearchDescription.cs index 94eba555cc..6ee043ac46 100644 --- a/API/DTOs/OPDS/OpenSearchDescription.cs +++ b/API/DTOs/OPDS/OpenSearchDescription.cs @@ -1,42 +1,41 @@ using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +[XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] +public class OpenSearchDescription { - [XmlRoot("OpenSearchDescription", Namespace = "http://a9.com/-/spec/opensearch/1.1/")] - public class OpenSearchDescription - { - /// - /// Contains a brief human-readable title that identifies this search engine. - /// - public string ShortName { get; set; } - /// - /// Contains an extended human-readable title that identifies this search engine. - /// - public string LongName { get; set; } - /// - /// Contains a human-readable text description of the search engine. - /// - public string Description { get; set; } - /// - /// https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-url-element - /// - public SearchLink Url { get; set; } - /// - /// Contains a set of words that are used as keywords to identify and categorize this search content. - /// Tags must be a single word and are delimited by the space character (' '). - /// - public string Tags { get; set; } - /// - /// Contains a URL that identifies the location of an image that can be used in association with this search content. - /// http://example.com/websearch.png - /// - public string Image { get; set; } - public string InputEncoding { get; set; } = "UTF-8"; - public string OutputEncoding { get; set; } = "UTF-8"; - /// - /// Contains the human-readable name or identifier of the creator or maintainer of the description document. - /// - public string Developer { get; set; } = "kavitareader.com"; + /// + /// Contains a brief human-readable title that identifies this search engine. + /// + public string ShortName { get; set; } + /// + /// Contains an extended human-readable title that identifies this search engine. + /// + public string LongName { get; set; } + /// + /// Contains a human-readable text description of the search engine. + /// + public string Description { get; set; } + /// + /// https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-url-element + /// + public SearchLink Url { get; set; } + /// + /// Contains a set of words that are used as keywords to identify and categorize this search content. + /// Tags must be a single word and are delimited by the space character (' '). + /// + public string Tags { get; set; } + /// + /// Contains a URL that identifies the location of an image that can be used in association with this search content. + /// http://example.com/websearch.png + /// + public string Image { get; set; } + public string InputEncoding { get; set; } = "UTF-8"; + public string OutputEncoding { get; set; } = "UTF-8"; + /// + /// Contains the human-readable name or identifier of the creator or maintainer of the description document. + /// + public string Developer { get; set; } = "kavitareader.com"; - } } diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/SearchLink.cs index db5a20f232..6aeca506a8 100644 --- a/API/DTOs/OPDS/SearchLink.cs +++ b/API/DTOs/OPDS/SearchLink.cs @@ -1,16 +1,15 @@ using System.Xml.Serialization; -namespace API.DTOs.OPDS +namespace API.DTOs.OPDS; + +public class SearchLink { - public class SearchLink - { - [XmlAttribute("type")] - public string Type { get; set; } + [XmlAttribute("type")] + public string Type { get; set; } - [XmlAttribute("rel")] - public string Rel { get; set; } = "results"; + [XmlAttribute("rel")] + public string Rel { get; set; } = "results"; - [XmlAttribute("template")] - public string Template { get; set; } - } + [XmlAttribute("template")] + public string Template { get; set; } } diff --git a/API/DTOs/PersonDto.cs b/API/DTOs/PersonDto.cs index 0ab7a4076f..92bd819243 100644 --- a/API/DTOs/PersonDto.cs +++ b/API/DTOs/PersonDto.cs @@ -1,11 +1,10 @@ using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs; + +public class PersonDto { - public class PersonDto - { - public int Id { get; set; } - public string Name { get; set; } - public PersonRole Role { get; set; } - } + public int Id { get; set; } + public string Name { get; set; } + public PersonRole Role { get; set; } } diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/ProgressDto.cs index 021a5f2430..1bab779cb3 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/ProgressDto.cs @@ -1,21 +1,20 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs +namespace API.DTOs; + +public class ProgressDto { - public class ProgressDto - { - [Required] - public int VolumeId { get; set; } - [Required] - public int ChapterId { get; set; } - [Required] - public int PageNum { get; set; } - [Required] - public int SeriesId { get; set; } - /// - /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position - /// on pages that combine multiple "chapters". - /// - public string BookScrollId { get; set; } - } + [Required] + public int VolumeId { get; set; } + [Required] + public int ChapterId { get; set; } + [Required] + public int PageNum { get; set; } + [Required] + public int SeriesId { get; set; } + /// + /// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position + /// on pages that combine multiple "chapters". + /// + public string BookScrollId { get; set; } } diff --git a/API/DTOs/Reader/BookChapterItem.cs b/API/DTOs/Reader/BookChapterItem.cs index 9db676cc5e..3dabbd1ec9 100644 --- a/API/DTOs/Reader/BookChapterItem.cs +++ b/API/DTOs/Reader/BookChapterItem.cs @@ -1,21 +1,20 @@ using System.Collections.Generic; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class BookChapterItem { - public class BookChapterItem - { - /// - /// Name of the Chapter - /// - public string Title { get; set; } - /// - /// A part represents the id of the anchor so we can scroll to it. 01_values.xhtml#h_sVZPaxUSy/ - /// - public string Part { get; set; } - /// - /// Page Number to load for the chapter - /// - public int Page { get; set; } - public ICollection Children { get; set; } - } + /// + /// Name of the Chapter + /// + public string Title { get; set; } + /// + /// A part represents the id of the anchor so we can scroll to it. 01_values.xhtml#h_sVZPaxUSy/ + /// + public string Part { get; set; } + /// + /// Page Number to load for the chapter + /// + public int Page { get; set; } + public ICollection Children { get; set; } } diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index b881c1b100..78cfc39b0c 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -1,19 +1,18 @@ using API.Entities.Enums; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class BookInfoDto : IChapterInfoDto { - public class BookInfoDto : IChapterInfoDto - { - public string BookTitle { get; set; } - public int SeriesId { get; set; } - public int VolumeId { get; set; } - public MangaFormat SeriesFormat { get; set; } - public string SeriesName { get; set; } - public string ChapterNumber { get; set; } - public string VolumeNumber { get; set; } - public int LibraryId { get; set; } - public int Pages { get; set; } - public bool IsSpecial { get; set; } - public string ChapterTitle { get; set; } - } + public string BookTitle { get; set; } + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public MangaFormat SeriesFormat { get; set; } + public string SeriesName { get; set; } + public string ChapterNumber { get; set; } + public string VolumeNumber { get; set; } + public int LibraryId { get; set; } + public int Pages { get; set; } + public bool IsSpecial { get; set; } + public string ChapterTitle { get; set; } } diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index 33f55cf8da..b132eb9585 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -1,17 +1,16 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class BookmarkDto { - public class BookmarkDto - { - public int Id { get; set; } - [Required] - public int Page { get; set; } - [Required] - public int VolumeId { get; set; } - [Required] - public int SeriesId { get; set; } - [Required] - public int ChapterId { get; set; } - } + public int Id { get; set; } + [Required] + public int Page { get; set; } + [Required] + public int VolumeId { get; set; } + [Required] + public int SeriesId { get; set; } + [Required] + public int ChapterId { get; set; } } diff --git a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs index 2408154b8a..9cd22f958b 100644 --- a/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/BulkRemoveBookmarkForSeriesDto.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class BulkRemoveBookmarkForSeriesDto { - public class BulkRemoveBookmarkForSeriesDto - { - public ICollection SeriesIds { get; init; } - } + public ICollection SeriesIds { get; init; } } diff --git a/API/DTOs/Reader/IChapterInfoDto.cs b/API/DTOs/Reader/IChapterInfoDto.cs index e448e5e13e..6a9a74a2c3 100644 --- a/API/DTOs/Reader/IChapterInfoDto.cs +++ b/API/DTOs/Reader/IChapterInfoDto.cs @@ -1,19 +1,18 @@ using API.Entities.Enums; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public interface IChapterInfoDto { - public interface IChapterInfoDto - { - public int SeriesId { get; set; } - public int VolumeId { get; set; } - public MangaFormat SeriesFormat { get; set; } - public string SeriesName { get; set; } - public string ChapterNumber { get; set; } - public string VolumeNumber { get; set; } - public int LibraryId { get; set; } - public int Pages { get; set; } - public bool IsSpecial { get; set; } - public string ChapterTitle { get; set; } + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public MangaFormat SeriesFormat { get; set; } + public string SeriesName { get; set; } + public string ChapterNumber { get; set; } + public string VolumeNumber { get; set; } + public int LibraryId { get; set; } + public int Pages { get; set; } + public bool IsSpecial { get; set; } + public string ChapterTitle { get; set; } - } } diff --git a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs index 7201658fa8..da36e44f52 100644 --- a/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs +++ b/API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class MarkMultipleSeriesAsReadDto { - public class MarkMultipleSeriesAsReadDto - { - public IReadOnlyList SeriesIds { get; init; } - } + public IReadOnlyList SeriesIds { get; init; } } diff --git a/API/DTOs/Reader/MarkReadDto.cs b/API/DTOs/Reader/MarkReadDto.cs index 3d94e3a9d7..9bf46a6d5e 100644 --- a/API/DTOs/Reader/MarkReadDto.cs +++ b/API/DTOs/Reader/MarkReadDto.cs @@ -1,7 +1,6 @@ -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class MarkReadDto { - public class MarkReadDto - { - public int SeriesId { get; init; } - } + public int SeriesId { get; init; } } diff --git a/API/DTOs/Reader/MarkVolumeReadDto.cs b/API/DTOs/Reader/MarkVolumeReadDto.cs index 757f23aee2..47ffd26491 100644 --- a/API/DTOs/Reader/MarkVolumeReadDto.cs +++ b/API/DTOs/Reader/MarkVolumeReadDto.cs @@ -1,8 +1,7 @@ -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class MarkVolumeReadDto { - public class MarkVolumeReadDto - { - public int SeriesId { get; init; } - public int VolumeId { get; init; } - } + public int SeriesId { get; init; } + public int VolumeId { get; init; } } diff --git a/API/DTOs/Reader/MarkVolumesReadDto.cs b/API/DTOs/Reader/MarkVolumesReadDto.cs index 7e23e721a3..9f02af5241 100644 --- a/API/DTOs/Reader/MarkVolumesReadDto.cs +++ b/API/DTOs/Reader/MarkVolumesReadDto.cs @@ -1,20 +1,19 @@ using System.Collections.Generic; -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +/// +/// This is used for bulk updating a set of volume and or chapters in one go +/// +public class MarkVolumesReadDto { + public int SeriesId { get; set; } + /// + /// A list of Volumes to mark read + /// + public IReadOnlyList VolumeIds { get; set; } /// - /// This is used for bulk updating a set of volume and or chapters in one go + /// A list of additional Chapters to mark as read /// - public class MarkVolumesReadDto - { - public int SeriesId { get; set; } - /// - /// A list of Volumes to mark read - /// - public IReadOnlyList VolumeIds { get; set; } - /// - /// A list of additional Chapters to mark as read - /// - public IReadOnlyList ChapterIds { get; set; } - } + public IReadOnlyList ChapterIds { get; set; } } diff --git a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs index a269b7095d..ed6368a4f5 100644 --- a/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs +++ b/API/DTOs/Reader/RemoveBookmarkForSeriesDto.cs @@ -1,7 +1,6 @@ -namespace API.DTOs.Reader +namespace API.DTOs.Reader; + +public class RemoveBookmarkForSeriesDto { - public class RemoveBookmarkForSeriesDto - { - public int SeriesId { get; init; } - } + public int SeriesId { get; init; } } diff --git a/API/DTOs/ReadingLists/CreateReadingListDto.cs b/API/DTOs/ReadingLists/CreateReadingListDto.cs index c32b62bea7..396c05e7c9 100644 --- a/API/DTOs/ReadingLists/CreateReadingListDto.cs +++ b/API/DTOs/ReadingLists/CreateReadingListDto.cs @@ -1,7 +1,6 @@ -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class CreateReadingListDto { - public class CreateReadingListDto - { - public string Title { get; init; } - } + public string Title { get; init; } } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index ba446d17a8..de212217eb 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,18 +1,17 @@ -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class ReadingListDto { - public class ReadingListDto - { - public int Id { get; init; } - public string Title { get; set; } - public string Summary { get; set; } - /// - /// Reading lists that are promoted are only done by admins - /// - public bool Promoted { get; set; } - public bool CoverImageLocked { get; set; } - /// - /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. - /// - public string CoverImage { get; set; } = string.Empty; - } + public int Id { get; init; } + public string Title { get; set; } + public string Summary { get; set; } + /// + /// Reading lists that are promoted are only done by admins + /// + public bool Promoted { get; set; } + public bool CoverImageLocked { get; set; } + /// + /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. + /// + public string CoverImage { get; set; } = string.Empty; } diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index b58fdcf80f..39f844d8b6 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -1,25 +1,24 @@ using API.Entities.Enums; -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class ReadingListItemDto { - public class ReadingListItemDto - { - public int Id { get; init; } - public int Order { get; init; } - public int ChapterId { get; init; } - public int SeriesId { get; init; } - public string SeriesName { get; set; } - public MangaFormat SeriesFormat { get; set; } - public int PagesRead { get; set; } - public int PagesTotal { get; set; } - public string ChapterNumber { get; set; } - public string VolumeNumber { get; set; } - public int VolumeId { get; set; } - public int LibraryId { get; set; } - public string Title { get; set; } - /// - /// Used internally only - /// - public int ReadingListId { get; set; } - } + public int Id { get; init; } + public int Order { get; init; } + public int ChapterId { get; init; } + public int SeriesId { get; init; } + public string SeriesName { get; set; } + public MangaFormat SeriesFormat { get; set; } + public int PagesRead { get; set; } + public int PagesTotal { get; set; } + public string ChapterNumber { get; set; } + public string VolumeNumber { get; set; } + public int VolumeId { get; set; } + public int LibraryId { get; set; } + public string Title { get; set; } + /// + /// Used internally only + /// + public int ReadingListId { get; set; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs index 887850755b..985f86ac08 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByChapterDto.cs @@ -1,9 +1,8 @@ -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class UpdateReadingListByChapterDto { - public class UpdateReadingListByChapterDto - { - public int ChapterId { get; init; } - public int SeriesId { get; init; } - public int ReadingListId { get; init; } - } + public int ChapterId { get; init; } + public int SeriesId { get; init; } + public int ReadingListId { get; init; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs index 02a41a7675..0d4bfb0dde 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs @@ -1,12 +1,11 @@ using System.Collections.Generic; -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class UpdateReadingListByMultipleDto { - public class UpdateReadingListByMultipleDto - { - public int SeriesId { get; init; } - public int ReadingListId { get; init; } - public IReadOnlyList VolumeIds { get; init; } - public IReadOnlyList ChapterIds { get; init; } - } + public int SeriesId { get; init; } + public int ReadingListId { get; init; } + public IReadOnlyList VolumeIds { get; init; } + public IReadOnlyList ChapterIds { get; init; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs index 4b08f95bcf..944d4ff780 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByMultipleSeriesDto.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class UpdateReadingListByMultipleSeriesDto { - public class UpdateReadingListByMultipleSeriesDto - { - public int ReadingListId { get; init; } - public IReadOnlyList SeriesIds { get; init; } - } + public int ReadingListId { get; init; } + public IReadOnlyList SeriesIds { get; init; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs index 1040a92186..0590882bd5 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListBySeriesDto.cs @@ -1,8 +1,7 @@ -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class UpdateReadingListBySeriesDto { - public class UpdateReadingListBySeriesDto - { - public int SeriesId { get; init; } - public int ReadingListId { get; init; } - } + public int SeriesId { get; init; } + public int ReadingListId { get; init; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs index 0d903d48e7..f77c7d63a2 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListByVolumeDto.cs @@ -1,9 +1,8 @@ -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class UpdateReadingListByVolumeDto { - public class UpdateReadingListByVolumeDto - { - public int VolumeId { get; init; } - public int SeriesId { get; init; } - public int ReadingListId { get; init; } - } + public int VolumeId { get; init; } + public int SeriesId { get; init; } + public int ReadingListId { get; init; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListDto.cs b/API/DTOs/ReadingLists/UpdateReadingListDto.cs index 5b8f69731c..b61ab2a727 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListDto.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListDto.cs @@ -1,11 +1,10 @@ -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +public class UpdateReadingListDto { - public class UpdateReadingListDto - { - public int ReadingListId { get; set; } - public string Title { get; set; } - public string Summary { get; set; } - public bool Promoted { get; set; } - public bool CoverImageLocked { get; set; } - } + public int ReadingListId { get; set; } + public string Title { get; set; } + public string Summary { get; set; } + public bool Promoted { get; set; } + public bool CoverImageLocked { get; set; } } diff --git a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs index 5407a1ad5c..3d04871448 100644 --- a/API/DTOs/ReadingLists/UpdateReadingListPosition.cs +++ b/API/DTOs/ReadingLists/UpdateReadingListPosition.cs @@ -1,18 +1,14 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs.ReadingLists +namespace API.DTOs.ReadingLists; + +/// +/// DTO for moving a reading list item to another position within the same list +/// +public class UpdateReadingListPosition { - /// - /// DTO for moving a reading list item to another position within the same list - /// - public class UpdateReadingListPosition - { - [Required] - public int ReadingListId { get; set; } - [Required] - public int ReadingListItemId { get; set; } - public int FromPosition { get; set; } - [Required] - public int ToPosition { get; set; } - } + [Required] public int ReadingListId { get; set; } + [Required] public int ReadingListItemId { get; set; } + public int FromPosition { get; set; } + [Required] public int ToPosition { get; set; } } diff --git a/API/DTOs/RefreshSeriesDto.cs b/API/DTOs/RefreshSeriesDto.cs index db1264bd3a..64a6843946 100644 --- a/API/DTOs/RefreshSeriesDto.cs +++ b/API/DTOs/RefreshSeriesDto.cs @@ -1,22 +1,21 @@ -namespace API.DTOs +namespace API.DTOs; + +/// +/// Used for running some task against a Series. +/// +public class RefreshSeriesDto { /// - /// Used for running some task against a Series. + /// Library Id series belongs to /// - public class RefreshSeriesDto - { - /// - /// Library Id series belongs to - /// - public int LibraryId { get; init; } - /// - /// Series Id - /// - public int SeriesId { get; init; } - /// - /// Should the task force opening/re-calculation. - /// - /// This is expensive if true. Defaults to true. - public bool ForceUpdate { get; init; } = true; - } + public int LibraryId { get; init; } + /// + /// Series Id + /// + public int SeriesId { get; init; } + /// + /// Should the task force opening/re-calculation. + /// + /// This is expensive if true. Defaults to true. + public bool ForceUpdate { get; init; } = true; } diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 95814b88fd..95fdc70c1c 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -1,15 +1,14 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs +namespace API.DTOs; + +public class RegisterDto { - public class RegisterDto - { - [Required] - public string Username { get; init; } - [Required] - public string Email { get; init; } - [Required] - [StringLength(32, MinimumLength = 6)] - public string Password { get; set; } - } + [Required] + public string Username { get; init; } + [Required] + public string Email { get; init; } + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; set; } } diff --git a/API/DTOs/Search/SearchResultDto.cs b/API/DTOs/Search/SearchResultDto.cs index 328ff7a1fc..4d9e300a5a 100644 --- a/API/DTOs/Search/SearchResultDto.cs +++ b/API/DTOs/Search/SearchResultDto.cs @@ -1,18 +1,17 @@ using API.Entities.Enums; -namespace API.DTOs.Search +namespace API.DTOs.Search; + +public class SearchResultDto { - public class SearchResultDto - { - public int SeriesId { get; init; } - public string Name { get; init; } - public string OriginalName { get; init; } - public string SortName { get; init; } - public string LocalizedName { get; init; } - public MangaFormat Format { get; init; } + public int SeriesId { get; init; } + public string Name { get; init; } + public string OriginalName { get; init; } + public string SortName { get; init; } + public string LocalizedName { get; init; } + public MangaFormat Format { get; init; } - // Grouping information - public string LibraryName { get; set; } - public int LibraryId { get; set; } - } + // Grouping information + public string LibraryName { get; set; } + public int LibraryId { get; set; } } diff --git a/API/DTOs/SeriesByIdsDto.cs b/API/DTOs/SeriesByIdsDto.cs index 0ffdd8cba8..29c0281560 100644 --- a/API/DTOs/SeriesByIdsDto.cs +++ b/API/DTOs/SeriesByIdsDto.cs @@ -1,7 +1,6 @@ -namespace API.DTOs +namespace API.DTOs; + +public class SeriesByIdsDto { - public class SeriesByIdsDto - { - public int[] SeriesIds { get; init; } - } + public int[] SeriesIds { get; init; } } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index bbf65e9fbd..b1b5a9f355 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -2,65 +2,64 @@ using API.Entities.Enums; using API.Entities.Interfaces; -namespace API.DTOs +namespace API.DTOs; + +public class SeriesDto : IHasReadTimeEstimate { - public class SeriesDto : IHasReadTimeEstimate - { - public int Id { get; init; } - public string Name { get; init; } - public string OriginalName { get; init; } - public string LocalizedName { get; init; } - public string SortName { get; init; } - public string Summary { get; init; } - public int Pages { get; init; } - public bool CoverImageLocked { get; set; } - /// - /// Sum of pages read from linked Volumes. Calculated at API-time. - /// - public int PagesRead { get; set; } - /// - /// DateTime representing last time the series was Read. Calculated at API-time. - /// - public DateTime LatestReadDate { get; set; } - /// - /// DateTime representing last time a chapter was added to the Series - /// - public DateTime LastChapterAdded { get; set; } - /// - /// Rating from logged in user. Calculated at API-time. - /// - public int UserRating { get; set; } - /// - /// Review from logged in user. Calculated at API-time. - /// - public string UserReview { get; set; } - public MangaFormat Format { get; set; } + public int Id { get; init; } + public string Name { get; init; } + public string OriginalName { get; init; } + public string LocalizedName { get; init; } + public string SortName { get; init; } + public string Summary { get; init; } + public int Pages { get; init; } + public bool CoverImageLocked { get; set; } + /// + /// Sum of pages read from linked Volumes. Calculated at API-time. + /// + public int PagesRead { get; set; } + /// + /// DateTime representing last time the series was Read. Calculated at API-time. + /// + public DateTime LatestReadDate { get; set; } + /// + /// DateTime representing last time a chapter was added to the Series + /// + public DateTime LastChapterAdded { get; set; } + /// + /// Rating from logged in user. Calculated at API-time. + /// + public int UserRating { get; set; } + /// + /// Review from logged in user. Calculated at API-time. + /// + public string UserReview { get; set; } + public MangaFormat Format { get; set; } - public DateTime Created { get; set; } + public DateTime Created { get; set; } - public bool NameLocked { get; set; } - public bool SortNameLocked { get; set; } - public bool LocalizedNameLocked { get; set; } - /// - /// Total number of words for the series. Only applies to epubs. - /// - public long WordCount { get; set; } + public bool NameLocked { get; set; } + public bool SortNameLocked { get; set; } + public bool LocalizedNameLocked { get; set; } + /// + /// Total number of words for the series. Only applies to epubs. + /// + public long WordCount { get; set; } - public int LibraryId { get; set; } - public string LibraryName { get; set; } - /// - public int MinHoursToRead { get; set; } - /// - public int MaxHoursToRead { get; set; } - /// - public int AvgHoursToRead { get; set; } - /// - /// The highest level folder for this Series - /// - public string FolderPath { get; set; } - /// - /// The last time the folder for this series was scanned - /// - public DateTime LastFolderScanned { get; set; } - } + public int LibraryId { get; set; } + public string LibraryName { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public int AvgHoursToRead { get; set; } + /// + /// The highest level folder for this Series + /// + public string FolderPath { get; set; } + /// + /// The last time the folder for this series was scanned + /// + public DateTime LastFolderScanned { get; set; } } diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 9a396f5d19..e5cf3a0341 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -4,83 +4,82 @@ using API.DTOs.Metadata; using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs; + +public class SeriesMetadataDto { - public class SeriesMetadataDto - { - public int Id { get; set; } - public string Summary { get; set; } = string.Empty; - /// - /// Collections the Series belongs to - /// - public ICollection CollectionTags { get; set; } - /// - /// Genres for the Series - /// - public ICollection Genres { get; set; } - /// - /// Collection of all Tags from underlying chapters for a Series - /// - public ICollection Tags { get; set; } - public ICollection Writers { get; set; } = new List(); - public ICollection CoverArtists { get; set; } = new List(); - public ICollection Publishers { get; set; } = new List(); - public ICollection Characters { get; set; } = new List(); - public ICollection Pencillers { get; set; } = new List(); - public ICollection Inkers { get; set; } = new List(); - public ICollection Colorists { get; set; } = new List(); - public ICollection Letterers { get; set; } = new List(); - public ICollection Editors { get; set; } = new List(); - public ICollection Translators { get; set; } = new List(); - /// - /// Highest Age Rating from all Chapters - /// - public AgeRating AgeRating { get; set; } = AgeRating.Unknown; - /// - /// Earliest Year from all chapters - /// - public int ReleaseYear { get; set; } - /// - /// Language of the content (BCP-47 code) - /// - public string Language { get; set; } = string.Empty; - /// - /// Max number of issues/volumes in the series (Max of Volume/Issue field in ComicInfo) - /// - public int MaxCount { get; set; } = 0; - /// - /// Total number of issues/volumes for the series - /// - public int TotalCount { get; set; } - /// - /// Publication status of the Series - /// - public PublicationStatus PublicationStatus { get; set; } + public int Id { get; set; } + public string Summary { get; set; } = string.Empty; + /// + /// Collections the Series belongs to + /// + public ICollection CollectionTags { get; set; } + /// + /// Genres for the Series + /// + public ICollection Genres { get; set; } + /// + /// Collection of all Tags from underlying chapters for a Series + /// + public ICollection Tags { get; set; } + public ICollection Writers { get; set; } = new List(); + public ICollection CoverArtists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); + /// + /// Highest Age Rating from all Chapters + /// + public AgeRating AgeRating { get; set; } = AgeRating.Unknown; + /// + /// Earliest Year from all chapters + /// + public int ReleaseYear { get; set; } + /// + /// Language of the content (BCP-47 code) + /// + public string Language { get; set; } = string.Empty; + /// + /// Max number of issues/volumes in the series (Max of Volume/Issue field in ComicInfo) + /// + public int MaxCount { get; set; } = 0; + /// + /// Total number of issues/volumes for the series + /// + public int TotalCount { get; set; } + /// + /// Publication status of the Series + /// + public PublicationStatus PublicationStatus { get; set; } - public bool LanguageLocked { get; set; } - public bool SummaryLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override AgeRating - /// - public bool AgeRatingLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override PublicationStatus - /// - public bool PublicationStatusLocked { get; set; } - public bool GenresLocked { get; set; } - public bool TagsLocked { get; set; } - public bool WritersLocked { get; set; } - public bool CharactersLocked { get; set; } - public bool ColoristsLocked { get; set; } - public bool EditorsLocked { get; set; } - public bool InkersLocked { get; set; } - public bool LetterersLocked { get; set; } - public bool PencillersLocked { get; set; } - public bool PublishersLocked { get; set; } - public bool TranslatorsLocked { get; set; } - public bool CoverArtistsLocked { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override PublicationStatus + /// + public bool PublicationStatusLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WritersLocked { get; set; } + public bool CharactersLocked { get; set; } + public bool ColoristsLocked { get; set; } + public bool EditorsLocked { get; set; } + public bool InkersLocked { get; set; } + public bool LetterersLocked { get; set; } + public bool PencillersLocked { get; set; } + public bool PublishersLocked { get; set; } + public bool TranslatorsLocked { get; set; } + public bool CoverArtistsLocked { get; set; } - public int SeriesId { get; set; } - } + public int SeriesId { get; set; } } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index f979684af5..481ee27218 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,64 +1,63 @@ using API.Services; -namespace API.DTOs.Settings +namespace API.DTOs.Settings; + +public class ServerSettingDto { - public class ServerSettingDto - { - public string CacheDirectory { get; set; } - public string TaskScan { get; set; } - /// - /// Logging level for server. Managed in appsettings.json. - /// - public string LoggingLevel { get; set; } - public string TaskBackup { get; set; } - /// - /// Port the server listens on. Managed in appsettings.json. - /// - public int Port { get; set; } - /// - /// Allows anonymous information to be collected and sent to KavitaStats - /// - public bool AllowStatCollection { get; set; } - /// - /// Enables OPDS connections to be made to the server. - /// - public bool EnableOpds { get; set; } - /// - /// Base Url for the kavita. Requires restart to take effect. - /// - public string BaseUrl { get; set; } - /// - /// Where Bookmarks are stored. - /// - /// If null or empty string, will default back to default install setting aka - public string BookmarksDirectory { get; set; } - /// - /// Email service to use for the invite user flow, forgot password, etc. - /// - /// If null or empty string, will default back to default install setting aka - public string EmailServiceUrl { get; set; } - public string InstallVersion { get; set; } - /// - /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. - /// - public string InstallId { get; set; } - /// - /// If the server should save bookmarks as WebP encoding - /// - public bool ConvertBookmarkToWebP { get; set; } - /// - /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. - /// - public bool EnableSwaggerUi { get; set; } + public string CacheDirectory { get; set; } + public string TaskScan { get; set; } + /// + /// Logging level for server. Managed in appsettings.json. + /// + public string LoggingLevel { get; set; } + public string TaskBackup { get; set; } + /// + /// Port the server listens on. Managed in appsettings.json. + /// + public int Port { get; set; } + /// + /// Allows anonymous information to be collected and sent to KavitaStats + /// + public bool AllowStatCollection { get; set; } + /// + /// Enables OPDS connections to be made to the server. + /// + public bool EnableOpds { get; set; } + /// + /// Base Url for the kavita. Requires restart to take effect. + /// + public string BaseUrl { get; set; } + /// + /// Where Bookmarks are stored. + /// + /// If null or empty string, will default back to default install setting aka + public string BookmarksDirectory { get; set; } + /// + /// Email service to use for the invite user flow, forgot password, etc. + /// + /// If null or empty string, will default back to default install setting aka + public string EmailServiceUrl { get; set; } + public string InstallVersion { get; set; } + /// + /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. + /// + public string InstallId { get; set; } + /// + /// If the server should save bookmarks as WebP encoding + /// + public bool ConvertBookmarkToWebP { get; set; } + /// + /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. + /// + public bool EnableSwaggerUi { get; set; } - /// - /// The amount of Backups before cleanup - /// - /// Value should be between 1 and 30 - public int TotalBackups { get; set; } = 30; - /// - /// If Kavita should watch the library folders and process changes - /// - public bool EnableFolderWatching { get; set; } = true; - } + /// + /// The amount of Backups before cleanup + /// + /// Value should be between 1 and 30 + public int TotalBackups { get; set; } = 30; + /// + /// If Kavita should watch the library folders and process changes + /// + public bool EnableFolderWatching { get; set; } = true; } diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 4b037a1080..ecfce3a166 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -1,123 +1,122 @@ using API.Entities.Enums; -namespace API.DTOs.Stats +namespace API.DTOs.Stats; + +/// +/// Represents information about a Kavita Installation +/// +public class ServerInfoDto { /// - /// Represents information about a Kavita Installation - /// - public class ServerInfoDto - { - /// - /// Unique Id that represents a unique install - /// - public string InstallId { get; set; } - public string Os { get; set; } - /// - /// If the Kavita install is using Docker - /// - public bool IsDocker { get; set; } - /// - /// Version of .NET instance is running - /// - public string DotnetVersion { get; set; } - /// - /// Version of Kavita - /// - public string KavitaVersion { get; set; } - /// - /// Number of Cores on the instance - /// - public int NumOfCores { get; set; } - /// - /// The number of libraries on the instance - /// - public int NumberOfLibraries { get; set; } - /// - /// Does any user have bookmarks - /// - public bool HasBookmarks { get; set; } - /// - /// The site theme the install is using - /// - /// Introduced in v0.5.2 - public string ActiveSiteTheme { get; set; } - /// - /// The reading mode the main user has as a preference - /// - /// Introduced in v0.5.2 - public ReaderMode MangaReaderMode { get; set; } + /// Unique Id that represents a unique install + /// + public string InstallId { get; set; } + public string Os { get; set; } + /// + /// If the Kavita install is using Docker + /// + public bool IsDocker { get; set; } + /// + /// Version of .NET instance is running + /// + public string DotnetVersion { get; set; } + /// + /// Version of Kavita + /// + public string KavitaVersion { get; set; } + /// + /// Number of Cores on the instance + /// + public int NumOfCores { get; set; } + /// + /// The number of libraries on the instance + /// + public int NumberOfLibraries { get; set; } + /// + /// Does any user have bookmarks + /// + public bool HasBookmarks { get; set; } + /// + /// The site theme the install is using + /// + /// Introduced in v0.5.2 + public string ActiveSiteTheme { get; set; } + /// + /// The reading mode the main user has as a preference + /// + /// Introduced in v0.5.2 + public ReaderMode MangaReaderMode { get; set; } - /// - /// Number of users on the install - /// - /// Introduced in v0.5.2 - public int NumberOfUsers { get; set; } + /// + /// Number of users on the install + /// + /// Introduced in v0.5.2 + public int NumberOfUsers { get; set; } - /// - /// Number of collections on the install - /// - /// Introduced in v0.5.2 - public int NumberOfCollections { get; set; } - /// - /// Number of reading lists on the install (Sum of all users) - /// - /// Introduced in v0.5.2 - public int NumberOfReadingLists { get; set; } - /// - /// Is OPDS enabled - /// - /// Introduced in v0.5.2 - public bool OPDSEnabled { get; set; } - /// - /// Total number of files in the instance - /// - /// Introduced in v0.5.2 - public int TotalFiles { get; set; } - /// - /// Total number of Genres in the instance - /// - /// Introduced in v0.5.4 - public int TotalGenres { get; set; } - /// - /// Total number of People in the instance - /// - /// Introduced in v0.5.4 - public int TotalPeople { get; set; } - /// - /// Is this instance storing bookmarks as WebP - /// - /// Introduced in v0.5.4 - public bool StoreBookmarksAsWebP { get; set; } - /// - /// Number of users on this instance using Card Layout - /// - /// Introduced in v0.5.4 - public int UsersOnCardLayout { get; set; } - /// - /// Number of users on this instance using List Layout - /// - /// Introduced in v0.5.4 - public int UsersOnListLayout { get; set; } - /// - /// Max number of Series for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxSeriesInALibrary { get; set; } - /// - /// Max number of Volumes for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxVolumesInASeries { get; set; } - /// - /// Max number of Chapters for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxChaptersInASeries { get; set; } - /// - /// Does this instance have relationships setup between series - /// - /// Introduced in v0.5.4 - public bool UsingSeriesRelationships { get; set; } + /// + /// Number of collections on the install + /// + /// Introduced in v0.5.2 + public int NumberOfCollections { get; set; } + /// + /// Number of reading lists on the install (Sum of all users) + /// + /// Introduced in v0.5.2 + public int NumberOfReadingLists { get; set; } + /// + /// Is OPDS enabled + /// + /// Introduced in v0.5.2 + public bool OPDSEnabled { get; set; } + /// + /// Total number of files in the instance + /// + /// Introduced in v0.5.2 + public int TotalFiles { get; set; } + /// + /// Total number of Genres in the instance + /// + /// Introduced in v0.5.4 + public int TotalGenres { get; set; } + /// + /// Total number of People in the instance + /// + /// Introduced in v0.5.4 + public int TotalPeople { get; set; } + /// + /// Is this instance storing bookmarks as WebP + /// + /// Introduced in v0.5.4 + public bool StoreBookmarksAsWebP { get; set; } + /// + /// Number of users on this instance using Card Layout + /// + /// Introduced in v0.5.4 + public int UsersOnCardLayout { get; set; } + /// + /// Number of users on this instance using List Layout + /// + /// Introduced in v0.5.4 + public int UsersOnListLayout { get; set; } + /// + /// Max number of Series for any library on the instance + /// + /// Introduced in v0.5.4 + public int MaxSeriesInALibrary { get; set; } + /// + /// Max number of Volumes for any library on the instance + /// + /// Introduced in v0.5.4 + public int MaxVolumesInASeries { get; set; } + /// + /// Max number of Chapters for any library on the instance + /// + /// Introduced in v0.5.4 + public int MaxChaptersInASeries { get; set; } + /// + /// Does this instance have relationships setup between series + /// + /// Introduced in v0.5.4 + public bool UsingSeriesRelationships { get; set; } - } } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 66c979cc47..030227a453 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,42 +1,41 @@ -namespace API.DTOs.Update +namespace API.DTOs.Update; + +/// +/// Update Notification denoting a new release available for user to update to +/// +public class UpdateNotificationDto { /// - /// Update Notification denoting a new release available for user to update to - /// - public class UpdateNotificationDto - { - /// - /// Current installed Version - /// - public string CurrentVersion { get; init; } - /// - /// Semver of the release version - /// 0.4.3 - /// - public string UpdateVersion { get; init; } - /// - /// Release body in HTML - /// - public string UpdateBody { get; init; } - /// - /// Title of the release - /// - public string UpdateTitle { get; init; } - /// - /// Github Url - /// - public string UpdateUrl { get; init; } - /// - /// If this install is within Docker - /// - public bool IsDocker { get; init; } - /// - /// Is this a pre-release - /// - public bool IsPrerelease { get; init; } - /// - /// Date of the publish - /// - public string PublishDate { get; init; } - } + /// Current installed Version + /// + public string CurrentVersion { get; init; } + /// + /// Semver of the release version + /// 0.4.3 + /// + public string UpdateVersion { get; init; } + /// + /// Release body in HTML + /// + public string UpdateBody { get; init; } + /// + /// Title of the release + /// + public string UpdateTitle { get; init; } + /// + /// Github Url + /// + public string UpdateUrl { get; init; } + /// + /// If this install is within Docker + /// + public bool IsDocker { get; init; } + /// + /// Is this a pre-release + /// + public bool IsPrerelease { get; init; } + /// + /// Date of the publish + /// + public string PublishDate { get; init; } } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index f0908c7a20..4f527cb60e 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -1,13 +1,12 @@ using System.Collections.Generic; using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs; + +public class UpdateLibraryDto { - public class UpdateLibraryDto - { - public int Id { get; init; } - public string Name { get; init; } - public LibraryType Type { get; set; } - public IEnumerable Folders { get; init; } - } + public int Id { get; init; } + public string Name { get; init; } + public LibraryType Type { get; set; } + public IEnumerable Folders { get; init; } } diff --git a/API/DTOs/UpdateLibraryForUserDto.cs b/API/DTOs/UpdateLibraryForUserDto.cs index 5280f3dd75..b2c752b227 100644 --- a/API/DTOs/UpdateLibraryForUserDto.cs +++ b/API/DTOs/UpdateLibraryForUserDto.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; -namespace API.DTOs +namespace API.DTOs; + +public class UpdateLibraryForUserDto { - public class UpdateLibraryForUserDto - { - public string Username { get; init; } - public IEnumerable SelectedLibraries { get; init; } - } -} \ No newline at end of file + public string Username { get; init; } + public IEnumerable SelectedLibraries { get; init; } +} diff --git a/API/DTOs/UpdateRBSDto.cs b/API/DTOs/UpdateRBSDto.cs index 8bf37d3148..f23edf7846 100644 --- a/API/DTOs/UpdateRBSDto.cs +++ b/API/DTOs/UpdateRBSDto.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; -namespace API.DTOs +namespace API.DTOs; + +public class UpdateRbsDto { - public class UpdateRbsDto - { - public string Username { get; init; } - public IList Roles { get; init; } - } -} \ No newline at end of file + public string Username { get; init; } + public IList Roles { get; init; } +} diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index 8f10373e44..c5db42e78a 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -1,15 +1,14 @@ -namespace API.DTOs +namespace API.DTOs; + +public class UpdateSeriesDto { - public class UpdateSeriesDto - { - public int Id { get; init; } - public string Name { get; init; } - public string LocalizedName { get; init; } - public string SortName { get; init; } - public bool CoverImageLocked { get; set; } + public int Id { get; init; } + public string Name { get; init; } + public string LocalizedName { get; init; } + public string SortName { get; init; } + public bool CoverImageLocked { get; set; } - public bool NameLocked { get; set; } - public bool SortNameLocked { get; set; } - public bool LocalizedNameLocked { get; set; } - } + public bool NameLocked { get; set; } + public bool SortNameLocked { get; set; } + public bool LocalizedNameLocked { get; set; } } diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 08d3e77e64..f2724b628d 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -1,11 +1,10 @@ using System.Collections.Generic; using API.DTOs.CollectionTags; -namespace API.DTOs +namespace API.DTOs; + +public class UpdateSeriesMetadataDto { - public class UpdateSeriesMetadataDto - { - public SeriesMetadataDto SeriesMetadata { get; set; } - public ICollection CollectionTags { get; set; } - } + public SeriesMetadataDto SeriesMetadata { get; set; } + public ICollection CollectionTags { get; set; } } diff --git a/API/DTOs/UpdateSeriesRatingDto.cs b/API/DTOs/UpdateSeriesRatingDto.cs index d8b8dac2df..167d321bb9 100644 --- a/API/DTOs/UpdateSeriesRatingDto.cs +++ b/API/DTOs/UpdateSeriesRatingDto.cs @@ -1,12 +1,11 @@ using System.ComponentModel.DataAnnotations; -namespace API.DTOs +namespace API.DTOs; + +public class UpdateSeriesRatingDto { - public class UpdateSeriesRatingDto - { - public int SeriesId { get; init; } - public int UserRating { get; init; } - [MaxLength(1000)] - public string UserReview { get; init; } - } -} \ No newline at end of file + public int SeriesId { get; init; } + public int UserRating { get; init; } + [MaxLength(1000)] + public string UserReview { get; init; } +} diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs index 42b889903f..374f43b23e 100644 --- a/API/DTOs/Uploads/UploadFileDto.cs +++ b/API/DTOs/Uploads/UploadFileDto.cs @@ -1,14 +1,13 @@ -namespace API.DTOs.Uploads +namespace API.DTOs.Uploads; + +public class UploadFileDto { - public class UploadFileDto - { - /// - /// Id of the Entity - /// - public int Id { get; set; } - /// - /// Base Url encoding of the file to upload from (can be null) - /// - public string Url { get; set; } - } + /// + /// Id of the Entity + /// + public int Id { get; set; } + /// + /// Base Url encoding of the file to upload from (can be null) + /// + public string Url { get; set; } } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index dc6fc8b43c..d2c05e5a89 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,13 +1,12 @@  -namespace API.DTOs +namespace API.DTOs; + +public class UserDto { - public class UserDto - { - public string Username { get; init; } - public string Email { get; init; } - public string Token { get; set; } - public string RefreshToken { get; set; } - public string ApiKey { get; init; } - public UserPreferencesDto Preferences { get; set; } - } + public string Username { get; init; } + public string Email { get; init; } + public string Token { get; set; } + public string RefreshToken { get; set; } + public string ApiKey { get; init; } + public UserPreferencesDto Preferences { get; set; } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 255c21c1f0..804f0b2eff 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -5,116 +5,115 @@ using API.Entities.Enums; using API.Entities.Enums.UserPreferences; -namespace API.DTOs +namespace API.DTOs; + +public class UserPreferencesDto { - public class UserPreferencesDto - { - /// - /// Manga Reader Option: What direction should the next/prev page buttons go - /// - [Required] - public ReadingDirection ReadingDirection { get; set; } - /// - /// Manga Reader Option: How should the image be scaled to screen - /// - [Required] - public ScalingOption ScalingOption { get; set; } - /// - /// Manga Reader Option: Which side of a split image should we show first - /// - [Required] - public PageSplitOption PageSplitOption { get; set; } - /// - /// Manga Reader Option: How the manga reader should perform paging or reading of the file - /// - /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging - /// by clicking top/bottom sides of reader. - /// - /// - [Required] - public ReaderMode ReaderMode { get; set; } - /// - /// Manga Reader Option: How many pages to display in the reader at once - /// - [Required] - public LayoutMode LayoutMode { get; set; } - /// - /// Manga Reader Option: Background color of the reader - /// - [Required] - public string BackgroundColor { get; set; } = "#000000"; - /// - /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction - /// - [Required] - public bool AutoCloseMenu { get; set; } - /// - /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change - /// - [Required] - public bool ShowScreenHints { get; set; } = true; - /// - /// Book Reader Option: Override extra Margin - /// - [Required] - public int BookReaderMargin { get; set; } - /// - /// Book Reader Option: Override line-height - /// - [Required] - public int BookReaderLineSpacing { get; set; } - /// - /// Book Reader Option: Override font size - /// - [Required] - public int BookReaderFontSize { get; set; } - /// - /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override - /// - [Required] - public string BookReaderFontFamily { get; set; } - /// - /// Book Reader Option: Allows tapping on side of screens to paginate - /// - [Required] - public bool BookReaderTapToPaginate { get; set; } - /// - /// Book Reader Option: What direction should the next/prev page buttons go - /// - [Required] - public ReadingDirection BookReaderReadingDirection { get; set; } - /// - /// UI Site Global Setting: The UI theme the user should use. - /// - /// Should default to Dark - [Required] - public SiteTheme Theme { get; set; } - [Required] - public string BookReaderThemeName { get; set; } - [Required] - public BookPageLayoutMode BookReaderLayoutMode { get; set; } - /// - /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. - /// - /// Defaults to false - [Required] - public bool BookReaderImmersiveMode { get; set; } = false; - /// - /// Global Site Option: If the UI should layout items as Cards or List items - /// - /// Defaults to Cards - [Required] - public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; - /// - /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already - /// - /// Defaults to false - [Required] - public bool BlurUnreadSummaries { get; set; } = false; - /// - /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. - /// - [Required] - public bool PromptForDownloadSize { get; set; } = true; - } + /// + /// Manga Reader Option: What direction should the next/prev page buttons go + /// + [Required] + public ReadingDirection ReadingDirection { get; set; } + /// + /// Manga Reader Option: How should the image be scaled to screen + /// + [Required] + public ScalingOption ScalingOption { get; set; } + /// + /// Manga Reader Option: Which side of a split image should we show first + /// + [Required] + public PageSplitOption PageSplitOption { get; set; } + /// + /// Manga Reader Option: How the manga reader should perform paging or reading of the file + /// + /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging + /// by clicking top/bottom sides of reader. + /// + /// + [Required] + public ReaderMode ReaderMode { get; set; } + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + [Required] + public LayoutMode LayoutMode { get; set; } + /// + /// Manga Reader Option: Background color of the reader + /// + [Required] + public string BackgroundColor { get; set; } = "#000000"; + /// + /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction + /// + [Required] + public bool AutoCloseMenu { get; set; } + /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + [Required] + public bool ShowScreenHints { get; set; } = true; + /// + /// Book Reader Option: Override extra Margin + /// + [Required] + public int BookReaderMargin { get; set; } + /// + /// Book Reader Option: Override line-height + /// + [Required] + public int BookReaderLineSpacing { get; set; } + /// + /// Book Reader Option: Override font size + /// + [Required] + public int BookReaderFontSize { get; set; } + /// + /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override + /// + [Required] + public string BookReaderFontFamily { get; set; } + /// + /// Book Reader Option: Allows tapping on side of screens to paginate + /// + [Required] + public bool BookReaderTapToPaginate { get; set; } + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// + [Required] + public ReadingDirection BookReaderReadingDirection { get; set; } + /// + /// UI Site Global Setting: The UI theme the user should use. + /// + /// Should default to Dark + [Required] + public SiteTheme Theme { get; set; } + [Required] + public string BookReaderThemeName { get; set; } + [Required] + public BookPageLayoutMode BookReaderLayoutMode { get; set; } + /// + /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. + /// + /// Defaults to false + [Required] + public bool BookReaderImmersiveMode { get; set; } = false; + /// + /// Global Site Option: If the UI should layout items as Cards or List items + /// + /// Defaults to Cards + [Required] + public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; + /// + /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already + /// + /// Defaults to false + [Required] + public bool BlurUnreadSummaries { get; set; } = false; + /// + /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. + /// + [Required] + public bool PromptForDownloadSize { get; set; } = true; } diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 5a20e61a54..4ef20950ab 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -4,26 +4,25 @@ using API.Entities; using API.Entities.Interfaces; -namespace API.DTOs +namespace API.DTOs; + +public class VolumeDto : IHasReadTimeEstimate { - public class VolumeDto : IHasReadTimeEstimate - { - public int Id { get; set; } - /// - public int Number { get; set; } - /// - public string Name { get; set; } - public int Pages { get; set; } - public int PagesRead { get; set; } - public DateTime LastModified { get; set; } - public DateTime Created { get; set; } - public int SeriesId { get; set; } - public ICollection Chapters { get; set; } - /// - public int MinHoursToRead { get; set; } - /// - public int MaxHoursToRead { get; set; } - /// - public int AvgHoursToRead { get; set; } - } + public int Id { get; set; } + /// + public int Number { get; set; } + /// + public string Name { get; set; } + public int Pages { get; set; } + public int PagesRead { get; set; } + public DateTime LastModified { get; set; } + public DateTime Created { get; set; } + public int SeriesId { get; set; } + public ICollection Chapters { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public int AvgHoursToRead { get; set; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 7c76e4a783..7b1ce8d36c 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -11,141 +11,140 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace API.Data +namespace API.Data; + +public sealed class DataContext : IdentityDbContext, AppUserRole, IdentityUserLogin, + IdentityRoleClaim, IdentityUserToken> { - public sealed class DataContext : IdentityDbContext, AppUserRole, IdentityUserLogin, - IdentityRoleClaim, IdentityUserToken> + public DataContext(DbContextOptions options) : base(options) { - public DataContext(DbContextOptions options) : base(options) - { - ChangeTracker.Tracked += OnEntityTracked; - ChangeTracker.StateChanged += OnEntityStateChanged; - } + ChangeTracker.Tracked += OnEntityTracked; + ChangeTracker.StateChanged += OnEntityStateChanged; + } - public DbSet Library { get; set; } - public DbSet Series { get; set; } - public DbSet Chapter { get; set; } - public DbSet Volume { get; set; } - public DbSet AppUser { get; set; } - public DbSet MangaFile { get; set; } - public DbSet AppUserProgresses { get; set; } - public DbSet AppUserRating { get; set; } - public DbSet ServerSetting { get; set; } - public DbSet AppUserPreferences { get; set; } - public DbSet SeriesMetadata { get; set; } - public DbSet CollectionTag { get; set; } - public DbSet AppUserBookmark { get; set; } - public DbSet ReadingList { get; set; } - public DbSet ReadingListItem { get; set; } - public DbSet Person { get; set; } - public DbSet Genre { get; set; } - public DbSet Tag { get; set; } - public DbSet SiteTheme { get; set; } - public DbSet SeriesRelation { get; set; } - public DbSet FolderPath { get; set; } - - - protected override void OnModelCreating(ModelBuilder builder) - { - base.OnModelCreating(builder); - - - builder.Entity() - .HasMany(ur => ur.UserRoles) - .WithOne(u => u.User) - .HasForeignKey(ur => ur.UserId) - .IsRequired(); - - builder.Entity() - .HasMany(ur => ur.UserRoles) - .WithOne(u => u.Role) - .HasForeignKey(ur => ur.RoleId) - .IsRequired(); - - builder.Entity() - .HasOne(pt => pt.Series) - .WithMany(p => p.Relations) - .HasForeignKey(pt => pt.SeriesId) - .OnDelete(DeleteBehavior.ClientCascade); - - builder.Entity() - .HasOne(pt => pt.TargetSeries) - .WithMany(t => t.RelationOf) - .HasForeignKey(pt => pt.TargetSeriesId) - .OnDelete(DeleteBehavior.ClientCascade); - - - builder.Entity() - .Property(b => b.BookThemeName) - .HasDefaultValue("Dark"); - builder.Entity() - .Property(b => b.BackgroundColor) - .HasDefaultValue("#000000"); - - builder.Entity() - .Property(b => b.GlobalPageLayoutMode) - .HasDefaultValue(PageLayoutMode.Cards); - } + public DbSet Library { get; set; } + public DbSet Series { get; set; } + public DbSet Chapter { get; set; } + public DbSet Volume { get; set; } + public DbSet AppUser { get; set; } + public DbSet MangaFile { get; set; } + public DbSet AppUserProgresses { get; set; } + public DbSet AppUserRating { get; set; } + public DbSet ServerSetting { get; set; } + public DbSet AppUserPreferences { get; set; } + public DbSet SeriesMetadata { get; set; } + public DbSet CollectionTag { get; set; } + public DbSet AppUserBookmark { get; set; } + public DbSet ReadingList { get; set; } + public DbSet ReadingListItem { get; set; } + public DbSet Person { get; set; } + public DbSet Genre { get; set; } + public DbSet Tag { get; set; } + public DbSet SiteTheme { get; set; } + public DbSet SeriesRelation { get; set; } + public DbSet FolderPath { get; set; } + + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + + builder.Entity() + .HasMany(ur => ur.UserRoles) + .WithOne(u => u.User) + .HasForeignKey(ur => ur.UserId) + .IsRequired(); + + builder.Entity() + .HasMany(ur => ur.UserRoles) + .WithOne(u => u.Role) + .HasForeignKey(ur => ur.RoleId) + .IsRequired(); + + builder.Entity() + .HasOne(pt => pt.Series) + .WithMany(p => p.Relations) + .HasForeignKey(pt => pt.SeriesId) + .OnDelete(DeleteBehavior.ClientCascade); + + builder.Entity() + .HasOne(pt => pt.TargetSeries) + .WithMany(t => t.RelationOf) + .HasForeignKey(pt => pt.TargetSeriesId) + .OnDelete(DeleteBehavior.ClientCascade); + + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); + + builder.Entity() + .Property(b => b.GlobalPageLayoutMode) + .HasDefaultValue(PageLayoutMode.Cards); + } - private static void OnEntityTracked(object sender, EntityTrackedEventArgs e) + private static void OnEntityTracked(object sender, EntityTrackedEventArgs e) + { + if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity) { - if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity) - { - entity.Created = DateTime.Now; - entity.LastModified = DateTime.Now; - } - + entity.Created = DateTime.Now; + entity.LastModified = DateTime.Now; } - private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e) - { - if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity) - entity.LastModified = DateTime.Now; - } + } - private void OnSaveChanges() + private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e) + { + if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity) + entity.LastModified = DateTime.Now; + } + + private void OnSaveChanges() + { + foreach (var saveEntity in ChangeTracker.Entries() + .Where(e => e.State == EntityState.Modified) + .Select(entry => entry.Entity) + .OfType()) { - foreach (var saveEntity in ChangeTracker.Entries() - .Where(e => e.State == EntityState.Modified) - .Select(entry => entry.Entity) - .OfType()) - { - saveEntity.OnSavingChanges(); - } + saveEntity.OnSavingChanges(); } + } - #region SaveChanges overrides - - public override int SaveChanges() - { - this.OnSaveChanges(); + #region SaveChanges overrides - return base.SaveChanges(); - } + public override int SaveChanges() + { + this.OnSaveChanges(); - public override int SaveChanges(bool acceptAllChangesOnSuccess) - { - this.OnSaveChanges(); + return base.SaveChanges(); + } - return base.SaveChanges(acceptAllChangesOnSuccess); - } + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + this.OnSaveChanges(); - public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken)) - { - this.OnSaveChanges(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } - return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); - } + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken)) + { + this.OnSaveChanges(); - public override Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken)) - { - this.OnSaveChanges(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } - return base.SaveChangesAsync(cancellationToken); - } + public override Task SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken)) + { + this.OnSaveChanges(); - #endregion + return base.SaveChangesAsync(cancellationToken); } + + #endregion } diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index 921b55c543..8151030ddb 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -9,162 +9,161 @@ using API.Parser; using API.Services.Tasks; -namespace API.Data +namespace API.Data; + +/// +/// Responsible for creating Series, Volume, Chapter, MangaFiles for use in +/// +public static class DbFactory { - /// - /// Responsible for creating Series, Volume, Chapter, MangaFiles for use in - /// - public static class DbFactory + public static Series Series(string name) { - public static Series Series(string name) + return new Series { - return new Series - { - Name = name, - OriginalName = name, - LocalizedName = name, - NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - SortName = name, - Volumes = new List(), - Metadata = SeriesMetadata(Array.Empty()) - }; - } + Name = name, + OriginalName = name, + LocalizedName = name, + NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), + NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), + SortName = name, + Volumes = new List(), + Metadata = SeriesMetadata(Array.Empty()) + }; + } - public static Series Series(string name, string localizedName) + public static Series Series(string name, string localizedName) + { + if (string.IsNullOrEmpty(localizedName)) { - if (string.IsNullOrEmpty(localizedName)) - { - localizedName = name; - } - return new Series - { - Name = name, - OriginalName = name, - LocalizedName = localizedName, - NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(localizedName), - SortName = name, - Volumes = new List(), - Metadata = SeriesMetadata(Array.Empty()) - }; + localizedName = name; } - - public static Volume Volume(string volumeNumber) + return new Series { - return new Volume() - { - Name = volumeNumber, - Number = (int) Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber), - Chapters = new List() - }; - } + Name = name, + OriginalName = name, + LocalizedName = localizedName, + NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), + NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(localizedName), + SortName = name, + Volumes = new List(), + Metadata = SeriesMetadata(Array.Empty()) + }; + } - public static Chapter Chapter(ParserInfo info) + public static Volume Volume(string volumeNumber) + { + return new Volume() { - var specialTreatment = info.IsSpecialInfo(); - var specialTitle = specialTreatment ? info.Filename : info.Chapters; - return new Chapter() - { - Number = specialTreatment ? "0" : Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(info.Chapters) + string.Empty, - Range = specialTreatment ? info.Filename : info.Chapters, - Title = (specialTreatment && info.Format == MangaFormat.Epub) - ? info.Title - : specialTitle, - Files = new List(), - IsSpecial = specialTreatment, - }; - } + Name = volumeNumber, + Number = (int) Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber), + Chapters = new List() + }; + } - public static SeriesMetadata SeriesMetadata(ComicInfo info) + public static Chapter Chapter(ParserInfo info) + { + var specialTreatment = info.IsSpecialInfo(); + var specialTitle = specialTreatment ? info.Filename : info.Chapters; + return new Chapter() { - return SeriesMetadata(Array.Empty()); - } + Number = specialTreatment ? "0" : Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(info.Chapters) + string.Empty, + Range = specialTreatment ? info.Filename : info.Chapters, + Title = (specialTreatment && info.Format == MangaFormat.Epub) + ? info.Title + : specialTitle, + Files = new List(), + IsSpecial = specialTreatment, + }; + } - public static SeriesMetadata SeriesMetadata(ICollection collectionTags) - { - return new SeriesMetadata() - { - CollectionTags = collectionTags, - Summary = string.Empty - }; - } + public static SeriesMetadata SeriesMetadata(ComicInfo info) + { + return SeriesMetadata(Array.Empty()); + } - public static CollectionTag CollectionTag(int id, string title, string summary, bool promoted) + public static SeriesMetadata SeriesMetadata(ICollection collectionTags) + { + return new SeriesMetadata() { - return new CollectionTag() - { - Id = id, - NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), - Title = title?.Trim(), - Summary = summary?.Trim(), - Promoted = promoted - }; - } + CollectionTags = collectionTags, + Summary = string.Empty + }; + } - public static ReadingList ReadingList(string title, string summary, bool promoted) + public static CollectionTag CollectionTag(int id, string title, string summary, bool promoted) + { + return new CollectionTag() { - return new ReadingList() - { - NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), - Title = title?.Trim(), - Summary = summary?.Trim(), - Promoted = promoted, - Items = new List() - }; - } + Id = id, + NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), + Title = title?.Trim(), + Summary = summary?.Trim(), + Promoted = promoted + }; + } - public static ReadingListItem ReadingListItem(int index, int seriesId, int volumeId, int chapterId) + public static ReadingList ReadingList(string title, string summary, bool promoted) + { + return new ReadingList() { - return new ReadingListItem() - { - Order = index, - ChapterId = chapterId, - SeriesId = seriesId, - VolumeId = volumeId - }; - } + NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(), + Title = title?.Trim(), + Summary = summary?.Trim(), + Promoted = promoted, + Items = new List() + }; + } - public static Genre Genre(string name, bool external) + public static ReadingListItem ReadingListItem(int index, int seriesId, int volumeId, int chapterId) + { + return new ReadingListItem() { - return new Genre() - { - Title = name.Trim().SentenceCase(), - NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - ExternalTag = external - }; - } + Order = index, + ChapterId = chapterId, + SeriesId = seriesId, + VolumeId = volumeId + }; + } - public static Tag Tag(string name, bool external) + public static Genre Genre(string name, bool external) + { + return new Genre() { - return new Tag() - { - Title = name.Trim().SentenceCase(), - NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - ExternalTag = external - }; - } + Title = name.Trim().SentenceCase(), + NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name), + ExternalTag = external + }; + } - public static Person Person(string name, PersonRole role) + public static Tag Tag(string name, bool external) + { + return new Tag() { - return new Person() - { - Name = name.Trim(), - NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), - Role = role - }; - } + Title = name.Trim().SentenceCase(), + NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name), + ExternalTag = external + }; + } - public static MangaFile MangaFile(string filePath, MangaFormat format, int pages) + public static Person Person(string name, PersonRole role) + { + return new Person() { - return new MangaFile() - { - FilePath = filePath, - Format = format, - Pages = pages, - LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now - }; - } + Name = name.Trim(), + NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name), + Role = role + }; + } + public static MangaFile MangaFile(string filePath, MangaFormat format, int pages) + { + return new MangaFile() + { + FilePath = filePath, + Format = format, + Pages = pages, + LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now + }; } + } diff --git a/API/Data/LogLevelOptions.cs b/API/Data/LogLevelOptions.cs deleted file mode 100644 index dfdfd111fd..0000000000 --- a/API/Data/LogLevelOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace API.Data -{ - public class LogLevelOptions - { - public const string Logging = "LogLevel"; - - public string Default { get; set; } - } -} \ No newline at end of file diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index d34901daa8..eb9cdb344a 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -3,122 +3,121 @@ using API.Entities.Enums; using Kavita.Common.Extensions; -namespace API.Data.Metadata +namespace API.Data.Metadata; + +/// +/// A representation of a ComicInfo.xml file +/// +/// See reference of the loose spec here: https://anansi-project.github.io/docs/comicinfo/documentation +public class ComicInfo { + public string Summary { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Series { get; set; } = string.Empty; /// - /// A representation of a ComicInfo.xml file + /// Localized Series name. Not standard. /// - /// See reference of the loose spec here: https://anansi-project.github.io/docs/comicinfo/documentation - public class ComicInfo - { - public string Summary { get; set; } = string.Empty; - public string Title { get; set; } = string.Empty; - public string Series { get; set; } = string.Empty; - /// - /// Localized Series name. Not standard. - /// - public string LocalizedSeries { get; set; } = string.Empty; - public string SeriesSort { get; set; } = string.Empty; - public string Number { get; set; } = string.Empty; - /// - /// The total number of items in the series. - /// - public int Count { get; set; } = 0; - public string Volume { get; set; } = string.Empty; - public string Notes { get; set; } = string.Empty; - public string Genre { get; set; } = string.Empty; - public int PageCount { get; set; } - // ReSharper disable once InconsistentNaming - /// - /// IETF BCP 47 Code to represent the language of the content - /// - public string LanguageISO { get; set; } = string.Empty; - /// - /// This is the link to where the data was scraped from - /// - public string Web { get; set; } = string.Empty; - public int Day { get; set; } = 0; - public int Month { get; set; } = 0; - public int Year { get; set; } = 0; + public string LocalizedSeries { get; set; } = string.Empty; + public string SeriesSort { get; set; } = string.Empty; + public string Number { get; set; } = string.Empty; + /// + /// The total number of items in the series. + /// + public int Count { get; set; } = 0; + public string Volume { get; set; } = string.Empty; + public string Notes { get; set; } = string.Empty; + public string Genre { get; set; } = string.Empty; + public int PageCount { get; set; } + // ReSharper disable once InconsistentNaming + /// + /// IETF BCP 47 Code to represent the language of the content + /// + public string LanguageISO { get; set; } = string.Empty; + /// + /// This is the link to where the data was scraped from + /// + public string Web { get; set; } = string.Empty; + public int Day { get; set; } = 0; + public int Month { get; set; } = 0; + public int Year { get; set; } = 0; - /// - /// Rating based on the content. Think PG-13, R for movies. See for valid types - /// - public string AgeRating { get; set; } = string.Empty; - /// - /// User's rating of the content - /// - public float UserRating { get; set; } + /// + /// Rating based on the content. Think PG-13, R for movies. See for valid types + /// + public string AgeRating { get; set; } = string.Empty; + /// + /// User's rating of the content + /// + public float UserRating { get; set; } - public string StoryArc { get; set; } = string.Empty; - public string SeriesGroup { get; set; } = string.Empty; - public string AlternateNumber { get; set; } = string.Empty; - public int AlternateCount { get; set; } = 0; - public string AlternateSeries { get; set; } = string.Empty; + public string StoryArc { get; set; } = string.Empty; + public string SeriesGroup { get; set; } = string.Empty; + public string AlternateNumber { get; set; } = string.Empty; + public int AlternateCount { get; set; } = 0; + public string AlternateSeries { get; set; } = string.Empty; - /// - /// This is Epub only: calibre:title_sort - /// Represents the sort order for the title - /// - public string TitleSort { get; set; } = string.Empty; - /// - /// This comes from ComicInfo and is free form text. We use this to validate against a set of tags and mark a file as - /// special. - /// - public string Format { get; set; } = string.Empty; + /// + /// This is Epub only: calibre:title_sort + /// Represents the sort order for the title + /// + public string TitleSort { get; set; } = string.Empty; + /// + /// This comes from ComicInfo and is free form text. We use this to validate against a set of tags and mark a file as + /// special. + /// + public string Format { get; set; } = string.Empty; - /// - /// The translator, can be comma separated. This is part of ComicInfo.xml draft v2.1 - /// - /// See https://github.com/anansi-project/comicinfo/issues/2 for information about this tag - public string Translator { get; set; } = string.Empty; - /// - /// Misc tags. This is part of ComicInfo.xml draft v2.1 - /// - /// See https://github.com/anansi-project/comicinfo/issues/1 for information about this tag - public string Tags { get; set; } = string.Empty; + /// + /// The translator, can be comma separated. This is part of ComicInfo.xml draft v2.1 + /// + /// See https://github.com/anansi-project/comicinfo/issues/2 for information about this tag + public string Translator { get; set; } = string.Empty; + /// + /// Misc tags. This is part of ComicInfo.xml draft v2.1 + /// + /// See https://github.com/anansi-project/comicinfo/issues/1 for information about this tag + public string Tags { get; set; } = string.Empty; - /// - /// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple. - /// - public string Writer { get; set; } = string.Empty; - public string Penciller { get; set; } = string.Empty; - public string Inker { get; set; } = string.Empty; - public string Colorist { get; set; } = string.Empty; - public string Letterer { get; set; } = string.Empty; - public string CoverArtist { get; set; } = string.Empty; - public string Editor { get; set; } = string.Empty; - public string Publisher { get; set; } = string.Empty; - public string Characters { get; set; } = string.Empty; + /// + /// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple. + /// + public string Writer { get; set; } = string.Empty; + public string Penciller { get; set; } = string.Empty; + public string Inker { get; set; } = string.Empty; + public string Colorist { get; set; } = string.Empty; + public string Letterer { get; set; } = string.Empty; + public string CoverArtist { get; set; } = string.Empty; + public string Editor { get; set; } = string.Empty; + public string Publisher { get; set; } = string.Empty; + public string Characters { get; set; } = string.Empty; - public static AgeRating ConvertAgeRatingToEnum(string value) - { - if (string.IsNullOrEmpty(value)) return Entities.Enums.AgeRating.Unknown; - return Enum.GetValues() - .SingleOrDefault(t => t.ToDescription().ToUpperInvariant().Equals(value.ToUpperInvariant()), Entities.Enums.AgeRating.Unknown); - } + public static AgeRating ConvertAgeRatingToEnum(string value) + { + if (string.IsNullOrEmpty(value)) return Entities.Enums.AgeRating.Unknown; + return Enum.GetValues() + .SingleOrDefault(t => t.ToDescription().ToUpperInvariant().Equals(value.ToUpperInvariant()), Entities.Enums.AgeRating.Unknown); + } - public static void CleanComicInfo(ComicInfo info) - { - if (info == null) return; + public static void CleanComicInfo(ComicInfo info) + { + if (info == null) return; - info.Series = info.Series.Trim(); - info.SeriesSort = info.SeriesSort.Trim(); - info.LocalizedSeries = info.LocalizedSeries.Trim(); + info.Series = info.Series.Trim(); + info.SeriesSort = info.SeriesSort.Trim(); + info.LocalizedSeries = info.LocalizedSeries.Trim(); - info.Writer = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Writer); - info.Colorist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Colorist); - info.Editor = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Editor); - info.Inker = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Inker); - info.Letterer = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Letterer); - info.Penciller = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Penciller); - info.Publisher = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Publisher); - info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters); - info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator); - info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist); - } + info.Writer = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Writer); + info.Colorist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Colorist); + info.Editor = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Editor); + info.Inker = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Inker); + info.Letterer = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Letterer); + info.Penciller = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Penciller); + info.Publisher = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Publisher); + info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters); + info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator); + info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist); + } - } } diff --git a/API/Data/MigrateConfigFiles.cs b/API/Data/MigrateConfigFiles.cs deleted file mode 100644 index 51ee371672..0000000000 --- a/API/Data/MigrateConfigFiles.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.IO.Abstractions; -using System.Linq; -using API.Services; -using Kavita.Common; - -namespace API.Data -{ - /// - /// A Migration to migrate config related files to the config/ directory for installs prior to v0.4.9. - /// - public static class MigrateConfigFiles - { - private static readonly List LooseLeafFiles = new List() - { - "appsettings.json", - "appsettings.Development.json", - "kavita.db", - }; - - private static readonly List AppFolders = new List() - { - "covers", - "stats", - "logs", - "backups", - "cache", - "temp" - }; - - - /// - /// In v0.4.8 we moved all config files to config/ to match with how docker was setup. This will move all config files from current directory - /// to config/ - /// - public static void Migrate(bool isDocker, IDirectoryService directoryService) - { - Console.WriteLine("Checking if migration to config/ is needed"); - - if (isDocker) - { - if (Configuration.LogPath.Contains("config")) - { - Console.WriteLine("Migration to config/ not needed"); - return; - } - - Console.WriteLine( - "Migrating files from pre-v0.4.8. All Kavita config files are now located in config/"); - - CopyAppFolders(directoryService); - DeleteAppFolders(directoryService); - - UpdateConfiguration(); - - Console.WriteLine("Migration complete. All config files are now in config/ directory"); - return; - } - - if (new FileInfo(Configuration.AppSettingsFilename).Exists) - { - Console.WriteLine("Migration to config/ not needed"); - return; - } - - Console.WriteLine( - "Migrating files from pre-v0.4.8. All Kavita config files are now located in config/"); - - Console.WriteLine($"Creating {directoryService.ConfigDirectory}"); - directoryService.ExistOrCreate(directoryService.ConfigDirectory); - - try - { - CopyLooseLeafFiles(directoryService); - - CopyAppFolders(directoryService); - - // Then we need to update the config file to point to the new DB file - UpdateConfiguration(); - } - catch (Exception) - { - Console.WriteLine("There was an exception during migration. Please move everything manually."); - return; - } - - // Finally delete everything in the source directory - Console.WriteLine("Removing old files"); - DeleteLooseFiles(directoryService); - DeleteAppFolders(directoryService); - Console.WriteLine("Removing old files...DONE"); - - Console.WriteLine("Migration complete. All config files are now in config/ directory"); - } - - private static void DeleteAppFolders(IDirectoryService directoryService) - { - foreach (var folderToDelete in AppFolders) - { - if (!new DirectoryInfo(Path.Join(Directory.GetCurrentDirectory(), folderToDelete)).Exists) continue; - - directoryService.ClearAndDeleteDirectory(Path.Join(Directory.GetCurrentDirectory(), folderToDelete)); - } - } - - private static void DeleteLooseFiles(IDirectoryService directoryService) - { - var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file))) - .Where(f => f.Exists); - directoryService.DeleteFiles(configFiles.Select(f => f.FullName)); - } - - private static void CopyAppFolders(IDirectoryService directoryService) - { - Console.WriteLine("Moving folders to config"); - - foreach (var folderToMove in AppFolders) - { - if (new DirectoryInfo(Path.Join(directoryService.ConfigDirectory, folderToMove)).Exists) continue; - - try - { - directoryService.CopyDirectoryToDirectory( - Path.Join(directoryService.FileSystem.Directory.GetCurrentDirectory(), folderToMove), - Path.Join(directoryService.ConfigDirectory, folderToMove)); - } - catch (Exception) - { - /* Swallow Exception */ - } - } - - - Console.WriteLine("Moving folders to config...DONE"); - } - - private static void CopyLooseLeafFiles(IDirectoryService directoryService) - { - var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(directoryService.FileSystem.Directory.GetCurrentDirectory(), file))) - .Where(f => f.Exists); - // First step is to move all the files - Console.WriteLine("Moving files to config/"); - foreach (var fileInfo in configFiles) - { - try - { - fileInfo.CopyTo(Path.Join(directoryService.ConfigDirectory, fileInfo.Name)); - } - catch (Exception) - { - /* Swallow exception when already exists */ - } - } - - Console.WriteLine("Moving files to config...DONE"); - } - - private static void UpdateConfiguration() - { - Console.WriteLine("Updating appsettings.json to new paths"); - Configuration.DatabasePath = "config//kavita.db"; - Configuration.LogPath = "config//logs/kavita.log"; - Console.WriteLine("Updating appsettings.json to new paths...DONE"); - } - } -} diff --git a/API/Data/MigrateCoverImages.cs b/API/Data/MigrateCoverImages.cs index 9c859e3e40..faf473edf5 100644 --- a/API/Data/MigrateCoverImages.cs +++ b/API/Data/MigrateCoverImages.cs @@ -7,176 +7,175 @@ using API.Services; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data; + +/// +/// A data structure to migrate Cover Images from byte[] to files. +/// +internal class CoverMigration { - /// - /// A data structure to migrate Cover Images from byte[] to files. - /// - internal class CoverMigration - { - public string Id { get; set; } - public byte[] CoverImage { get; set; } - public string ParentId { get; set; } - } + public string Id { get; set; } + public byte[] CoverImage { get; set; } + public string ParentId { get; set; } +} + +/// +/// In v0.4.6, Cover Images were migrated from byte[] in the DB to external files. This migration handles that work. +/// +public static class MigrateCoverImages +{ + private static readonly ChapterSortComparerZeroFirst ChapterSortComparerForInChapterSorting = new (); /// - /// In v0.4.6, Cover Images were migrated from byte[] in the DB to external files. This migration handles that work. + /// Run first. Will extract byte[]s from DB and write them to the cover directory. /// - public static class MigrateCoverImages + public static void ExtractToImages(DbContext context, IDirectoryService directoryService, IImageService imageService) { - private static readonly ChapterSortComparerZeroFirst ChapterSortComparerForInChapterSorting = new (); + Console.WriteLine("Migrating Cover Images to disk. Expect delay."); + directoryService.ExistOrCreate(directoryService.CoverImageDirectory); - /// - /// Run first. Will extract byte[]s from DB and write them to the cover directory. - /// - public static void ExtractToImages(DbContext context, IDirectoryService directoryService, IImageService imageService) + Console.WriteLine("Extracting cover images for Series"); + var lockedSeries = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage From Series Where CoverImage IS NOT NULL", x => + new CoverMigration() + { + Id = x[0] + string.Empty, + CoverImage = (byte[]) x[1], + ParentId = "0" + }); + foreach (var series in lockedSeries) { - Console.WriteLine("Migrating Cover Images to disk. Expect delay."); - directoryService.ExistOrCreate(directoryService.CoverImageDirectory); - - Console.WriteLine("Extracting cover images for Series"); - var lockedSeries = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage From Series Where CoverImage IS NOT NULL", x => - new CoverMigration() - { - Id = x[0] + string.Empty, - CoverImage = (byte[]) x[1], - ParentId = "0" - }); - foreach (var series in lockedSeries) - { - if (series.CoverImage == null || !series.CoverImage.Any()) continue; - if (File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetSeriesFormat(int.Parse(series.Id))}.png"))) continue; - - try - { - var stream = new MemoryStream(series.CoverImage); - stream.Position = 0; - imageService.WriteCoverThumbnail(stream, ImageService.GetSeriesFormat(int.Parse(series.Id)), directoryService.CoverImageDirectory); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - Console.WriteLine("Extracting cover images for Chapters"); - var chapters = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage, VolumeId From Chapter Where CoverImage IS NOT NULL;", x => - new CoverMigration() - { - Id = x[0] + string.Empty, - CoverImage = (byte[]) x[1], - ParentId = x[2] + string.Empty - }); - foreach (var chapter in chapters) - { - if (chapter.CoverImage == null || !chapter.CoverImage.Any()) continue; - if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}.png"))) continue; - - try - { - var stream = new MemoryStream(chapter.CoverImage); - stream.Position = 0; - imageService.WriteCoverThumbnail(stream, $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}", directoryService.CoverImageDirectory); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - - Console.WriteLine("Extracting cover images for Collection Tags"); - var tags = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage From CollectionTag Where CoverImage IS NOT NULL;", x => - new CoverMigration() - { - Id = x[0] + string.Empty, - CoverImage = (byte[]) x[1] , - ParentId = "0" - }); - foreach (var tag in tags) - { - if (tag.CoverImage == null || !tag.CoverImage.Any()) continue; - if (directoryService.FileSystem.File.Exists(Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}.png"))) continue; - try - { - var stream = new MemoryStream(tag.CoverImage); - stream.Position = 0; - imageService.WriteCoverThumbnail(stream, $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}", directoryService.CoverImageDirectory); - } - catch (Exception e) - { - Console.WriteLine(e); - } - } - } + if (series.CoverImage == null || !series.CoverImage.Any()) continue; + if (File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetSeriesFormat(int.Parse(series.Id))}.png"))) continue; - /// - /// Run after . Will update the DB with names of files that were extracted. - /// - /// - public static async Task UpdateDatabaseWithImages(DataContext context, IDirectoryService directoryService) - { - Console.WriteLine("Updating Series entities"); - var seriesCovers = await context.Series.Where(s => !string.IsNullOrEmpty(s.CoverImage)).ToListAsync(); - foreach (var series in seriesCovers) + try { - if (!directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetSeriesFormat(series.Id)}.png"))) continue; - series.CoverImage = $"{ImageService.GetSeriesFormat(series.Id)}.png"; + var stream = new MemoryStream(series.CoverImage); + stream.Position = 0; + imageService.WriteCoverThumbnail(stream, ImageService.GetSeriesFormat(int.Parse(series.Id)), directoryService.CoverImageDirectory); + } + catch (Exception e) + { + Console.WriteLine(e); } + } - await context.SaveChangesAsync(); + Console.WriteLine("Extracting cover images for Chapters"); + var chapters = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage, VolumeId From Chapter Where CoverImage IS NOT NULL;", x => + new CoverMigration() + { + Id = x[0] + string.Empty, + CoverImage = (byte[]) x[1], + ParentId = x[2] + string.Empty + }); + foreach (var chapter in chapters) + { + if (chapter.CoverImage == null || !chapter.CoverImage.Any()) continue; + if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}.png"))) continue; - Console.WriteLine("Updating Chapter entities"); - var chapters = await context.Chapter.ToListAsync(); - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var chapter in chapters) + try { - if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}.png"))) - { - chapter.CoverImage = $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}.png"; - } + var stream = new MemoryStream(chapter.CoverImage); + stream.Position = 0; + imageService.WriteCoverThumbnail(stream, $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}", directoryService.CoverImageDirectory); + } + catch (Exception e) + { + Console.WriteLine(e); + } + } + Console.WriteLine("Extracting cover images for Collection Tags"); + var tags = SqlHelper.RawSqlQuery(context, "Select Id, CoverImage From CollectionTag Where CoverImage IS NOT NULL;", x => + new CoverMigration() + { + Id = x[0] + string.Empty, + CoverImage = (byte[]) x[1] , + ParentId = "0" + }); + foreach (var tag in tags) + { + if (tag.CoverImage == null || !tag.CoverImage.Any()) continue; + if (directoryService.FileSystem.File.Exists(Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}.png"))) continue; + try + { + var stream = new MemoryStream(tag.CoverImage); + stream.Position = 0; + imageService.WriteCoverThumbnail(stream, $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}", directoryService.CoverImageDirectory); } + catch (Exception e) + { + Console.WriteLine(e); + } + } + } + + /// + /// Run after . Will update the DB with names of files that were extracted. + /// + /// + public static async Task UpdateDatabaseWithImages(DataContext context, IDirectoryService directoryService) + { + Console.WriteLine("Updating Series entities"); + var seriesCovers = await context.Series.Where(s => !string.IsNullOrEmpty(s.CoverImage)).ToListAsync(); + foreach (var series in seriesCovers) + { + if (!directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetSeriesFormat(series.Id)}.png"))) continue; + series.CoverImage = $"{ImageService.GetSeriesFormat(series.Id)}.png"; + } - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); - Console.WriteLine("Updating Volume entities"); - var volumes = await context.Volume.Include(v => v.Chapters).ToListAsync(); - foreach (var volume in volumes) + Console.WriteLine("Updating Chapter entities"); + var chapters = await context.Chapter.ToListAsync(); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (var chapter in chapters) + { + if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}.png"))) { - var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting); - if (firstChapter == null) continue; - if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png"))) - { - volume.CoverImage = $"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png"; - } - + chapter.CoverImage = $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}.png"; } - await context.SaveChangesAsync(); + } - Console.WriteLine("Updating Collection Tag entities"); - var tags = await context.CollectionTag.ToListAsync(); - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - foreach (var tag in tags) - { - if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, - $"{ImageService.GetCollectionTagFormat(tag.Id)}.png"))) - { - tag.CoverImage = $"{ImageService.GetCollectionTagFormat(tag.Id)}.png"; - } + await context.SaveChangesAsync(); + Console.WriteLine("Updating Volume entities"); + var volumes = await context.Volume.Include(v => v.Chapters).ToListAsync(); + foreach (var volume in volumes) + { + var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting); + if (firstChapter == null) continue; + if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png"))) + { + volume.CoverImage = $"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png"; } - await context.SaveChangesAsync(); + } + + await context.SaveChangesAsync(); + + Console.WriteLine("Updating Collection Tag entities"); + var tags = await context.CollectionTag.ToListAsync(); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (var tag in tags) + { + if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, + $"{ImageService.GetCollectionTagFormat(tag.Id)}.png"))) + { + tag.CoverImage = $"{ImageService.GetCollectionTagFormat(tag.Id)}.png"; + } - Console.WriteLine("Cover Image Migration completed"); } + await context.SaveChangesAsync(); + + Console.WriteLine("Cover Image Migration completed"); } + } diff --git a/API/Data/Scanner/Chunk.cs b/API/Data/Scanner/Chunk.cs index 9a9e04f5cd..78091200d6 100644 --- a/API/Data/Scanner/Chunk.cs +++ b/API/Data/Scanner/Chunk.cs @@ -1,21 +1,20 @@ -namespace API.Data.Scanner +namespace API.Data.Scanner; + +/// +/// Represents a set of Entities which is broken up and iterated on +/// +public class Chunk { /// - /// Represents a set of Entities which is broken up and iterated on + /// Total number of entities /// - public class Chunk - { - /// - /// Total number of entities - /// - public int TotalSize { get; set; } - /// - /// Size of each chunk to iterate over - /// - public int ChunkSize { get; set; } - /// - /// Total chunks to iterate over - /// - public int TotalChunks { get; set; } - } + public int TotalSize { get; set; } + /// + /// Size of each chunk to iterate over + /// + public int ChunkSize { get; set; } + /// + /// Total chunks to iterate over + /// + public int TotalChunks { get; set; } } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 97e141eab4..3f55f31f2f 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -14,133 +14,130 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -namespace API.Data +namespace API.Data; + +public static class Seed { - public static class Seed - { - /// - /// Generated on Startup. Seed.SeedSettings must run before - /// - public static ImmutableArray DefaultSettings; + /// + /// Generated on Startup. Seed.SeedSettings must run before + /// + public static ImmutableArray DefaultSettings; - public static readonly ImmutableArray DefaultThemes = ImmutableArray.Create( - new List + public static readonly ImmutableArray DefaultThemes = ImmutableArray.Create( + new List + { + new() { - new() - { - Name = "Dark", - NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize("Dark"), - Provider = ThemeProvider.System, - FileName = "dark.scss", - IsDefault = true, - } - }.ToArray()); - - public static async Task SeedRoles(RoleManager roleManager) + Name = "Dark", + NormalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize("Dark"), + Provider = ThemeProvider.System, + FileName = "dark.scss", + IsDefault = true, + } + }.ToArray()); + + public static async Task SeedRoles(RoleManager roleManager) + { + var roles = typeof(PolicyConstants) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(string)) + .ToDictionary(f => f.Name, + f => (string) f.GetValue(null)).Values + .Select(policyName => new AppRole() {Name = policyName}) + .ToList(); + + foreach (var role in roles) { - var roles = typeof(PolicyConstants) - .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.FieldType == typeof(string)) - .ToDictionary(f => f.Name, - f => (string) f.GetValue(null)).Values - .Select(policyName => new AppRole() {Name = policyName}) - .ToList(); - - foreach (var role in roles) + var exists = await roleManager.RoleExistsAsync(role.Name); + if (!exists) { - var exists = await roleManager.RoleExistsAsync(role.Name); - if (!exists) - { - await roleManager.CreateAsync(role); - } + await roleManager.CreateAsync(role); } } + } - public static async Task SeedThemes(DataContext context) - { - await context.Database.EnsureCreatedAsync(); + public static async Task SeedThemes(DataContext context) + { + await context.Database.EnsureCreatedAsync(); - foreach (var theme in DefaultThemes) + foreach (var theme in DefaultThemes) + { + var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name)); + if (existing == null) { - var existing = context.SiteTheme.FirstOrDefault(s => s.Name.Equals(theme.Name)); - if (existing == null) - { - await context.SiteTheme.AddAsync(theme); - } + await context.SiteTheme.AddAsync(theme); } - - await context.SaveChangesAsync(); } - public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) + await context.SaveChangesAsync(); + } + + public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) + { + await context.Database.EnsureCreatedAsync(); + DefaultSettings = ImmutableArray.Create(new List() { - await context.Database.EnsureCreatedAsync(); - DefaultSettings = ImmutableArray.Create(new List() + new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, + new() {Key = ServerSettingKey.TaskScan, Value = "daily"}, + new() + { + Key = ServerSettingKey.LoggingLevel, Value = "Information" + }, // Not used from DB, but DB is sync with appSettings.json + new() {Key = ServerSettingKey.TaskBackup, Value = "daily"}, + new() { - new() {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, - new() {Key = ServerSettingKey.TaskScan, Value = "daily"}, - new() - { - Key = ServerSettingKey.LoggingLevel, Value = "Information" - }, // Not used from DB, but DB is sync with appSettings.json - new() {Key = ServerSettingKey.TaskBackup, Value = "daily"}, - new() - { - Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory) - }, - new() - { - Key = ServerSettingKey.Port, Value = "5000" - }, // Not used from DB, but DB is sync with appSettings.json - new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, - new() {Key = ServerSettingKey.EnableOpds, Value = "false"}, - new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, - new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, - new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, - new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, - new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, - new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, - new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"}, - new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"}, - new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, - new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, - }.ToArray()); - - foreach (var defaultSetting in DefaultSettings) + Key = ServerSettingKey.BackupDirectory, Value = Path.GetFullPath(DirectoryService.BackupDirectory) + }, + new() { - var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); - if (existing == null) - { - await context.ServerSetting.AddAsync(defaultSetting); - } + Key = ServerSettingKey.Port, Value = "5000" + }, // Not used from DB, but DB is sync with appSettings.json + new() {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, + new() {Key = ServerSettingKey.EnableOpds, Value = "false"}, + new() {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, + new() {Key = ServerSettingKey.BaseUrl, Value = "/"}, + new() {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, + new() {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, + new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, + new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, + new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"}, + new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"}, + new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, + new() {Key = ServerSettingKey.EnableFolderWatching, Value = "false"}, + }.ToArray()); + + foreach (var defaultSetting in DefaultSettings) + { + var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + if (existing == null) + { + await context.ServerSetting.AddAsync(defaultSetting); } + } - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); - // Port and LoggingLevel are managed in appSettings.json. Update the DB values to match - context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = - Configuration.Port + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.LoggingLevel).Value = - Configuration.LogLevel + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = - directoryService.CacheDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = - DirectoryService.BackupDirectory + string.Empty; + // Port and LoggingLevel are managed in appSettings.json. Update the DB values to match + context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = + Configuration.Port + string.Empty; + context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = + directoryService.CacheDirectory + string.Empty; + context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = + DirectoryService.BackupDirectory + string.Empty; - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); - } + } - public static async Task SeedUserApiKeys(DataContext context) - { - await context.Database.EnsureCreatedAsync(); + public static async Task SeedUserApiKeys(DataContext context) + { + await context.Database.EnsureCreatedAsync(); - var users = await context.AppUser.ToListAsync(); - foreach (var user in users.Where(user => string.IsNullOrEmpty(user.ApiKey))) - { - user.ApiKey = HashUtil.ApiKey(); - } - await context.SaveChangesAsync(); + var users = await context.AppUser.ToListAsync(); + foreach (var user in users.Where(user => string.IsNullOrEmpty(user.ApiKey))) + { + user.ApiKey = HashUtil.ApiKey(); } + await context.SaveChangesAsync(); } } diff --git a/API/Entities/AppRole.cs b/API/Entities/AppRole.cs index 8c0d07f965..e27311027b 100644 --- a/API/Entities/AppRole.cs +++ b/API/Entities/AppRole.cs @@ -1,10 +1,9 @@ using System.Collections.Generic; using Microsoft.AspNetCore.Identity; -namespace API.Entities +namespace API.Entities; + +public class AppRole : IdentityRole { - public class AppRole : IdentityRole - { - public ICollection UserRoles { get; set; } - } -} \ No newline at end of file + public ICollection UserRoles { get; set; } +} diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 640860a0f2..da536c96f3 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -5,45 +5,44 @@ using Microsoft.AspNetCore.Identity; -namespace API.Entities -{ - public class AppUser : IdentityUser, IHasConcurrencyToken - { - public DateTime Created { get; set; } = DateTime.Now; - public DateTime LastActive { get; set; } - public ICollection Libraries { get; set; } - public ICollection UserRoles { get; set; } - public ICollection Progresses { get; set; } - public ICollection Ratings { get; set; } - public AppUserPreferences UserPreferences { get; set; } - public ICollection Bookmarks { get; set; } - /// - /// Reading lists associated with this user - /// - public ICollection ReadingLists { get; set; } - /// - /// A list of Series the user want's to read - /// - public ICollection WantToRead { get; set; } - /// - /// An API Key to interact with external services, like OPDS - /// - public string ApiKey { get; set; } - /// - /// The confirmation token for the user (invite). This will be set to null after the user confirms. - /// - public string ConfirmationToken { get; set; } +namespace API.Entities; +public class AppUser : IdentityUser, IHasConcurrencyToken +{ + public DateTime Created { get; set; } = DateTime.Now; + public DateTime LastActive { get; set; } + public ICollection Libraries { get; set; } + public ICollection UserRoles { get; set; } + public ICollection Progresses { get; set; } + public ICollection Ratings { get; set; } + public AppUserPreferences UserPreferences { get; set; } + public ICollection Bookmarks { get; set; } + /// + /// Reading lists associated with this user + /// + public ICollection ReadingLists { get; set; } + /// + /// A list of Series the user want's to read + /// + public ICollection WantToRead { get; set; } + /// + /// An API Key to interact with external services, like OPDS + /// + public string ApiKey { get; set; } + /// + /// The confirmation token for the user (invite). This will be set to null after the user confirms. + /// + public string ConfirmationToken { get; set; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } - /// - public void OnSavingChanges() - { - RowVersion++; - } + /// + [ConcurrencyCheck] + public uint RowVersion { get; private set; } + /// + public void OnSavingChanges() + { + RowVersion++; } + } diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index 6d1ff04507..78d43fc9e3 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -2,30 +2,29 @@ using System.Text.Json.Serialization; using API.Entities.Interfaces; -namespace API.Entities +namespace API.Entities; + +/// +/// Represents a saved page in a Chapter entity for a given user. +/// +public class AppUserBookmark : IEntityDate { + public int Id { get; set; } + public int Page { get; set; } + public int VolumeId { get; set; } + public int SeriesId { get; set; } + public int ChapterId { get; set; } + /// - /// Represents a saved page in a Chapter entity for a given user. + /// Filename in the Bookmark Directory /// - public class AppUserBookmark : IEntityDate - { - public int Id { get; set; } - public int Page { get; set; } - public int VolumeId { get; set; } - public int SeriesId { get; set; } - public int ChapterId { get; set; } - - /// - /// Filename in the Bookmark Directory - /// - public string FileName { get; set; } = string.Empty; + public string FileName { get; set; } = string.Empty; - // Relationships - [JsonIgnore] - public AppUser AppUser { get; set; } - public int AppUserId { get; set; } - public DateTime Created { get; set; } - public DateTime LastModified { get; set; } - } + // Relationships + [JsonIgnore] + public AppUser AppUser { get; set; } + public int AppUserId { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } } diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 477f379998..d96736f196 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -1,109 +1,108 @@ using API.Entities.Enums; using API.Entities.Enums.UserPreferences; -namespace API.Entities +namespace API.Entities; + +public class AppUserPreferences { - public class AppUserPreferences - { - public int Id { get; set; } - /// - /// Manga Reader Option: What direction should the next/prev page buttons go - /// - public ReadingDirection ReadingDirection { get; set; } = ReadingDirection.LeftToRight; - /// - /// Manga Reader Option: How should the image be scaled to screen - /// - public ScalingOption ScalingOption { get; set; } = ScalingOption.Automatic; - /// - /// Manga Reader Option: Which side of a split image should we show first - /// - public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit; - /// - /// Manga Reader Option: How the manga reader should perform paging or reading of the file - /// - /// Webtoon uses scrolling to page, MANGA_LR uses paging by clicking left/right side of reader, MANGA_UD uses paging - /// by clicking top/bottom sides of reader. - /// - /// - public ReaderMode ReaderMode { get; set; } + public int Id { get; set; } + /// + /// Manga Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection ReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// Manga Reader Option: How should the image be scaled to screen + /// + public ScalingOption ScalingOption { get; set; } = ScalingOption.Automatic; + /// + /// Manga Reader Option: Which side of a split image should we show first + /// + public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit; + /// + /// Manga Reader Option: How the manga reader should perform paging or reading of the file + /// + /// Webtoon uses scrolling to page, MANGA_LR uses paging by clicking left/right side of reader, MANGA_UD uses paging + /// by clicking top/bottom sides of reader. + /// + /// + public ReaderMode ReaderMode { get; set; } - /// - /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction - /// - public bool AutoCloseMenu { get; set; } = true; - /// - /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change - /// - public bool ShowScreenHints { get; set; } = true; - /// - /// Manga Reader Option: How many pages to display in the reader at once - /// - public LayoutMode LayoutMode { get; set; } = LayoutMode.Single; - /// - /// Manga Reader Option: Background color of the reader - /// - public string BackgroundColor { get; set; } = "#000000"; - /// - /// Book Reader Option: Override extra Margin - /// - public int BookReaderMargin { get; set; } = 15; - /// - /// Book Reader Option: Override line-height - /// - public int BookReaderLineSpacing { get; set; } = 100; - /// - /// Book Reader Option: Override font size - /// - public int BookReaderFontSize { get; set; } = 100; - /// - /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override - /// - public string BookReaderFontFamily { get; set; } = "default"; - /// - /// Book Reader Option: Allows tapping on side of screens to paginate - /// - public bool BookReaderTapToPaginate { get; set; } = false; - /// - /// Book Reader Option: What direction should the next/prev page buttons go - /// - public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; - /// - /// UI Site Global Setting: The UI theme the user should use. - /// - /// Should default to Dark - public SiteTheme Theme { get; set; } - /// - /// Book Reader Option: The color theme to decorate the book contents - /// - /// Should default to Dark - public string BookThemeName { get; set; } = "Dark"; - /// - /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, - /// 2 column is fit to height, 2 columns - /// - /// Defaults to Default - public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default; - /// - /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. - /// - /// Defaults to false - public bool BookReaderImmersiveMode { get; set; } = false; - /// - /// Global Site Option: If the UI should layout items as Cards or List items - /// - /// Defaults to Cards - public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; - /// - /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already - /// - /// Defaults to false - public bool BlurUnreadSummaries { get; set; } = false; - /// - /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. - /// - public bool PromptForDownloadSize { get; set; } = true; + /// + /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction + /// + public bool AutoCloseMenu { get; set; } = true; + /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + public LayoutMode LayoutMode { get; set; } = LayoutMode.Single; + /// + /// Manga Reader Option: Background color of the reader + /// + public string BackgroundColor { get; set; } = "#000000"; + /// + /// Book Reader Option: Override extra Margin + /// + public int BookReaderMargin { get; set; } = 15; + /// + /// Book Reader Option: Override line-height + /// + public int BookReaderLineSpacing { get; set; } = 100; + /// + /// Book Reader Option: Override font size + /// + public int BookReaderFontSize { get; set; } = 100; + /// + /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override + /// + public string BookReaderFontFamily { get; set; } = "default"; + /// + /// Book Reader Option: Allows tapping on side of screens to paginate + /// + public bool BookReaderTapToPaginate { get; set; } = false; + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// + public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; + /// + /// UI Site Global Setting: The UI theme the user should use. + /// + /// Should default to Dark + public SiteTheme Theme { get; set; } + /// + /// Book Reader Option: The color theme to decorate the book contents + /// + /// Should default to Dark + public string BookThemeName { get; set; } = "Dark"; + /// + /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, + /// 2 column is fit to height, 2 columns + /// + /// Defaults to Default + public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default; + /// + /// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this. + /// + /// Defaults to false + public bool BookReaderImmersiveMode { get; set; } = false; + /// + /// Global Site Option: If the UI should layout items as Cards or List items + /// + /// Defaults to Cards + public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards; + /// + /// UI Site Global Setting: If unread summaries should be blurred until expanded or unless user has read it already + /// + /// Defaults to false + public bool BlurUnreadSummaries { get; set; } = false; + /// + /// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB. + /// + public bool PromptForDownloadSize { get; set; } = true; - public AppUser AppUser { get; set; } - public int AppUserId { get; set; } - } + public AppUser AppUser { get; set; } + public int AppUserId { get; set; } } diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 1704628cb6..6804bfa988 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -2,56 +2,55 @@ using System; using API.Entities.Interfaces; -namespace API.Entities +namespace API.Entities; + +/// +/// Represents the progress a single user has on a given Chapter. +/// +//[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)] +public class AppUserProgress : IEntityDate { /// - /// Represents the progress a single user has on a given Chapter. - /// - //[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)] - public class AppUserProgress : IEntityDate - { - /// - /// Id of Entity - /// - public int Id { get; set; } - /// - /// Pages Read for given Chapter - /// - public int PagesRead { get; set; } - /// - /// Volume belonging to Chapter - /// - public int VolumeId { get; set; } - /// - /// Series belonging to Chapter - /// - public int SeriesId { get; set; } - /// - /// Chapter - /// - public int ChapterId { get; set; } - /// - /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point - /// on next load - /// - public string BookScrollId { get; set; } - /// - /// When this was first created - /// - public DateTime Created { get; set; } - /// - /// Last date this was updated - /// - public DateTime LastModified { get; set; } + /// Id of Entity + /// + public int Id { get; set; } + /// + /// Pages Read for given Chapter + /// + public int PagesRead { get; set; } + /// + /// Volume belonging to Chapter + /// + public int VolumeId { get; set; } + /// + /// Series belonging to Chapter + /// + public int SeriesId { get; set; } + /// + /// Chapter + /// + public int ChapterId { get; set; } + /// + /// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point + /// on next load + /// + public string BookScrollId { get; set; } + /// + /// When this was first created + /// + public DateTime Created { get; set; } + /// + /// Last date this was updated + /// + public DateTime LastModified { get; set; } - // Relationships - /// - /// Navigational Property for EF. Links to a unique AppUser - /// - public AppUser AppUser { get; set; } - /// - /// User this progress belongs to - /// - public int AppUserId { get; set; } - } + // Relationships + /// + /// Navigational Property for EF. Links to a unique AppUser + /// + public AppUser AppUser { get; set; } + /// + /// User this progress belongs to + /// + public int AppUserId { get; set; } } diff --git a/API/Entities/AppUserRating.cs b/API/Entities/AppUserRating.cs index ca176e7ae0..54376bbd1c 100644 --- a/API/Entities/AppUserRating.cs +++ b/API/Entities/AppUserRating.cs @@ -1,22 +1,21 @@  -namespace API.Entities +namespace API.Entities; + +public class AppUserRating { - public class AppUserRating - { - public int Id { get; set; } - /// - /// A number between 0-5 that represents how good a series is. - /// - public int Rating { get; set; } - /// - /// A short summary the user can write when giving their review. - /// - public string Review { get; set; } - public int SeriesId { get; set; } - - - // Relationships - public int AppUserId { get; set; } - public AppUser AppUser { get; set; } - } -} \ No newline at end of file + public int Id { get; set; } + /// + /// A number between 0-5 that represents how good a series is. + /// + public int Rating { get; set; } + /// + /// A short summary the user can write when giving their review. + /// + public string Review { get; set; } + public int SeriesId { get; set; } + + + // Relationships + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } +} diff --git a/API/Entities/AppUserRole.cs b/API/Entities/AppUserRole.cs index b4c73f87ea..09ccbce6cb 100644 --- a/API/Entities/AppUserRole.cs +++ b/API/Entities/AppUserRole.cs @@ -1,10 +1,9 @@ using Microsoft.AspNetCore.Identity; -namespace API.Entities +namespace API.Entities; + +public class AppUserRole : IdentityUserRole { - public class AppUserRole : IdentityUserRole - { - public AppUser User { get; set; } - public AppRole Role { get; set; } - } -} \ No newline at end of file + public AppUser User { get; set; } + public AppRole Role { get; set; } +} diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index de989a5037..eb0cecc175 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -5,116 +5,115 @@ using API.Parser; using API.Services; -namespace API.Entities +namespace API.Entities; + +public class Chapter : IEntityDate, IHasReadTimeEstimate { - public class Chapter : IEntityDate, IHasReadTimeEstimate - { - public int Id { get; set; } - /// - /// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". - /// - public string Range { get; set; } - /// - /// Smallest number of the Range. Can be a partial like Chapter 4.5 - /// - public string Number { get; set; } - /// - /// The files that represent this Chapter - /// - public ICollection Files { get; set; } - public DateTime Created { get; set; } - public DateTime LastModified { get; set; } - /// - /// Relative path to the (managed) image file representing the cover image - /// - /// The file is managed internally to Kavita's APPDIR - public string CoverImage { get; set; } - public bool CoverImageLocked { get; set; } - /// - /// Total number of pages in all MangaFiles - /// - public int Pages { get; set; } - /// - /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename - /// - public bool IsSpecial { get; set; } - /// - /// Used for books/specials to display custom title. For non-specials/books, will be set to - /// - public string Title { get; set; } - /// - /// Age Rating for the issue/chapter - /// - public AgeRating AgeRating { get; set; } + public int Id { get; set; } + /// + /// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// + public string Range { get; set; } + /// + /// Smallest number of the Range. Can be a partial like Chapter 4.5 + /// + public string Number { get; set; } + /// + /// The files that represent this Chapter + /// + public ICollection Files { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + /// + /// Relative path to the (managed) image file representing the cover image + /// + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + public bool CoverImageLocked { get; set; } + /// + /// Total number of pages in all MangaFiles + /// + public int Pages { get; set; } + /// + /// If this Chapter contains files that could only be identified as Series or has Special Identifier from filename + /// + public bool IsSpecial { get; set; } + /// + /// Used for books/specials to display custom title. For non-specials/books, will be set to + /// + public string Title { get; set; } + /// + /// Age Rating for the issue/chapter + /// + public AgeRating AgeRating { get; set; } - /// - /// Chapter title - /// - /// This should not be confused with Title which is used for special filenames. - public string TitleName { get; set; } = string.Empty; - /// - /// Date which chapter was released - /// - public DateTime ReleaseDate { get; set; } - /// - /// Summary for the Chapter/Issue - /// - public string Summary { get; set; } - /// - /// Language for the Chapter/Issue - /// - public string Language { get; set; } - /// - /// Total number of issues in the series - /// - public int TotalCount { get; set; } = 0; - /// - /// Number in the Total Count - /// - public int Count { get; set; } = 0; + /// + /// Chapter title + /// + /// This should not be confused with Title which is used for special filenames. + public string TitleName { get; set; } = string.Empty; + /// + /// Date which chapter was released + /// + public DateTime ReleaseDate { get; set; } + /// + /// Summary for the Chapter/Issue + /// + public string Summary { get; set; } + /// + /// Language for the Chapter/Issue + /// + public string Language { get; set; } + /// + /// Total number of issues in the series + /// + public int TotalCount { get; set; } = 0; + /// + /// Number in the Total Count + /// + public int Count { get; set; } = 0; - /// - /// Total Word count of all chapters in this chapter. - /// - /// Word Count is only available from EPUB files - public long WordCount { get; set; } - /// - public int MinHoursToRead { get; set; } - /// - public int MaxHoursToRead { get; set; } - /// - public int AvgHoursToRead { get; set; } + /// + /// Total Word count of all chapters in this chapter. + /// + /// Word Count is only available from EPUB files + public long WordCount { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public int AvgHoursToRead { get; set; } - /// - /// All people attached at a Chapter level. Usually Comics will have different people per issue. - /// - public ICollection People { get; set; } = new List(); - /// - /// Genres for the Chapter - /// - public ICollection Genres { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); + /// + /// All people attached at a Chapter level. Usually Comics will have different people per issue. + /// + public ICollection People { get; set; } = new List(); + /// + /// Genres for the Chapter + /// + public ICollection Genres { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); - // Relationships - public Volume Volume { get; set; } - public int VolumeId { get; set; } + // Relationships + public Volume Volume { get; set; } + public int VolumeId { get; set; } - public void UpdateFrom(ParserInfo info) + public void UpdateFrom(ParserInfo info) + { + Files ??= new List(); + IsSpecial = info.IsSpecialInfo(); + if (IsSpecial) { - Files ??= new List(); - IsSpecial = info.IsSpecialInfo(); - if (IsSpecial) - { - Number = "0"; - } - Title = (IsSpecial && info.Format == MangaFormat.Epub) - ? info.Title - : Range; - + Number = "0"; } + Title = (IsSpecial && info.Format == MangaFormat.Epub) + ? info.Title + : Range; + } } diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index b38960f899..f32e981e9d 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -2,56 +2,55 @@ using API.Entities.Metadata; using Microsoft.EntityFrameworkCore; -namespace API.Entities +namespace API.Entities; + +/// +/// Represents a user entered field that is used as a tagging and grouping mechanism +/// +[Index(nameof(Id), nameof(Promoted), IsUnique = true)] +public class CollectionTag { + public int Id { get; set; } /// - /// Represents a user entered field that is used as a tagging and grouping mechanism + /// Visible title of the Tag /// - [Index(nameof(Id), nameof(Promoted), IsUnique = true)] - public class CollectionTag - { - public int Id { get; set; } - /// - /// Visible title of the Tag - /// - public string Title { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR - public string CoverImage { get; set; } - /// - /// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations. - /// - public bool CoverImageLocked { get; set; } + public string Title { get; set; } + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + /// + /// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations. + /// + public bool CoverImageLocked { get; set; } - /// - /// A description of the tag - /// - public string Summary { get; set; } + /// + /// A description of the tag + /// + public string Summary { get; set; } - /// - /// A normalized string used to check if the tag already exists in the DB - /// - public string NormalizedTitle { get; set; } - /// - /// A promoted collection tag will allow all linked seriesMetadata's Series to show for all users. - /// - public bool Promoted { get; set; } + /// + /// A normalized string used to check if the tag already exists in the DB + /// + public string NormalizedTitle { get; set; } + /// + /// A promoted collection tag will allow all linked seriesMetadata's Series to show for all users. + /// + public bool Promoted { get; set; } - public ICollection SeriesMetadatas { get; set; } + public ICollection SeriesMetadatas { get; set; } - /// - /// Not Used due to not using concurrency update - /// - public uint RowVersion { get; private set; } + /// + /// Not Used due to not using concurrency update + /// + public uint RowVersion { get; private set; } - /// - /// Not Used due to not using concurrency update - /// - public void OnSavingChanges() - { - RowVersion++; - } + /// + /// Not Used due to not using concurrency update + /// + public void OnSavingChanges() + { + RowVersion++; } } diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index dd2c83b923..5f4ab1cc79 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -1,23 +1,22 @@ using System.ComponentModel; -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum LibraryType { - public enum LibraryType - { - /// - /// Uses Manga regex for filename parsing - /// - [Description("Manga")] - Manga = 0, - /// - /// Uses Comic regex for filename parsing - /// - [Description("Comic")] - Comic = 1, - /// - /// Uses Manga regex for filename parsing also uses epub metadata - /// - [Description("Book")] - Book = 2, - } + /// + /// Uses Manga regex for filename parsing + /// + [Description("Manga")] + Manga = 0, + /// + /// Uses Comic regex for filename parsing + /// + [Description("Comic")] + Comic = 1, + /// + /// Uses Manga regex for filename parsing also uses epub metadata + /// + [Description("Book")] + Book = 2, } diff --git a/API/Entities/Enums/MangaFormat.cs b/API/Entities/Enums/MangaFormat.cs index 07e34ed779..cea5064717 100644 --- a/API/Entities/Enums/MangaFormat.cs +++ b/API/Entities/Enums/MangaFormat.cs @@ -1,38 +1,37 @@ using System.ComponentModel; -namespace API.Entities.Enums +namespace API.Entities.Enums; + +/// +/// Represents the format of the file +/// +public enum MangaFormat { /// - /// Represents the format of the file + /// Image file + /// See for supported extensions + /// + [Description("Image")] + Image = 0, + /// + /// Archive based file + /// See for supported extensions + /// + [Description("Archive")] + Archive = 1, + /// + /// Unknown. Not used. + /// + [Description("Unknown")] + Unknown = 2, + /// + /// EPUB File + /// + [Description("EPUB")] + Epub = 3, + /// + /// PDF File /// - public enum MangaFormat - { - /// - /// Image file - /// See for supported extensions - /// - [Description("Image")] - Image = 0, - /// - /// Archive based file - /// See for supported extensions - /// - [Description("Archive")] - Archive = 1, - /// - /// Unknown. Not used. - /// - [Description("Unknown")] - Unknown = 2, - /// - /// EPUB File - /// - [Description("EPUB")] - Epub = 3, - /// - /// PDF File - /// - [Description("PDF")] - Pdf = 4 - } + [Description("PDF")] + Pdf = 4 } diff --git a/API/Entities/Enums/PageSplitOption.cs b/API/Entities/Enums/PageSplitOption.cs index 5234a4cce5..7b421240c7 100644 --- a/API/Entities/Enums/PageSplitOption.cs +++ b/API/Entities/Enums/PageSplitOption.cs @@ -1,10 +1,9 @@ -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum PageSplitOption { - public enum PageSplitOption - { - SplitLeftToRight = 0, - SplitRightToLeft = 1, - NoSplit = 2, - FitSplit = 3 - } + SplitLeftToRight = 0, + SplitRightToLeft = 1, + NoSplit = 2, + FitSplit = 3 } diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs index 238c808a03..bd84985c04 100644 --- a/API/Entities/Enums/PersonRole.cs +++ b/API/Entities/Enums/PersonRole.cs @@ -1,31 +1,30 @@ -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum PersonRole { - public enum PersonRole - { - /// - /// Another role, not covered by other types - /// - Other = 1, - /// - /// Author or Writer - /// - Writer = 3, - Penciller = 4, - Inker = 5, - Colorist = 6, - Letterer = 7, - CoverArtist = 8, - Editor = 9, - Publisher = 10, - /// - /// Represents a character/person within the story - /// - Character = 11, - /// - /// The Translator - /// - Translator = 12 + /// + /// Another role, not covered by other types + /// + Other = 1, + /// + /// Author or Writer + /// + Writer = 3, + Penciller = 4, + Inker = 5, + Colorist = 6, + Letterer = 7, + CoverArtist = 8, + Editor = 9, + Publisher = 10, + /// + /// Represents a character/person within the story + /// + Character = 11, + /// + /// The Translator + /// + Translator = 12 - } } diff --git a/API/Entities/Enums/ReaderMode.cs b/API/Entities/Enums/ReaderMode.cs index 94776252b9..e1353ad59d 100644 --- a/API/Entities/Enums/ReaderMode.cs +++ b/API/Entities/Enums/ReaderMode.cs @@ -1,14 +1,13 @@ using System.ComponentModel; -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum ReaderMode { - public enum ReaderMode - { - [Description("Left and Right")] - LeftRight = 0, - [Description("Up and Down")] - UpDown = 1, - [Description("Webtoon")] - Webtoon = 2 - } + [Description("Left and Right")] + LeftRight = 0, + [Description("Up and Down")] + UpDown = 1, + [Description("Webtoon")] + Webtoon = 2 } diff --git a/API/Entities/Enums/ReadingDirection.cs b/API/Entities/Enums/ReadingDirection.cs index e702970c9f..8804ca6d45 100644 --- a/API/Entities/Enums/ReadingDirection.cs +++ b/API/Entities/Enums/ReadingDirection.cs @@ -1,8 +1,7 @@ -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum ReadingDirection { - public enum ReadingDirection - { - LeftToRight = 0, - RightToLeft = 1 - } -} \ No newline at end of file + LeftToRight = 0, + RightToLeft = 1 +} diff --git a/API/Entities/Enums/ScalingOption.cs b/API/Entities/Enums/ScalingOption.cs index 2da3b79f75..f0b3578980 100644 --- a/API/Entities/Enums/ScalingOption.cs +++ b/API/Entities/Enums/ScalingOption.cs @@ -1,10 +1,9 @@ -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum ScalingOption { - public enum ScalingOption - { - FitToHeight = 0, - FitToWidth = 1, - Original = 2, - Automatic = 3 - } -} \ No newline at end of file + FitToHeight = 0, + FitToWidth = 1, + Original = 2, + Automatic = 3 +} diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 3fcf938b21..16eec2cec4 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -1,100 +1,99 @@ using System.ComponentModel; -namespace API.Entities.Enums +namespace API.Entities.Enums; + +public enum ServerSettingKey { - public enum ServerSettingKey - { - /// - /// Cron format for how often full library scans are performed. - /// - [Description("TaskScan")] - TaskScan = 0, - /// - /// Where files are cached. Not currently used. - /// - [Description("CacheDirectory")] - CacheDirectory = 1, - /// - /// Cron format for how often backups are taken. - /// - [Description("TaskBackup")] - TaskBackup = 2, - /// - /// Logging level for Server. Not managed in DB. Managed in appsettings.json and synced to DB. - /// - [Description("LoggingLevel")] - LoggingLevel = 3, - /// - /// Port server listens on. Not managed in DB. Managed in appsettings.json and synced to DB. - /// - [Description("Port")] - Port = 4, - /// - /// Where the backups are stored. - /// - [Description("BackupDirectory")] - BackupDirectory = 5, - /// - /// Allow anonymous data to be reported to KavitaStats - /// - [Description("AllowStatCollection")] - AllowStatCollection = 6, - /// - /// Is OPDS enabled for the server - /// - [Description("EnableOpds")] - EnableOpds = 7, - /// - /// Is Authentication needed for non-admin accounts - /// - /// Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect - [Description("EnableAuthentication")] - EnableAuthentication = 8, - /// - /// Base Url for the server. Not Implemented. - /// - [Description("BaseUrl")] - BaseUrl = 9, - /// - /// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files. - /// - [Description("InstallId")] - InstallId = 10, - /// - /// Represents the version the software is running. - /// - /// This will be updated on Startup to the latest release. Provides ability to detect if certain migrations need to be run. - [Description("InstallVersion")] - InstallVersion = 11, - /// - /// Location of where bookmarks are stored - /// - [Description("BookmarkDirectory")] - BookmarkDirectory = 12, - /// - /// If SMTP is enabled on the server - /// - [Description("CustomEmailService")] - EmailServiceUrl = 13, - /// - /// If Kavita should save bookmarks as WebP images - /// - [Description("ConvertBookmarkToWebP")] - ConvertBookmarkToWebP = 14, - /// - /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. - /// - [Description("EnableSwaggerUi")] - EnableSwaggerUi = 15, - /// - /// Total Number of Backups to maintain before cleaning. Default 30, min 1. - /// - [Description("TotalBackups")] - TotalBackups = 16, - /// - /// If Kavita should watch the library folders and process changes - /// - [Description("EnableFolderWatching")] - EnableFolderWatching = 17, - } + /// + /// Cron format for how often full library scans are performed. + /// + [Description("TaskScan")] + TaskScan = 0, + /// + /// Where files are cached. Not currently used. + /// + [Description("CacheDirectory")] + CacheDirectory = 1, + /// + /// Cron format for how often backups are taken. + /// + [Description("TaskBackup")] + TaskBackup = 2, + /// + /// Logging level for Server. Not managed in DB. Managed in appsettings.json and synced to DB. + /// + [Description("LoggingLevel")] + LoggingLevel = 3, + /// + /// Port server listens on. Not managed in DB. Managed in appsettings.json and synced to DB. + /// + [Description("Port")] + Port = 4, + /// + /// Where the backups are stored. + /// + [Description("BackupDirectory")] + BackupDirectory = 5, + /// + /// Allow anonymous data to be reported to KavitaStats + /// + [Description("AllowStatCollection")] + AllowStatCollection = 6, + /// + /// Is OPDS enabled for the server + /// + [Description("EnableOpds")] + EnableOpds = 7, + /// + /// Is Authentication needed for non-admin accounts + /// + /// Deprecated. This is no longer used v0.5.1+. Assume Authentication is always in effect + [Description("EnableAuthentication")] + EnableAuthentication = 8, + /// + /// Base Url for the server. Not Implemented. + /// + [Description("BaseUrl")] + BaseUrl = 9, + /// + /// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files. + /// + [Description("InstallId")] + InstallId = 10, + /// + /// Represents the version the software is running. + /// + /// This will be updated on Startup to the latest release. Provides ability to detect if certain migrations need to be run. + [Description("InstallVersion")] + InstallVersion = 11, + /// + /// Location of where bookmarks are stored + /// + [Description("BookmarkDirectory")] + BookmarkDirectory = 12, + /// + /// If SMTP is enabled on the server + /// + [Description("CustomEmailService")] + EmailServiceUrl = 13, + /// + /// If Kavita should save bookmarks as WebP images + /// + [Description("ConvertBookmarkToWebP")] + ConvertBookmarkToWebP = 14, + /// + /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. + /// + [Description("EnableSwaggerUi")] + EnableSwaggerUi = 15, + /// + /// Total Number of Backups to maintain before cleaning. Default 30, min 1. + /// + [Description("TotalBackups")] + TotalBackups = 16, + /// + /// If Kavita should watch the library folders and process changes + /// + [Description("EnableFolderWatching")] + EnableFolderWatching = 17, } diff --git a/API/Entities/FolderPath.cs b/API/Entities/FolderPath.cs index 20ba4f466b..fe0e734934 100644 --- a/API/Entities/FolderPath.cs +++ b/API/Entities/FolderPath.cs @@ -1,20 +1,19 @@  using System; -namespace API.Entities +namespace API.Entities; + +public class FolderPath { - public class FolderPath - { - public int Id { get; set; } - public string Path { get; set; } - /// - /// Used when scanning to see if we can skip if nothing has changed - /// - /// Time stored in UTC - public DateTime LastScanned { get; set; } + public int Id { get; set; } + public string Path { get; set; } + /// + /// Used when scanning to see if we can skip if nothing has changed + /// + /// Time stored in UTC + public DateTime LastScanned { get; set; } - // Relationship - public Library Library { get; set; } - public int LibraryId { get; set; } - } + // Relationship + public Library Library { get; set; } + public int LibraryId { get; set; } } diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs index 447f14943b..ec9cdde0e6 100644 --- a/API/Entities/Genre.cs +++ b/API/Entities/Genre.cs @@ -2,17 +2,16 @@ using API.Entities.Metadata; using Microsoft.EntityFrameworkCore; -namespace API.Entities +namespace API.Entities; + +[Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] +public class Genre { - [Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] - public class Genre - { - public int Id { get; set; } - public string Title { get; set; } - public string NormalizedTitle { get; set; } - public bool ExternalTag { get; set; } + public int Id { get; set; } + public string Title { get; set; } + public string NormalizedTitle { get; set; } + public bool ExternalTag { get; set; } - public ICollection SeriesMetadatas { get; set; } - public ICollection Chapters { get; set; } - } + public ICollection SeriesMetadatas { get; set; } + public ICollection Chapters { get; set; } } diff --git a/API/Entities/Interfaces/IEntityDate.cs b/API/Entities/Interfaces/IEntityDate.cs index 79330546e2..11b4e89695 100644 --- a/API/Entities/Interfaces/IEntityDate.cs +++ b/API/Entities/Interfaces/IEntityDate.cs @@ -1,10 +1,9 @@ using System; -namespace API.Entities.Interfaces +namespace API.Entities.Interfaces; + +public interface IEntityDate { - public interface IEntityDate - { - DateTime Created { get; set; } - DateTime LastModified { get; set; } - } -} \ No newline at end of file + DateTime Created { get; set; } + DateTime LastModified { get; set; } +} diff --git a/API/Entities/Interfaces/IHasConcurrencyToken.cs b/API/Entities/Interfaces/IHasConcurrencyToken.cs index 9372f1eb78..3cd3f1adf6 100644 --- a/API/Entities/Interfaces/IHasConcurrencyToken.cs +++ b/API/Entities/Interfaces/IHasConcurrencyToken.cs @@ -1,19 +1,18 @@ -namespace API.Entities.Interfaces +namespace API.Entities.Interfaces; + +/// +/// An interface abstracting an entity that has a concurrency token. +/// +public interface IHasConcurrencyToken { /// - /// An interface abstracting an entity that has a concurrency token. + /// Gets the version of this row. Acts as a concurrency token. /// - public interface IHasConcurrencyToken - { - /// - /// Gets the version of this row. Acts as a concurrency token. - /// - uint RowVersion { get; } + uint RowVersion { get; } - /// - /// Called when saving changes to this entity. - /// - void OnSavingChanges(); + /// + /// Called when saving changes to this entity. + /// + void OnSavingChanges(); - } -} \ No newline at end of file +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index fd9956b1f6..819bf76d5c 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -5,39 +5,38 @@ using API.Entities.Enums; using API.Entities.Interfaces; -namespace API.Entities -{ - public class Library : IEntityDate - { - public int Id { get; set; } - public string Name { get; set; } - /// - /// Update this summary with a way it's used, else let's remove it. - /// - [Obsolete("This has never been coded for. Likely we can remove it.")] - public string CoverImage { get; set; } - public LibraryType Type { get; set; } - public DateTime Created { get; set; } - public DateTime LastModified { get; set; } - /// - /// Last time Library was scanned - /// - /// Time stored in UTC - public DateTime LastScanned { get; set; } - public ICollection Folders { get; set; } - public ICollection AppUsers { get; set; } - public ICollection Series { get; set; } +namespace API.Entities; - // Methods - /// - /// Has there been any modifications to the FolderPath's directory since the date - /// - /// - public bool AnyModificationsSinceLastScan() - { - // NOTE: I don't think we can do this due to NTFS - return Folders.All(folder => File.GetLastWriteTimeUtc(folder.Path) > folder.LastScanned); - } +public class Library : IEntityDate +{ + public int Id { get; set; } + public string Name { get; set; } + /// + /// Update this summary with a way it's used, else let's remove it. + /// + [Obsolete("This has never been coded for. Likely we can remove it.")] + public string CoverImage { get; set; } + public LibraryType Type { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + /// + /// Last time Library was scanned + /// + /// Time stored in UTC + public DateTime LastScanned { get; set; } + public ICollection Folders { get; set; } + public ICollection AppUsers { get; set; } + public ICollection Series { get; set; } + // Methods + /// + /// Has there been any modifications to the FolderPath's directory since the date + /// + /// + public bool AnyModificationsSinceLastScan() + { + // NOTE: I don't think we can do this due to NTFS + return Folders.All(folder => File.GetLastWriteTimeUtc(folder.Path) > folder.LastScanned); } + } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index da0a619248..5f78dd7f74 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -4,48 +4,47 @@ using API.Entities.Enums; using API.Entities.Interfaces; -namespace API.Entities +namespace API.Entities; + +/// +/// Represents a wrapper to the underlying file. This provides information around file, like number of pages, format, etc. +/// +public class MangaFile : IEntityDate { + public int Id { get; set; } /// - /// Represents a wrapper to the underlying file. This provides information around file, like number of pages, format, etc. + /// Absolute path to the archive file /// - public class MangaFile : IEntityDate - { - public int Id { get; set; } - /// - /// Absolute path to the archive file - /// - public string FilePath { get; set; } - /// - /// Number of pages for the given file - /// - public int Pages { get; set; } - public MangaFormat Format { get; set; } - /// - public DateTime Created { get; set; } + public string FilePath { get; set; } + /// + /// Number of pages for the given file + /// + public int Pages { get; set; } + public MangaFormat Format { get; set; } + /// + public DateTime Created { get; set; } - /// - /// Last time underlying file was modified - /// - /// This gets updated anytime the file is scanned - public DateTime LastModified { get; set; } - /// - /// Last time file analysis ran on this file - /// - public DateTime LastFileAnalysis { get; set; } + /// + /// Last time underlying file was modified + /// + /// This gets updated anytime the file is scanned + public DateTime LastModified { get; set; } + /// + /// Last time file analysis ran on this file + /// + public DateTime LastFileAnalysis { get; set; } - // Relationship Mapping - public Chapter Chapter { get; set; } - public int ChapterId { get; set; } + // Relationship Mapping + public Chapter Chapter { get; set; } + public int ChapterId { get; set; } - /// - /// Updates the Last Modified time of the underlying file to the LastWriteTime - /// - public void UpdateLastModified() - { - LastModified = File.GetLastWriteTime(FilePath); - } + /// + /// Updates the Last Modified time of the underlying file to the LastWriteTime + /// + public void UpdateLastModified() + { + LastModified = File.GetLastWriteTime(FilePath); } } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 98e9fa8e96..6f659c0e16 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -4,84 +4,83 @@ using API.Entities.Interfaces; using Microsoft.EntityFrameworkCore; -namespace API.Entities.Metadata +namespace API.Entities.Metadata; + +[Index(nameof(Id), nameof(SeriesId), IsUnique = true)] +public class SeriesMetadata : IHasConcurrencyToken { - [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] - public class SeriesMetadata : IHasConcurrencyToken - { - public int Id { get; set; } + public int Id { get; set; } - public string Summary { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; - public ICollection CollectionTags { get; set; } + public ICollection CollectionTags { get; set; } - public ICollection Genres { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); - /// - /// All people attached at a Series level. - /// - public ICollection People { get; set; } = new List(); + public ICollection Genres { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); + /// + /// All people attached at a Series level. + /// + public ICollection People { get; set; } = new List(); - /// - /// Highest Age Rating from all Chapters - /// - public AgeRating AgeRating { get; set; } - /// - /// Earliest Year from all chapters - /// - public int ReleaseYear { get; set; } - /// - /// Language of the content (BCP-47 code) - /// - public string Language { get; set; } = string.Empty; - /// - /// Total number of issues/volumes in the series - /// - public int TotalCount { get; set; } = 0; - /// - /// Max number of issues/volumes in the series (Max of Volume/Issue field in ComicInfo) - /// - public int MaxCount { get; set; } = 0; - public PublicationStatus PublicationStatus { get; set; } + /// + /// Highest Age Rating from all Chapters + /// + public AgeRating AgeRating { get; set; } + /// + /// Earliest Year from all chapters + /// + public int ReleaseYear { get; set; } + /// + /// Language of the content (BCP-47 code) + /// + public string Language { get; set; } = string.Empty; + /// + /// Total number of issues/volumes in the series + /// + public int TotalCount { get; set; } = 0; + /// + /// Max number of issues/volumes in the series (Max of Volume/Issue field in ComicInfo) + /// + public int MaxCount { get; set; } = 0; + public PublicationStatus PublicationStatus { get; set; } - // Locks - public bool LanguageLocked { get; set; } - public bool SummaryLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override AgeRating - /// - public bool AgeRatingLocked { get; set; } - /// - /// Locked by user so metadata updates from scan loop will not override PublicationStatus - /// - public bool PublicationStatusLocked { get; set; } - public bool GenresLocked { get; set; } - public bool TagsLocked { get; set; } - public bool WriterLocked { get; set; } - public bool CharacterLocked { get; set; } - public bool ColoristLocked { get; set; } - public bool EditorLocked { get; set; } - public bool InkerLocked { get; set; } - public bool LettererLocked { get; set; } - public bool PencillerLocked { get; set; } - public bool PublisherLocked { get; set; } - public bool TranslatorLocked { get; set; } - public bool CoverArtistLocked { get; set; } + // Locks + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override PublicationStatus + /// + public bool PublicationStatusLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool CoverArtistLocked { get; set; } - // Relationship - public Series Series { get; set; } - public int SeriesId { get; set; } + // Relationship + public Series Series { get; set; } + public int SeriesId { get; set; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } + /// + [ConcurrencyCheck] + public uint RowVersion { get; private set; } - /// - public void OnSavingChanges() - { - RowVersion++; - } + /// + public void OnSavingChanges() + { + RowVersion++; } } diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs index 785a037bda..4029b6af96 100644 --- a/API/Entities/Person.cs +++ b/API/Entities/Person.cs @@ -2,23 +2,22 @@ using API.Entities.Enums; using API.Entities.Metadata; -namespace API.Entities +namespace API.Entities; + +public enum ProviderSource +{ + Local = 1, + External = 2 +} +public class Person { - public enum ProviderSource - { - Local = 1, - External = 2 - } - public class Person - { - public int Id { get; set; } - public string Name { get; set; } - public string NormalizedName { get; set; } - public PersonRole Role { get; set; } - //public ProviderSource Source { get; set; } + public int Id { get; set; } + public string Name { get; set; } + public string NormalizedName { get; set; } + public PersonRole Role { get; set; } + //public ProviderSource Source { get; set; } - // Relationships - public ICollection SeriesMetadatas { get; set; } - public ICollection ChapterMetadatas { get; set; } - } + // Relationships + public ICollection SeriesMetadatas { get; set; } + public ICollection ChapterMetadatas { get; set; } } diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index b665203c44..8a4ecf96b0 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -2,38 +2,37 @@ using System.Collections.Generic; using API.Entities.Interfaces; -namespace API.Entities +namespace API.Entities; + +/// +/// This is a collection of which represent individual chapters and an order. +/// +public class ReadingList : IEntityDate { + public int Id { get; init; } + public string Title { get; set; } + /// + /// A normalized string used to check if the reading list already exists in the DB + /// + public string NormalizedTitle { get; set; } + public string Summary { get; set; } + /// + /// Reading lists that are promoted are only done by admins + /// + public bool Promoted { get; set; } /// - /// This is a collection of which represent individual chapters and an order. + /// Absolute path to the (managed) image file /// - public class ReadingList : IEntityDate - { - public int Id { get; init; } - public string Title { get; set; } - /// - /// A normalized string used to check if the reading list already exists in the DB - /// - public string NormalizedTitle { get; set; } - public string Summary { get; set; } - /// - /// Reading lists that are promoted are only done by admins - /// - public bool Promoted { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR - public string CoverImage { get; set; } - public bool CoverImageLocked { get; set; } + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + public bool CoverImageLocked { get; set; } - public ICollection Items { get; set; } - public DateTime Created { get; set; } - public DateTime LastModified { get; set; } + public ICollection Items { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } - // Relationships - public int AppUserId { get; set; } - public AppUser AppUser { get; set; } + // Relationships + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } - } } diff --git a/API/Entities/ReadingListItem.cs b/API/Entities/ReadingListItem.cs index a7c7982b20..a68042d3d6 100644 --- a/API/Entities/ReadingListItem.cs +++ b/API/Entities/ReadingListItem.cs @@ -1,24 +1,23 @@ -namespace API.Entities +namespace API.Entities; + +public class ReadingListItem { - public class ReadingListItem - { - public int Id { get; init; } - public int SeriesId { get; set; } - public int VolumeId { get; set; } - public int ChapterId { get; set; } - /// - /// Order of the chapter within a Reading List - /// - public int Order { get; set; } + public int Id { get; init; } + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public int ChapterId { get; set; } + /// + /// Order of the chapter within a Reading List + /// + public int Order { get; set; } - // Relationship - public ReadingList ReadingList { get; set; } - public int ReadingListId { get; set; } + // Relationship + public ReadingList ReadingList { get; set; } + public int ReadingListId { get; set; } - // Keep these for easy join statements - public Series Series { get; set; } - public Volume Volume { get; set; } - public Chapter Chapter { get; set; } + // Keep these for easy join statements + public Series Series { get; set; } + public Volume Volume { get; set; } + public Chapter Chapter { get; set; } - } } diff --git a/API/Entities/ServerSetting.cs b/API/Entities/ServerSetting.cs index 6c4b5f21d1..277bb6569a 100644 --- a/API/Entities/ServerSetting.cs +++ b/API/Entities/ServerSetting.cs @@ -2,25 +2,24 @@ using API.Entities.Enums; using API.Entities.Interfaces; -namespace API.Entities +namespace API.Entities; + +public class ServerSetting : IHasConcurrencyToken { - public class ServerSetting : IHasConcurrencyToken - { - [Key] - public ServerSettingKey Key { get; set; } - /// - /// The value of the Setting. Converter knows how to convert to the correct type - /// - public string Value { get; set; } + [Key] + public ServerSettingKey Key { get; set; } + /// + /// The value of the Setting. Converter knows how to convert to the correct type + /// + public string Value { get; set; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } + /// + [ConcurrencyCheck] + public uint RowVersion { get; private set; } - /// - public void OnSavingChanges() - { - RowVersion++; - } + /// + public void OnSavingChanges() + { + RowVersion++; } } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 06a61e11cb..2caddbb73a 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -2,45 +2,44 @@ using System.Collections.Generic; using API.Entities.Interfaces; -namespace API.Entities +namespace API.Entities; + +public class Volume : IEntityDate, IHasReadTimeEstimate { - public class Volume : IEntityDate, IHasReadTimeEstimate - { - public int Id { get; set; } - /// - /// A String representation of the volume number. Allows for floats. - /// - /// For Books with Series_index, this will map to the Series Index. - public string Name { get; set; } - /// - /// The minimum number in the Name field in Int form - /// - public int Number { get; set; } - public IList Chapters { get; set; } - public DateTime Created { get; set; } - public DateTime LastModified { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR - public string CoverImage { get; set; } - /// - /// Total pages of all chapters in this volume - /// - public int Pages { get; set; } - /// - /// Total Word count of all chapters in this volume. - /// - /// Word Count is only available from EPUB files - public long WordCount { get; set; } - public int MinHoursToRead { get; set; } - public int MaxHoursToRead { get; set; } - public int AvgHoursToRead { get; set; } + public int Id { get; set; } + /// + /// A String representation of the volume number. Allows for floats. + /// + /// For Books with Series_index, this will map to the Series Index. + public string Name { get; set; } + /// + /// The minimum number in the Name field in Int form + /// + public int Number { get; set; } + public IList Chapters { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string CoverImage { get; set; } + /// + /// Total pages of all chapters in this volume + /// + public int Pages { get; set; } + /// + /// Total Word count of all chapters in this volume. + /// + /// Word Count is only available from EPUB files + public long WordCount { get; set; } + public int MinHoursToRead { get; set; } + public int MaxHoursToRead { get; set; } + public int AvgHoursToRead { get; set; } - // Relationships - public Series Series { get; set; } - public int SeriesId { get; set; } + // Relationships + public Series Series { get; set; } + public int SeriesId { get; set; } - } } diff --git a/API/Errors/ApiException.cs b/API/Errors/ApiException.cs index 1d570e8ff5..d67a97f8af 100644 --- a/API/Errors/ApiException.cs +++ b/API/Errors/ApiException.cs @@ -1,16 +1,15 @@ -namespace API.Errors +namespace API.Errors; + +public class ApiException { - public class ApiException - { - public int Status { get; init; } - public string Message { get; init; } - public string Details { get; init; } + public int Status { get; init; } + public string Message { get; init; } + public string Details { get; init; } - public ApiException(int status, string message = null, string details = null) - { - Status = status; - Message = message; - Details = details; - } + public ApiException(int status, string message = null, string details = null) + { + Status = status; + Message = message; + Details = details; } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index d4fa192586..c7836f0571 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -15,74 +15,71 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace API.Extensions +namespace API.Extensions; + +public static class ApplicationServiceExtensions { - public static class ApplicationServiceExtensions + public static void AddApplicationServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment env) { - public static void AddApplicationServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment env) - { - services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - services.AddSqLite(config, env); - services.AddLogging(config); - services.AddSignalR(opt => opt.EnableDetailedErrors = true); - } + services.AddSqLite(config, env); + services.AddLogging(config); + services.AddSignalR(opt => opt.EnableDetailedErrors = true); + } - private static void AddSqLite(this IServiceCollection services, IConfiguration config, - IHostEnvironment env) + private static void AddSqLite(this IServiceCollection services, IConfiguration config, + IHostEnvironment env) + { + services.AddDbContext(options => { - services.AddDbContext(options => - { - options.UseSqlite(config.GetConnectionString("DefaultConnection")); - options.EnableDetailedErrors(); - options.EnableSensitiveDataLogging(env.IsDevelopment() || Configuration.LogLevel.Equals("Debug")); - }); - } + options.UseSqlite(config.GetConnectionString("DefaultConnection")); + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(env.IsDevelopment()); + }); + } - private static void AddLogging(this IServiceCollection services, IConfiguration config) + private static void AddLogging(this IServiceCollection services, IConfiguration config) + { + services.AddLogging(loggingBuilder => { - services.AddLogging(loggingBuilder => - { var loggingSection = config.GetSection("Logging"); loggingBuilder.AddFile(loggingSection); - }); - } + }); } } diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index a5d80bb925..94a9675b8b 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -3,33 +3,32 @@ using API.Entities; using API.Parser; -namespace API.Extensions +namespace API.Extensions; + +public static class ChapterListExtensions { - public static class ChapterListExtensions + /// + /// Returns first chapter in the list with at least one file + /// + /// + /// + public static Chapter GetFirstChapterWithFiles(this IList chapters) { - /// - /// Returns first chapter in the list with at least one file - /// - /// - /// - public static Chapter GetFirstChapterWithFiles(this IList chapters) - { - return chapters.FirstOrDefault(c => c.Files.Any()); - } + return chapters.FirstOrDefault(c => c.Files.Any()); + } - /// - /// Gets a single chapter (or null if doesn't exist) where Range matches the info.Chapters property. If the info - /// is then, the filename is used to search against Range or if filename exists within Files of said Chapter. - /// - /// - /// - /// - public static Chapter GetChapterByRange(this IList chapters, ParserInfo info) - { - var specialTreatment = info.IsSpecialInfo(); - return specialTreatment - ? chapters.FirstOrDefault(c => c.Range == info.Filename || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath))) - : chapters.FirstOrDefault(c => c.Range == info.Chapters); - } + /// + /// Gets a single chapter (or null if doesn't exist) where Range matches the info.Chapters property. If the info + /// is then, the filename is used to search against Range or if filename exists within Files of said Chapter. + /// + /// + /// + /// + public static Chapter GetChapterByRange(this IList chapters, ParserInfo info) + { + var specialTreatment = info.IsSpecialInfo(); + return specialTreatment + ? chapters.FirstOrDefault(c => c.Range == info.Filename || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath))) + : chapters.FirstOrDefault(c => c.Range == info.Chapters); } } diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 61ece56760..f351aea42c 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -1,15 +1,14 @@ using System.Security.Claims; using Kavita.Common; -namespace API.Extensions +namespace API.Extensions; + +public static class ClaimsPrincipalExtensions { - public static class ClaimsPrincipalExtensions + public static string GetUsername(this ClaimsPrincipal user) { - public static string GetUsername(this ClaimsPrincipal user) - { - var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); - if (userClaim == null) throw new KavitaException("User is not authenticated"); - return userClaim.Value; - } + var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); + if (userClaim == null) throw new KavitaException("User is not authenticated"); + return userClaim.Value; } -} \ No newline at end of file +} diff --git a/API/Extensions/ConfigurationExtensions.cs b/API/Extensions/ConfigurationExtensions.cs index 2388fee21b..a5bfe76607 100644 --- a/API/Extensions/ConfigurationExtensions.cs +++ b/API/Extensions/ConfigurationExtensions.cs @@ -1,16 +1,15 @@ using Microsoft.Extensions.Configuration; -namespace API.Extensions +namespace API.Extensions; + +public static class ConfigurationExtensions { - public static class ConfigurationExtensions + public static int GetMaxRollingFiles(this IConfiguration config) + { + return int.Parse(config.GetSection("Logging").GetSection("File").GetSection("MaxRollingFiles").Value); + } + public static string GetLoggingFileName(this IConfiguration config) { - public static int GetMaxRollingFiles(this IConfiguration config) - { - return int.Parse(config.GetSection("Logging").GetSection("File").GetSection("MaxRollingFiles").Value); - } - public static string GetLoggingFileName(this IConfiguration config) - { - return config.GetSection("Logging").GetSection("File").GetSection("Path").Value; - } + return config.GetSection("Logging").GetSection("File").GetSection("Path").Value; } -} \ No newline at end of file +} diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 30a75a9eb9..5b39ca024b 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -3,29 +3,28 @@ using System.Linq; using System.Text.RegularExpressions; -namespace API.Extensions +namespace API.Extensions; + +public static class EnumerableExtensions { - public static class EnumerableExtensions - { - private static readonly Regex Regex = new Regex(@"\d+", RegexOptions.Compiled, TimeSpan.FromMilliseconds(500)); + 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 list = items.ToList(); - var maxDigits = list - .SelectMany(i => Regex.Matches(selector(i)) - .Select(digitChunk => (int?)digitChunk.Value.Length)) - .Max() ?? 0; + /// + /// 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 list = items.ToList(); + var maxDigits = list + .SelectMany(i => Regex.Matches(selector(i)) + .Select(digitChunk => (int?)digitChunk.Value.Length)) + .Max() ?? 0; - return list.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture); - } + return list.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture); } } diff --git a/API/Extensions/FileInfoExtensions.cs b/API/Extensions/FileInfoExtensions.cs index f7e1291e71..1f4ea62e1c 100644 --- a/API/Extensions/FileInfoExtensions.cs +++ b/API/Extensions/FileInfoExtensions.cs @@ -1,19 +1,18 @@ using System; using System.IO; -namespace API.Extensions +namespace API.Extensions; + +public static class FileInfoExtensions { - public static class FileInfoExtensions + /// + /// Checks if the last write time of the file is after the passed date + /// + /// + /// + /// + public static bool HasFileBeenModifiedSince(this FileInfo fileInfo, DateTime comparison) { - /// - /// Checks if the last write time of the file is after the passed date - /// - /// - /// - /// - public static bool HasFileBeenModifiedSince(this FileInfo fileInfo, DateTime comparison) - { - return DateTime.Compare(fileInfo.LastWriteTime, comparison) > 0; - } + return DateTime.Compare(fileInfo.LastWriteTime, comparison) > 0; } } diff --git a/API/Extensions/FilterDtoExtensions.cs b/API/Extensions/FilterDtoExtensions.cs index b0d9f80f6d..bc5b4eb524 100644 --- a/API/Extensions/FilterDtoExtensions.cs +++ b/API/Extensions/FilterDtoExtensions.cs @@ -3,20 +3,19 @@ using API.DTOs.Filtering; using API.Entities.Enums; -namespace API.Extensions +namespace API.Extensions; + +public static class FilterDtoExtensions { - public static class FilterDtoExtensions - { - private static readonly IList AllFormats = Enum.GetValues(); + private static readonly IList AllFormats = Enum.GetValues(); - public static IList GetSqlFilter(this FilterDto filter) + public static IList GetSqlFilter(this FilterDto filter) + { + if (filter.Formats == null || filter.Formats.Count == 0) { - if (filter.Formats == null || filter.Formats.Count == 0) - { - return AllFormats; - } - - return filter.Formats; + return AllFormats; } + + return filter.Formats; } } diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index 419483fed5..c7820284a7 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -9,53 +9,52 @@ using Microsoft.AspNetCore.Http; using Microsoft.Net.Http.Headers; -namespace API.Extensions +namespace API.Extensions; + +public static class HttpExtensions { - public static class HttpExtensions + public static void AddPaginationHeader(this HttpResponse response, int currentPage, + int itemsPerPage, int totalItems, int totalPages) { - public static void AddPaginationHeader(this HttpResponse response, int currentPage, - int itemsPerPage, int totalItems, int totalPages) + var paginationHeader = new PaginationHeader(currentPage, itemsPerPage, totalItems, totalPages); + var options = new JsonSerializerOptions() { - var paginationHeader = new PaginationHeader(currentPage, itemsPerPage, totalItems, totalPages); - var options = new JsonSerializerOptions() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; - response.Headers.Add("Pagination", JsonSerializer.Serialize(paginationHeader, options)); - response.Headers.Add("Access-Control-Expose-Headers", "Pagination"); - } + response.Headers.Add("Pagination", JsonSerializer.Serialize(paginationHeader, options)); + response.Headers.Add("Access-Control-Expose-Headers", "Pagination"); + } - /// - /// Calculates SHA256 hash for a byte[] and sets as ETag. Ensures Cache-Control: private header is added. - /// - /// - /// If byte[] is null or empty, will only add cache-control - public static void AddCacheHeader(this HttpResponse response, byte[] content) - { - if (content is not {Length: > 0}) return; - using var sha1 = SHA256.Create(); + /// + /// Calculates SHA256 hash for a byte[] and sets as ETag. Ensures Cache-Control: private header is added. + /// + /// + /// If byte[] is null or empty, will only add cache-control + public static void AddCacheHeader(this HttpResponse response, byte[] content) + { + if (content is not {Length: > 0}) return; + using var sha1 = SHA256.Create(); - response.Headers.Add(HeaderNames.ETag, string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2")))); - response.Headers.CacheControl = $"private,max-age=100"; - } + response.Headers.Add(HeaderNames.ETag, string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2")))); + response.Headers.CacheControl = $"private,max-age=100"; + } - /// - /// Calculates SHA256 hash for a cover image filename and sets as ETag. Ensures Cache-Control: private header is added. - /// - /// - /// - /// Maximum amount of seconds to set for Cache-Control - public static void AddCacheHeader(this HttpResponse response, string filename, int maxAge = 10) + /// + /// Calculates SHA256 hash for a cover image filename and sets as ETag. Ensures Cache-Control: private header is added. + /// + /// + /// + /// Maximum amount of seconds to set for Cache-Control + public static void AddCacheHeader(this HttpResponse response, string filename, int maxAge = 10) + { + if (filename is not {Length: > 0}) return; + var hashContent = filename + File.GetLastWriteTimeUtc(filename); + using var sha1 = SHA256.Create(); + response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")))); + if (maxAge != 10) { - if (filename is not {Length: > 0}) return; - var hashContent = filename + File.GetLastWriteTimeUtc(filename); - using var sha1 = SHA256.Create(); - response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")))); - if (maxAge != 10) - { - response.Headers.CacheControl = $"max-age={maxAge}"; - } + response.Headers.CacheControl = $"max-age={maxAge}"; } } } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 5cc4718bb2..6c32f22381 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -10,79 +10,78 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; -namespace API.Extensions +namespace API.Extensions; + +public static class IdentityServiceExtensions { - public static class IdentityServiceExtensions + public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) { - public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config) + services.Configure(options => { - services.Configure(options => - { - options.User.AllowedUserNameCharacters = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/"; - }); + options.User.AllowedUserNameCharacters = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/"; + }); - services.AddIdentityCore(opt => - { - opt.Password.RequireNonAlphanumeric = false; - opt.Password.RequireDigit = false; - opt.Password.RequireDigit = false; - opt.Password.RequireLowercase = false; - opt.Password.RequireUppercase = false; - opt.Password.RequireNonAlphanumeric = false; - opt.Password.RequiredLength = 6; + services.AddIdentityCore(opt => + { + opt.Password.RequireNonAlphanumeric = false; + opt.Password.RequireDigit = false; + opt.Password.RequireDigit = false; + opt.Password.RequireLowercase = false; + opt.Password.RequireUppercase = false; + opt.Password.RequireNonAlphanumeric = false; + opt.Password.RequiredLength = 6; - opt.SignIn.RequireConfirmedEmail = true; + opt.SignIn.RequireConfirmedEmail = true; - opt.Lockout.AllowedForNewUsers = true; - opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10); - opt.Lockout.MaxFailedAccessAttempts = 5; + opt.Lockout.AllowedForNewUsers = true; + opt.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(10); + opt.Lockout.MaxFailedAccessAttempts = 5; - }) - .AddTokenProvider>(TokenOptions.DefaultProvider) - .AddRoles() - .AddRoleManager>() - .AddSignInManager>() - .AddRoleValidator>() - .AddEntityFrameworkStores(); + }) + .AddTokenProvider>(TokenOptions.DefaultProvider) + .AddRoles() + .AddRoleManager>() + .AddSignInManager>() + .AddRoleValidator>() + .AddEntityFrameworkStores(); - services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters() { - options.TokenValidationParameters = new TokenValidationParameters() - { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])), - ValidateIssuer = false, - ValidateAudience = false, - ValidIssuer = "Kavita" - }; + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])), + ValidateIssuer = false, + ValidateAudience = false, + ValidIssuer = "Kavita" + }; - options.Events = new JwtBearerEvents() + options.Events = new JwtBearerEvents() + { + OnMessageReceived = context => { - OnMessageReceived = context => + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + // Only use query string based token on SignalR hubs + if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) { - var accessToken = context.Request.Query["access_token"]; - var path = context.HttpContext.Request.Path; - // Only use query string based token on SignalR hubs - if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) - { - context.Token = accessToken; - } - - return Task.CompletedTask; + context.Token = accessToken; } - }; - }); - services.AddAuthorization(opt => - { - opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); - opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); - opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); + + return Task.CompletedTask; + } + }; }); + services.AddAuthorization(opt => + { + opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole)); + opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole)); + opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole)); + }); - return services; - } + return services; } } diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 1bca8787b5..9bea79ce92 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -3,31 +3,30 @@ using API.Entities; using API.Parser; -namespace API.Extensions +namespace API.Extensions; + +public static class ParserInfoListExtensions { - public static class ParserInfoListExtensions + /// + /// Selects distinct volume numbers by the "Volumes" key on the ParserInfo + /// + /// + /// + public static IList DistinctVolumes(this IList infos) { - /// - /// Selects distinct volume numbers by the "Volumes" key on the ParserInfo - /// - /// - /// - public static IList DistinctVolumes(this IList infos) - { - return infos.Select(p => p.Volumes).Distinct().ToList(); - } + return infos.Select(p => p.Volumes).Distinct().ToList(); + } - /// - /// Checks if a list of ParserInfos has a given chapter or not. Lookup occurs on Range property. If a chapter is - /// special, then the is matched, else the field is checked. - /// - /// - /// - /// - public static bool HasInfo(this IList infos, Chapter chapter) - { - return chapter.IsSpecial ? infos.Any(v => v.Filename == chapter.Range) - : infos.Any(v => v.Chapters == chapter.Range); - } + /// + /// Checks if a list of ParserInfos has a given chapter or not. Lookup occurs on Range property. If a chapter is + /// special, then the is matched, else the field is checked. + /// + /// + /// + /// + public static bool HasInfo(this IList infos, Chapter chapter) + { + return chapter.IsSpecial ? infos.Any(v => v.Filename == chapter.Range) + : infos.Any(v => v.Chapters == chapter.Range); } } diff --git a/API/Extensions/SeriesExtensions.cs b/API/Extensions/SeriesExtensions.cs index acd8284806..bfb999d100 100644 --- a/API/Extensions/SeriesExtensions.cs +++ b/API/Extensions/SeriesExtensions.cs @@ -4,46 +4,45 @@ using API.Parser; using API.Services.Tasks.Scanner; -namespace API.Extensions +namespace API.Extensions; + +public static class SeriesExtensions { - public static class SeriesExtensions + /// + /// Checks against all the name variables of the Series if it matches anything in the list. This does not check against format. + /// + /// + /// + /// + public static bool NameInList(this Series series, IEnumerable list) { - /// - /// Checks against all the name variables of the Series if it matches anything in the list. This does not check against format. - /// - /// - /// - /// - public static bool NameInList(this Series series, IEnumerable list) - { - return list.Any(name => Services.Tasks.Scanner.Parser.Parser.Normalize(name) == series.NormalizedName || Services.Tasks.Scanner.Parser.Parser.Normalize(name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name) - || name == series.Name || name == series.LocalizedName || name == series.OriginalName || Services.Tasks.Scanner.Parser.Parser.Normalize(name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName)); - } + return list.Any(name => Services.Tasks.Scanner.Parser.Parser.Normalize(name) == series.NormalizedName || Services.Tasks.Scanner.Parser.Parser.Normalize(name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name) + || name == series.Name || name == series.LocalizedName || name == series.OriginalName || Services.Tasks.Scanner.Parser.Parser.Normalize(name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName)); + } - /// - /// Checks against all the name variables of the Series if it matches anything in the list. Includes a check against the Format of the Series - /// - /// - /// - /// - public static bool NameInList(this Series series, IEnumerable list) - { - return list.Any(name => Services.Tasks.Scanner.Parser.Parser.Normalize(name.Name) == series.NormalizedName || Services.Tasks.Scanner.Parser.Parser.Normalize(name.Name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name) - || name.Name == series.Name || name.Name == series.LocalizedName || name.Name == series.OriginalName || Services.Tasks.Scanner.Parser.Parser.Normalize(name.Name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName) && series.Format == name.Format); - } + /// + /// Checks against all the name variables of the Series if it matches anything in the list. Includes a check against the Format of the Series + /// + /// + /// + /// + public static bool NameInList(this Series series, IEnumerable list) + { + return list.Any(name => Services.Tasks.Scanner.Parser.Parser.Normalize(name.Name) == series.NormalizedName || Services.Tasks.Scanner.Parser.Parser.Normalize(name.Name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name) + || name.Name == series.Name || name.Name == series.LocalizedName || name.Name == series.OriginalName || Services.Tasks.Scanner.Parser.Parser.Normalize(name.Name) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName) && series.Format == name.Format); + } - /// - /// Checks against all the name variables of the Series if it matches the - /// - /// - /// - /// - public static bool NameInParserInfo(this Series series, ParserInfo info) - { - if (info == null) return false; - return Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == series.NormalizedName || Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name) - || info.Series == series.Name || info.Series == series.LocalizedName || info.Series == series.OriginalName - || Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName); - } + /// + /// Checks against all the name variables of the Series if it matches the + /// + /// + /// + /// + public static bool NameInParserInfo(this Series series, ParserInfo info) + { + if (info == null) return false; + return Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == series.NormalizedName || Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.Name) + || info.Series == series.Name || info.Series == series.LocalizedName || info.Series == series.OriginalName + || Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName); } } diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index 8933e04a52..7f4cc08a63 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -4,29 +4,28 @@ using API.Entities; using API.Entities.Enums; -namespace API.Extensions +namespace API.Extensions; + +public static class VolumeListExtensions { - public static class VolumeListExtensions + /// + /// Selects the first Volume to get the cover image from. For a book with only a special, the special will be returned. + /// If there are both specials and non-specials, then the first non-special will be returned. + /// + /// + /// + /// + public static Volume GetCoverImage(this IList volumes, MangaFormat seriesFormat) { - /// - /// Selects the first Volume to get the cover image from. For a book with only a special, the special will be returned. - /// If there are both specials and non-specials, then the first non-special will be returned. - /// - /// - /// - /// - public static Volume GetCoverImage(this IList volumes, MangaFormat seriesFormat) + if (seriesFormat is MangaFormat.Epub or MangaFormat.Pdf) { - if (seriesFormat is MangaFormat.Epub or MangaFormat.Pdf) - { - return volumes.OrderBy(x => x.Number).FirstOrDefault(); - } - - if (volumes.Any(x => x.Number != 0)) - { - return volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); - } return volumes.OrderBy(x => x.Number).FirstOrDefault(); } + + if (volumes.Any(x => x.Number != 0)) + { + return volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); + } + return volumes.OrderBy(x => x.Number).FirstOrDefault(); } } diff --git a/API/Extensions/ZipArchiveExtensions.cs b/API/Extensions/ZipArchiveExtensions.cs index a871162e87..89a0834905 100644 --- a/API/Extensions/ZipArchiveExtensions.cs +++ b/API/Extensions/ZipArchiveExtensions.cs @@ -2,18 +2,17 @@ using System.IO.Compression; using System.Linq; -namespace API.Extensions +namespace API.Extensions; + +public static class ZipArchiveExtensions { - public static class ZipArchiveExtensions + /// + /// Checks if archive has one or more files. Excludes directory entries. + /// + /// + /// + public static bool HasFiles(this ZipArchive archive) { - /// - /// Checks if archive has one or more files. Excludes directory entries. - /// - /// - /// - public static bool HasFiles(this ZipArchive archive) - { - return archive.Entries.Any(x => Path.HasExtension(x.FullName)); - } + return archive.Entries.Any(x => Path.HasExtension(x.FullName)); } -} \ No newline at end of file +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index a15913374e..e69db721d7 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -15,135 +15,134 @@ using API.Helpers.Converters; using AutoMapper; -namespace API.Helpers +namespace API.Helpers; + +public class AutoMapperProfiles : Profile { - public class AutoMapperProfiles : Profile + public AutoMapperProfiles() { - public AutoMapperProfiles() - { - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - CreateMap(); - - CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); - - CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); - - // CreateMap() - // .ForMember(dest => dest.Adaptations, - // opt => - // opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer))) - - CreateMap(); - CreateMap(); - CreateMap() - .ForMember(dest => dest.Theme, - opt => - opt.MapFrom(src => src.Theme)) - .ForMember(dest => dest.BookReaderThemeName, - opt => - opt.MapFrom(src => src.BookThemeName)) - .ForMember(dest => dest.BookReaderLayoutMode, - opt => - opt.MapFrom(src => src.BookReaderLayoutMode)); - - - CreateMap(); - - CreateMap(); - CreateMap(); - - CreateMap() - .ForMember(dest => dest.SeriesId, - opt => opt.MapFrom(src => src.Id)) - .ForMember(dest => dest.LibraryName, - opt => opt.MapFrom(src => src.Library.Name)); - - - CreateMap() - .ForMember(dest => dest.Folders, - opt => - opt.MapFrom(src => src.Folders.Select(x => x.Path).ToList())); - - CreateMap() - .AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries)); - - CreateMap(); - - CreateMap, ServerSettingDto>() - .ConvertUsing(); - - CreateMap, ServerSettingDto>() - .ConvertUsing(); - - } + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + CreateMap(); + + CreateMap() + .ForMember(dest => dest.Writers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + .ForMember(dest => dest.CoverArtists, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Characters, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) + .ForMember(dest => dest.Publishers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Colorists, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inkers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Pencillers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Translators, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) + .ForMember(dest => dest.Editors, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + + CreateMap() + .ForMember(dest => dest.Writers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + .ForMember(dest => dest.CoverArtists, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Colorists, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inkers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Pencillers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Publishers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Translators, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))) + .ForMember(dest => dest.Characters, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) + .ForMember(dest => dest.Editors, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + + // CreateMap() + // .ForMember(dest => dest.Adaptations, + // opt => + // opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer))) + + CreateMap(); + CreateMap(); + CreateMap() + .ForMember(dest => dest.Theme, + opt => + opt.MapFrom(src => src.Theme)) + .ForMember(dest => dest.BookReaderThemeName, + opt => + opt.MapFrom(src => src.BookThemeName)) + .ForMember(dest => dest.BookReaderLayoutMode, + opt => + opt.MapFrom(src => src.BookReaderLayoutMode)); + + + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap() + .ForMember(dest => dest.SeriesId, + opt => opt.MapFrom(src => src.Id)) + .ForMember(dest => dest.LibraryName, + opt => opt.MapFrom(src => src.Library.Name)); + + + CreateMap() + .ForMember(dest => dest.Folders, + opt => + opt.MapFrom(src => src.Folders.Select(x => x.Path).ToList())); + + CreateMap() + .AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries)); + + CreateMap(); + + CreateMap, ServerSettingDto>() + .ConvertUsing(); + + CreateMap, ServerSettingDto>() + .ConvertUsing(); + } } diff --git a/API/Helpers/Converters/CronConverter.cs b/API/Helpers/Converters/CronConverter.cs index 4f8305e016..4e9547c6ce 100644 --- a/API/Helpers/Converters/CronConverter.cs +++ b/API/Helpers/Converters/CronConverter.cs @@ -1,29 +1,28 @@ using System.Collections.Generic; using Hangfire; -namespace API.Helpers.Converters +namespace API.Helpers.Converters; + +public static class CronConverter { - public static class CronConverter + public static readonly IEnumerable Options = new [] + { + "disabled", + "daily", + "weekly", + }; + public static string ConvertToCronNotation(string source) { - public static readonly IEnumerable Options = new [] + var destination = string.Empty; + destination = source.ToLower() switch { - "disabled", - "daily", - "weekly", + "daily" => Cron.Daily(), + "weekly" => Cron.Weekly(), + "disabled" => Cron.Never(), + "" => Cron.Never(), + _ => destination }; - public static string ConvertToCronNotation(string source) - { - var destination = string.Empty; - destination = source.ToLower() switch - { - "daily" => Cron.Daily(), - "weekly" => Cron.Weekly(), - "disabled" => Cron.Never(), - "" => Cron.Never(), - _ => destination - }; - return destination; - } + return destination; } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 6cc48e9ebe..56c8e618f1 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -4,69 +4,68 @@ using API.Entities.Enums; using AutoMapper; -namespace API.Helpers.Converters +namespace API.Helpers.Converters; + +public class ServerSettingConverter : ITypeConverter, ServerSettingDto> { - public class ServerSettingConverter : ITypeConverter, ServerSettingDto> + public ServerSettingDto Convert(IEnumerable source, ServerSettingDto destination, ResolutionContext context) { - public ServerSettingDto Convert(IEnumerable source, ServerSettingDto destination, ResolutionContext context) + destination ??= new ServerSettingDto(); + foreach (var row in source) { - destination ??= new ServerSettingDto(); - foreach (var row in source) + switch (row.Key) { - switch (row.Key) - { - case ServerSettingKey.CacheDirectory: - destination.CacheDirectory = row.Value; - break; - case ServerSettingKey.TaskScan: - destination.TaskScan = row.Value; - break; - case ServerSettingKey.LoggingLevel: - destination.LoggingLevel = row.Value; - break; - case ServerSettingKey.TaskBackup: - destination.TaskBackup = row.Value; - break; - case ServerSettingKey.Port: - destination.Port = int.Parse(row.Value); - break; - case ServerSettingKey.AllowStatCollection: - destination.AllowStatCollection = bool.Parse(row.Value); - break; - case ServerSettingKey.EnableOpds: - destination.EnableOpds = bool.Parse(row.Value); - break; - case ServerSettingKey.BaseUrl: - destination.BaseUrl = row.Value; - break; - case ServerSettingKey.BookmarkDirectory: - destination.BookmarksDirectory = row.Value; - break; - case ServerSettingKey.EmailServiceUrl: - destination.EmailServiceUrl = row.Value; - break; - case ServerSettingKey.InstallVersion: - destination.InstallVersion = row.Value; - break; - case ServerSettingKey.ConvertBookmarkToWebP: - destination.ConvertBookmarkToWebP = bool.Parse(row.Value); - break; - case ServerSettingKey.EnableSwaggerUi: - destination.EnableSwaggerUi = bool.Parse(row.Value); - break; - case ServerSettingKey.TotalBackups: - destination.TotalBackups = int.Parse(row.Value); - break; - case ServerSettingKey.InstallId: - destination.InstallId = row.Value; - break; - case ServerSettingKey.EnableFolderWatching: - destination.EnableFolderWatching = bool.Parse(row.Value); - break; - } + case ServerSettingKey.CacheDirectory: + destination.CacheDirectory = row.Value; + break; + case ServerSettingKey.TaskScan: + destination.TaskScan = row.Value; + break; + case ServerSettingKey.LoggingLevel: + destination.LoggingLevel = row.Value; + break; + case ServerSettingKey.TaskBackup: + destination.TaskBackup = row.Value; + break; + case ServerSettingKey.Port: + destination.Port = int.Parse(row.Value); + break; + case ServerSettingKey.AllowStatCollection: + destination.AllowStatCollection = bool.Parse(row.Value); + break; + case ServerSettingKey.EnableOpds: + destination.EnableOpds = bool.Parse(row.Value); + break; + case ServerSettingKey.BaseUrl: + destination.BaseUrl = row.Value; + break; + case ServerSettingKey.BookmarkDirectory: + destination.BookmarksDirectory = row.Value; + break; + case ServerSettingKey.EmailServiceUrl: + destination.EmailServiceUrl = row.Value; + break; + case ServerSettingKey.InstallVersion: + destination.InstallVersion = row.Value; + break; + case ServerSettingKey.ConvertBookmarkToWebP: + destination.ConvertBookmarkToWebP = bool.Parse(row.Value); + break; + case ServerSettingKey.EnableSwaggerUi: + destination.EnableSwaggerUi = bool.Parse(row.Value); + break; + case ServerSettingKey.TotalBackups: + destination.TotalBackups = int.Parse(row.Value); + break; + case ServerSettingKey.InstallId: + destination.InstallId = row.Value; + break; + case ServerSettingKey.EnableFolderWatching: + destination.EnableFolderWatching = bool.Parse(row.Value); + break; } - - return destination; } + + return destination; } } diff --git a/API/Helpers/PagedList.cs b/API/Helpers/PagedList.cs index b87687a6ec..0c666612d8 100644 --- a/API/Helpers/PagedList.cs +++ b/API/Helpers/PagedList.cs @@ -4,30 +4,29 @@ using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; -namespace API.Helpers +namespace API.Helpers; + +public class PagedList : List { - public class PagedList : List + public PagedList(IEnumerable items, int count, int pageNumber, int pageSize) { - public PagedList(IEnumerable items, int count, int pageNumber, int pageSize) - { - CurrentPage = pageNumber; - TotalPages = (int) Math.Ceiling(count / (double) pageSize); - PageSize = pageSize; - TotalCount = count; - AddRange(items); - } + CurrentPage = pageNumber; + TotalPages = (int) Math.Ceiling(count / (double) pageSize); + PageSize = pageSize; + TotalCount = count; + AddRange(items); + } - public int CurrentPage { get; set; } - public int TotalPages { get; set; } - public int PageSize { get; set; } - public int TotalCount { get; set; } + public int CurrentPage { get; set; } + public int TotalPages { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } - public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) - { - // NOTE: OrderBy warning being thrown here even if query has the orderby statement - var count = await source.CountAsync(); - var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); - return new PagedList(items, count, pageNumber, pageSize); - } + public static async Task> CreateAsync(IQueryable source, int pageNumber, int pageSize) + { + // NOTE: OrderBy warning being thrown here even if query has the orderby statement + var count = await source.CountAsync(); + var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync(); + return new PagedList(items, count, pageNumber, pageSize); } -} \ No newline at end of file +} diff --git a/API/Helpers/PaginationHeader.cs b/API/Helpers/PaginationHeader.cs index 8d24eeca06..d3c5827984 100644 --- a/API/Helpers/PaginationHeader.cs +++ b/API/Helpers/PaginationHeader.cs @@ -1,18 +1,17 @@ -namespace API.Helpers +namespace API.Helpers; + +public class PaginationHeader { - public class PaginationHeader + public PaginationHeader(int currentPage, int itemsPerPage, int totalItems, int totalPages) { - public PaginationHeader(int currentPage, int itemsPerPage, int totalItems, int totalPages) - { - CurrentPage = currentPage; - ItemsPerPage = itemsPerPage; - TotalItems = totalItems; - TotalPages = totalPages; - } - - public int CurrentPage { get; set; } - public int ItemsPerPage { get; set; } - public int TotalItems { get; set; } - public int TotalPages { get; set; } + CurrentPage = currentPage; + ItemsPerPage = itemsPerPage; + TotalItems = totalItems; + TotalPages = totalPages; } -} \ No newline at end of file + + public int CurrentPage { get; set; } + public int ItemsPerPage { get; set; } + public int TotalItems { get; set; } + public int TotalPages { get; set; } +} diff --git a/API/Helpers/SQLHelper.cs b/API/Helpers/SQLHelper.cs index d06d246efa..dd56a288bd 100644 --- a/API/Helpers/SQLHelper.cs +++ b/API/Helpers/SQLHelper.cs @@ -5,27 +5,26 @@ using API.DTOs; using Microsoft.EntityFrameworkCore; -namespace API.Helpers +namespace API.Helpers; + +public static class SqlHelper { - public static class SqlHelper + public static List RawSqlQuery(DbContext context, string query, Func map) { - public static List RawSqlQuery(DbContext context, string query, Func map) - { - using var command = context.Database.GetDbConnection().CreateCommand(); - command.CommandText = query; - command.CommandType = CommandType.Text; - - context.Database.OpenConnection(); + using var command = context.Database.GetDbConnection().CreateCommand(); + command.CommandText = query; + command.CommandType = CommandType.Text; - using var result = command.ExecuteReader(); - var entities = new List(); + context.Database.OpenConnection(); - while (result.Read()) - { - entities.Add(map(result)); - } + using var result = command.ExecuteReader(); + var entities = new List(); - return entities; + while (result.Read()) + { + entities.Add(map(result)); } + + return entities; } } diff --git a/API/Helpers/UserParams.cs b/API/Helpers/UserParams.cs index 87cc284713..2ad6792630 100644 --- a/API/Helpers/UserParams.cs +++ b/API/Helpers/UserParams.cs @@ -1,18 +1,17 @@ -namespace API.Helpers +namespace API.Helpers; + +public class UserParams { - public class UserParams - { - private const int MaxPageSize = int.MaxValue; - public int PageNumber { get; init; } = 1; - private readonly int _pageSize = MaxPageSize; + private const int MaxPageSize = int.MaxValue; + public int PageNumber { get; init; } = 1; + private readonly int _pageSize = MaxPageSize; - /// - /// If set to 0, will set as MaxInt - /// - public int PageSize - { - get => _pageSize; - init => _pageSize = (value == 0) ? MaxPageSize : value; - } + /// + /// If set to 0, will set as MaxInt + /// + public int PageSize + { + get => _pageSize; + init => _pageSize = (value == 0) ? MaxPageSize : value; } } diff --git a/API/Logging/LogEnricher.cs b/API/Logging/LogEnricher.cs new file mode 100644 index 0000000000..8cc7a6b29d --- /dev/null +++ b/API/Logging/LogEnricher.cs @@ -0,0 +1,19 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; +using Serilog; + +namespace API.Logging; + +public static class LogEnricher +{ + /// + /// Enriches the HTTP request log with additional data via the Diagnostic Context + /// + /// The Serilog diagnostic context + /// The current HTTP Context + public static void EnrichFromRequest(IDiagnosticContext diagnosticContext, HttpContext httpContext) + { + diagnosticContext.Set("ClientIP", httpContext.Connection.RemoteIpAddress?.ToString()); + diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].FirstOrDefault()); + } +} diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs new file mode 100644 index 0000000000..0032fa421c --- /dev/null +++ b/API/Logging/LogLevelOptions.cs @@ -0,0 +1,89 @@ +using System.IO; +using API.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace API.Logging; + +/// +/// This class represents information for configuring Logging in the Application. Only a high log level is exposed and Kavita +/// controls the underlying log levels for different loggers in ASP.NET +/// +public static class LogLevelOptions +{ + public const string LogFile = "config/logs/kavita.log"; + public const bool LogRollingEnabled = true; + /// + /// Controls the Logging Level of the Application + /// + private static readonly LoggingLevelSwitch LogLevelSwitch = new (); + /// + /// Controls Microsoft's Logging Level + /// + private static readonly LoggingLevelSwitch MicrosoftLogLevelSwitch = new (LogEventLevel.Error); + /// + /// Controls Microsoft.Hosting.Lifetime's Logging Level + /// + private static readonly LoggingLevelSwitch MicrosoftHostingLifetimeLogLevelSwitch = new (LogEventLevel.Error); + /// + /// Controls Hangfire's Logging Level + /// + private static readonly LoggingLevelSwitch HangfireLogLevelSwitch = new (LogEventLevel.Error); + /// + /// Controls Microsoft.AspNetCore.Hosting.Internal.WebHost's Logging Level + /// + private static readonly LoggingLevelSwitch AspNetCoreLogLevelSwitch = new (LogEventLevel.Error); + + public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration) + { + return configuration + .MinimumLevel + .ControlledBy(LogLevelSwitch) + .MinimumLevel.Override("Microsoft", MicrosoftLogLevelSwitch) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", MicrosoftHostingLifetimeLogLevelSwitch) + .MinimumLevel.Override("Hangfire", HangfireLogLevelSwitch) + .MinimumLevel.Override("Microsoft.AspNetCore.Hosting.Internal.WebHost", AspNetCoreLogLevelSwitch) + .Enrich.FromLogContext() + .WriteTo.Console() + .WriteTo.File(LogFile, + shared: true, + rollingInterval: RollingInterval.Day, + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {Level}] {Message:lj}{NewLine}{Exception}"); + } + + public static void SwitchLogLevel(string level) + { + switch (level) + { + case "Debug": + LogLevelSwitch.MinimumLevel = LogEventLevel.Debug; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; + break; + case "Information": + LogLevelSwitch.MinimumLevel = LogEventLevel.Information; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; + break; + case "Trace": + LogLevelSwitch.MinimumLevel = LogEventLevel.Verbose; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; + break; + case "Warning": + LogLevelSwitch.MinimumLevel = LogEventLevel.Warning; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; + break; + case "Critical": + LogLevelSwitch.MinimumLevel = LogEventLevel.Error; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; + break; + } + } + +} diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index f844d32f9a..81238d7a30 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -7,49 +7,48 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace API.Middleware +namespace API.Middleware; + +public class ExceptionMiddleware { - public class ExceptionMiddleware - { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly IHostEnvironment _env; + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _env; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) + { + _next = next; + _logger = logger; + _env = env; + } - public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) + public async Task InvokeAsync(HttpContext context) + { + try { - _next = next; - _logger = logger; - _env = env; + await _next(context); // downstream middlewares or http call } - - public async Task InvokeAsync(HttpContext context) + catch (Exception ex) { - try - { - await _next(context); // downstream middlewares or http call - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception"); - context.Response.ContentType = "application/json"; - context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; + _logger.LogError(ex, "There was an exception"); + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; - var errorMessage = string.IsNullOrEmpty(ex.Message) ? "Internal Server Error" : ex.Message; + var errorMessage = string.IsNullOrEmpty(ex.Message) ? "Internal Server Error" : ex.Message; - var response = new ApiException(context.Response.StatusCode, errorMessage, ex.StackTrace); + var response = new ApiException(context.Response.StatusCode, errorMessage, ex.StackTrace); - var options = new JsonSerializerOptions - { - PropertyNamingPolicy = - JsonNamingPolicy.CamelCase - }; + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = + JsonNamingPolicy.CamelCase + }; - var json = JsonSerializer.Serialize(response, options); + var json = JsonSerializer.Serialize(response, options); - await context.Response.WriteAsync(json); + await context.Response.WriteAsync(json); - } } } } diff --git a/API/Program.cs b/API/Program.cs index 1c3568d5ec..69527caefc 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -7,7 +7,9 @@ using API.Data; using API.Entities; using API.Entities.Enums; +using API.Logging; using API.Services; +using API.SignalR; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Hosting; @@ -18,35 +20,42 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.AspNetCore.SignalR.Extensions; -namespace API +namespace API; + +public class Program { - public class Program + private static readonly int HttpPort = Configuration.Port; + + protected Program() + { + } + + public static async Task Main(string[] args) { - private static readonly int HttpPort = Configuration.Port; + Console.OutputEncoding = System.Text.Encoding.UTF8; + var isDocker = new OsInfo(Array.Empty()).IsDocker; + Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); - protected Program() + var directoryService = new DirectoryService(null, new FileSystem()); + + // Before anything, check if JWT has been generated properly or if user still has default + if (!Configuration.CheckIfJwtTokenSet() && + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) { + Console.WriteLine("Generating JWT TokenKey for encrypting user sessions..."); + var rBytes = new byte[128]; + RandomNumberGenerator.Create().GetBytes(rBytes); + Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); } - public static async Task Main(string[] args) + try { - Console.OutputEncoding = System.Text.Encoding.UTF8; - var isDocker = new OsInfo(Array.Empty()).IsDocker; - - - var directoryService = new DirectoryService(null, new FileSystem()); - - // Before anything, check if JWT has been generated properly or if user still has default - if (!Configuration.CheckIfJwtTokenSet() && - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) - { - Console.WriteLine("Generating JWT TokenKey for encrypting user sessions..."); - var rBytes = new byte[128]; - RandomNumberGenerator.Create().GetBytes(rBytes); - Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); - } - var host = CreateHostBuilder(args).Build(); using var scope = host.Services.CreateScope(); @@ -97,58 +106,76 @@ public static async Task Main(string[] args) return; } + // Update the logger with the log level + var unitOfWork = services.GetRequiredService(); + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + LogLevelOptions.SwitchLogLevel(settings.LoggingLevel); + await host.RunAsync(); + } catch (Exception ex) + { + Log.Fatal(ex, "Host terminated unexpectedly"); + } finally + { + Log.CloseAndFlush(); } + } - private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) + private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) + { + string currentVersion = null; + try { - string currentVersion = null; - try - { - if (!await context.ServerSetting.AnyAsync()) return "vUnknown"; - currentVersion = - (await context.ServerSetting.SingleOrDefaultAsync(s => - s.Key == ServerSettingKey.InstallVersion))?.Value; - } - catch (Exception) - { - // ignored - } - - if (string.IsNullOrEmpty(currentVersion)) - { - currentVersion = "vUnknown"; - } + if (!await context.ServerSetting.AnyAsync()) return "vUnknown"; + currentVersion = + (await context.ServerSetting.SingleOrDefaultAsync(s => + s.Key == ServerSettingKey.InstallVersion))?.Value; + } + catch (Exception) + { + // ignored + } - var migrationDirectory = directoryService.FileSystem.Path.Join(directoryService.TempDirectory, - "migration", currentVersion); - return migrationDirectory; + if (string.IsNullOrEmpty(currentVersion)) + { + currentVersion = "vUnknown"; } - private static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureAppConfiguration((hostingContext, config) => - { - config.Sources.Clear(); + var migrationDirectory = directoryService.FileSystem.Path.Join(directoryService.TempDirectory, + "migration", currentVersion); + return migrationDirectory; + } - var env = hostingContext.HostingEnvironment; + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseSerilog((_, services, configuration) => + { + LogLevelOptions.CreateConfig(configuration) + .WriteTo.SignalRSink( + LogEventLevel.Information, + services); + }) + .ConfigureAppConfiguration((hostingContext, config) => + { + config.Sources.Clear(); - config.AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: false) - .AddJsonFile($"config/appsettings.{env.EnvironmentName}.json", - optional: true, reloadOnChange: false); - }) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseKestrel((opts) => - { - opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); - }); + var env = hostingContext.HostingEnvironment; - webBuilder.UseStartup(); + config.AddJsonFile("config/appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"config/appsettings.{env.EnvironmentName}.json", + optional: true, reloadOnChange: false); + }) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseKestrel((opts) => + { + opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); }); + webBuilder.UseStartup(); + }); + - } } diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 80ea7a55da..1423451aef 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -10,113 +10,112 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace API.Services +namespace API.Services; + +public interface IAccountService { - public interface IAccountService + Task> ChangeUserPassword(AppUser user, string newPassword); + Task> ValidatePassword(AppUser user, string password); + Task> ValidateUsername(string username); + Task> ValidateEmail(string email); + Task HasBookmarkPermission(AppUser user); + Task HasDownloadPermission(AppUser appuser); +} + +public class AccountService : IAccountService +{ + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; + + public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { - Task> ChangeUserPassword(AppUser user, string newPassword); - Task> ValidatePassword(AppUser user, string password); - Task> ValidateUsername(string username); - Task> ValidateEmail(string email); - Task HasBookmarkPermission(AppUser user); - Task HasDownloadPermission(AppUser appuser); + _userManager = userManager; + _logger = logger; + _unitOfWork = unitOfWork; } - public class AccountService : IAccountService + public async Task> ChangeUserPassword(AppUser user, string newPassword) { - private readonly UserManager _userManager; - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; + var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); + if (passwordValidationIssues.Any()) return passwordValidationIssues; - public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) + var result = await _userManager.RemovePasswordAsync(user); + if (!result.Succeeded) { - _userManager = userManager; - _logger = logger; - _unitOfWork = unitOfWork; + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - public async Task> ChangeUserPassword(AppUser user, string newPassword) - { - var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); - if (passwordValidationIssues.Any()) return passwordValidationIssues; - - var result = await _userManager.RemovePasswordAsync(user); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } - - result = await _userManager.AddPasswordAsync(user, newPassword); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } - - return new List(); + result = await _userManager.AddPasswordAsync(user, newPassword); + if (!result.Succeeded) + { + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - public async Task> ValidatePassword(AppUser user, string password) - { - foreach (var validator in _userManager.PasswordValidators) - { - var validationResult = await validator.ValidateAsync(_userManager, user, password); - if (!validationResult.Succeeded) - { - return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } - } + return new List(); + } - return Array.Empty(); - } - public async Task> ValidateUsername(string username) + public async Task> ValidatePassword(AppUser user, string password) + { + foreach (var validator in _userManager.PasswordValidators) { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + var validationResult = await validator.ValidateAsync(_userManager, user, password); + if (!validationResult.Succeeded) { - return new List() - { - new ApiException(400, "Username is already taken") - }; + return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - - return Array.Empty(); } - public async Task> ValidateEmail(string email) + return Array.Empty(); + } + public async Task> ValidateUsername(string username) + { + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) { - var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); - if (user == null) return Array.Empty(); - return new List() { - new ApiException(400, "Email is already registered") + new ApiException(400, "Username is already taken") }; } - /// - /// Does the user have the Bookmark permission or admin rights - /// - /// - /// - public async Task HasBookmarkPermission(AppUser user) - { - var roles = await _userManager.GetRolesAsync(user); - return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole); - } + return Array.Empty(); + } - /// - /// Does the user have the Download permission or admin rights - /// - /// - /// - public async Task HasDownloadPermission(AppUser user) + public async Task> ValidateEmail(string email) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); + if (user == null) return Array.Empty(); + + return new List() { - var roles = await _userManager.GetRolesAsync(user); - return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); - } + new ApiException(400, "Email is already registered") + }; + } + /// + /// Does the user have the Bookmark permission or admin rights + /// + /// + /// + public async Task HasBookmarkPermission(AppUser user) + { + var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole); } + + /// + /// Does the user have the Download permission or admin rights + /// + /// + /// + public async Task HasDownloadPermission(AppUser user) + { + var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); + } + } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 58a2b0aaed..271fb7b563 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -14,479 +14,478 @@ using SharpCompress.Archives; using SharpCompress.Common; -namespace API.Services +namespace API.Services; + +public interface IArchiveService +{ + void ExtractArchive(string archivePath, string extractPath); + int GetNumberOfPagesFromArchive(string archivePath); + string GetCoverImage(string archivePath, string fileName, string outputDirectory); + bool IsValidArchive(string archivePath); + ComicInfo GetComicInfo(string archivePath); + ArchiveLibrary CanOpen(string archivePath); + bool ArchiveNeedsFlattening(ZipArchive archive); + /// + /// Creates a zip file form the listed files and outputs to the temp folder. + /// + /// List of files to be zipped up. Should be full file paths. + /// Temp folder name to use for preparing the files. Will be created and deleted + /// Path to the temp zip + /// + string CreateZipForDownload(IEnumerable files, string tempFolder); +} + +/// +/// Responsible for manipulating Archive files. Used by and +/// +// ReSharper disable once ClassWithVirtualMembersNeverInherited.Global +public class ArchiveService : IArchiveService { - public interface IArchiveService + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IImageService _imageService; + private const string ComicInfoFilename = "comicinfo"; + + public ArchiveService(ILogger logger, IDirectoryService directoryService, IImageService imageService) { - void ExtractArchive(string archivePath, string extractPath); - int GetNumberOfPagesFromArchive(string archivePath); - string GetCoverImage(string archivePath, string fileName, string outputDirectory); - bool IsValidArchive(string archivePath); - ComicInfo GetComicInfo(string archivePath); - ArchiveLibrary CanOpen(string archivePath); - bool ArchiveNeedsFlattening(ZipArchive archive); - /// - /// Creates a zip file form the listed files and outputs to the temp folder. - /// - /// List of files to be zipped up. Should be full file paths. - /// Temp folder name to use for preparing the files. Will be created and deleted - /// Path to the temp zip - /// - string CreateZipForDownload(IEnumerable files, string tempFolder); + _logger = logger; + _directoryService = directoryService; + _imageService = imageService; } /// - /// Responsible for manipulating Archive files. Used by and + /// Checks if a File can be opened. Requires up to 2 opens of the filestream. /// - // ReSharper disable once ClassWithVirtualMembersNeverInherited.Global - public class ArchiveService : IArchiveService + /// + /// + public virtual ArchiveLibrary CanOpen(string archivePath) { - private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; - private readonly IImageService _imageService; - private const string ComicInfoFilename = "comicinfo"; + if (string.IsNullOrEmpty(archivePath) || !(File.Exists(archivePath) && Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath))) return ArchiveLibrary.NotSupported; + + var ext = _directoryService.FileSystem.Path.GetExtension(archivePath).ToUpper(); + if (ext.Equals(".CBR") || ext.Equals(".RAR")) return ArchiveLibrary.SharpCompress; - public ArchiveService(ILogger logger, IDirectoryService directoryService, IImageService imageService) + try { - _logger = logger; - _directoryService = directoryService; - _imageService = imageService; + using var a2 = ZipFile.OpenRead(archivePath); + return ArchiveLibrary.Default; } - - /// - /// Checks if a File can be opened. Requires up to 2 opens of the filestream. - /// - /// - /// - public virtual ArchiveLibrary CanOpen(string archivePath) + catch (Exception) { - if (string.IsNullOrEmpty(archivePath) || !(File.Exists(archivePath) && Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath))) return ArchiveLibrary.NotSupported; - - var ext = _directoryService.FileSystem.Path.GetExtension(archivePath).ToUpper(); - if (ext.Equals(".CBR") || ext.Equals(".RAR")) return ArchiveLibrary.SharpCompress; - try { - using var a2 = ZipFile.OpenRead(archivePath); - return ArchiveLibrary.Default; + using var a1 = ArchiveFactory.Open(archivePath); + return ArchiveLibrary.SharpCompress; } catch (Exception) { - try - { - using var a1 = ArchiveFactory.Open(archivePath); - return ArchiveLibrary.SharpCompress; - } - catch (Exception) - { - return ArchiveLibrary.NotSupported; - } + return ArchiveLibrary.NotSupported; } } + } - public int GetNumberOfPagesFromArchive(string archivePath) + public int GetNumberOfPagesFromArchive(string archivePath) + { + if (!IsValidArchive(archivePath)) { - if (!IsValidArchive(archivePath)) - { - _logger.LogError("Archive {ArchivePath} could not be found", archivePath); - return 0; - } + _logger.LogError("Archive {ArchivePath} could not be found", archivePath); + return 0; + } - try + try + { + var libraryHandler = CanOpen(archivePath); + switch (libraryHandler) { - var libraryHandler = CanOpen(archivePath); - switch (libraryHandler) + case ArchiveLibrary.Default: { - case ArchiveLibrary.Default: - { - using var archive = ZipFile.OpenRead(archivePath); - return archive.Entries.Count(e => !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(e.FullName) && Tasks.Scanner.Parser.Parser.IsImage(e.FullName)); - } - case ArchiveLibrary.SharpCompress: - { - using var archive = ArchiveFactory.Open(archivePath); - return archive.Entries.Count(entry => !entry.IsDirectory && - !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) - && Tasks.Scanner.Parser.Parser.IsImage(entry.Key)); - } - case ArchiveLibrary.NotSupported: - _logger.LogWarning("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); - return 0; - default: - _logger.LogWarning("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); - return 0; + using var archive = ZipFile.OpenRead(archivePath); + return archive.Entries.Count(e => !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(e.FullName) && Tasks.Scanner.Parser.Parser.IsImage(e.FullName)); } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); - return 0; + case ArchiveLibrary.SharpCompress: + { + using var archive = ArchiveFactory.Open(archivePath); + return archive.Entries.Count(entry => !entry.IsDirectory && + !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) + && Tasks.Scanner.Parser.Parser.IsImage(entry.Key)); + } + case ArchiveLibrary.NotSupported: + _logger.LogWarning("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); + return 0; + default: + _logger.LogWarning("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + return 0; } } - - /// - /// Finds the first instance of a folder entry and returns it - /// - /// - /// Entry name of match, null if no match - public static string FindFolderEntry(IEnumerable entryFullNames) + catch (Exception ex) { - var result = entryFullNames - .Where(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith))) - .OrderByNatural(Path.GetFileNameWithoutExtension) - .FirstOrDefault(Tasks.Scanner.Parser.Parser.IsCoverImage); - - return string.IsNullOrEmpty(result) ? null : result; + _logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + return 0; } + } + + /// + /// Finds the first instance of a folder entry and returns it + /// + /// + /// Entry name of match, null if no match + public static string FindFolderEntry(IEnumerable entryFullNames) + { + var result = entryFullNames + .Where(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith))) + .OrderByNatural(Path.GetFileNameWithoutExtension) + .FirstOrDefault(Tasks.Scanner.Parser.Parser.IsCoverImage); - /// - /// 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) + 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 + /// + /// + /// + /// Entry name of match, null if no match + 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(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)) && Tasks.Scanner.Parser.Parser.IsImage(path)) + .OrderByNatural(c => c.GetFullPathWithoutExtension()) + .ToList(); + if (fullNames.Count == 0) return null; + + var nonNestedFile = fullNames.Where(entry => (Path.GetDirectoryName(entry) ?? string.Empty).Equals(archiveName)) + .OrderByNatural(c => c.GetFullPathWithoutExtension()) + .FirstOrDefault(); + + if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile; + + // Check the first folder and sort within that to see if we can find a file, else fallback to first file with basic sort. + // Get first folder, then sort within that + var firstDirectoryFile = fullNames.OrderByNatural(Path.GetDirectoryName).FirstOrDefault(); + if (!string.IsNullOrEmpty(firstDirectoryFile)) { - // First check if there are any files that are not in a nested folder before just comparing by filename. This is needed - // because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg. - var fullNames = entryFullNames - .Where(path => !(Path.EndsInDirectorySeparator(path) || Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)) && Tasks.Scanner.Parser.Parser.IsImage(path)) - .OrderByNatural(c => c.GetFullPathWithoutExtension()) - .ToList(); - if (fullNames.Count == 0) return null; - - var nonNestedFile = fullNames.Where(entry => (Path.GetDirectoryName(entry) ?? string.Empty).Equals(archiveName)) - .OrderByNatural(c => c.GetFullPathWithoutExtension()) - .FirstOrDefault(); - - if (!string.IsNullOrEmpty(nonNestedFile)) return nonNestedFile; - - // Check the first folder and sort within that to see if we can find a file, else fallback to first file with basic sort. - // Get first folder, then sort within that - var firstDirectoryFile = fullNames.OrderByNatural(Path.GetDirectoryName).FirstOrDefault(); - if (!string.IsNullOrEmpty(firstDirectoryFile)) + var firstDirectory = Path.GetDirectoryName(firstDirectoryFile); + if (!string.IsNullOrEmpty(firstDirectory)) { - var firstDirectory = Path.GetDirectoryName(firstDirectoryFile); - if (!string.IsNullOrEmpty(firstDirectory)) - { - var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f))) - .OrderByNatural(Path.GetFileNameWithoutExtension) - .FirstOrDefault(); + var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f))) + .OrderByNatural(Path.GetFileNameWithoutExtension) + .FirstOrDefault(); - if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult; - } + if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult; } + } - var result = fullNames - .OrderByNatural(Path.GetFileNameWithoutExtension) - .FirstOrDefault(); + var result = fullNames + .OrderByNatural(Path.GetFileNameWithoutExtension) + .FirstOrDefault(); - return string.IsNullOrEmpty(result) ? null : result; - } + return string.IsNullOrEmpty(result) ? null : result; + } - /// - /// Generates byte array of cover image. - /// Given a path to a compressed file , will ensure the first image (respects directory structure) is returned unless - /// a folder/cover.(image extension) exists in the the compressed file (if duplicate, the first is chosen) - /// - /// This skips over any __MACOSX folder/file iteration. - /// - /// This always creates a thumbnail - /// - /// File name to use based on context of entity. - /// Where to output the file, defaults to covers directory - /// - public string GetCoverImage(string archivePath, string fileName, string outputDirectory) + /// + /// Generates byte array of cover image. + /// Given a path to a compressed file , will ensure the first image (respects directory structure) is returned unless + /// a folder/cover.(image extension) exists in the the compressed file (if duplicate, the first is chosen) + /// + /// This skips over any __MACOSX folder/file iteration. + /// + /// This always creates a thumbnail + /// + /// File name to use based on context of entity. + /// Where to output the file, defaults to covers directory + /// + public string GetCoverImage(string archivePath, string fileName, string outputDirectory) + { + if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; + try { - if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; - try + var libraryHandler = CanOpen(archivePath); + switch (libraryHandler) { - var libraryHandler = CanOpen(archivePath); - switch (libraryHandler) + case ArchiveLibrary.Default: { - case ArchiveLibrary.Default: - { - using var archive = ZipFile.OpenRead(archivePath); + using var archive = ZipFile.OpenRead(archivePath); - var entryName = FindCoverImageFilename(archivePath, archive.Entries.Select(e => e.FullName)); - var entry = archive.Entries.Single(e => e.FullName == entryName); + var entryName = FindCoverImageFilename(archivePath, archive.Entries.Select(e => e.FullName)); + var entry = archive.Entries.Single(e => e.FullName == entryName); - using var stream = entry.Open(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); - } - case ArchiveLibrary.SharpCompress: - { - using var archive = ArchiveFactory.Open(archivePath); - var entryNames = archive.Entries.Where(archiveEntry => !archiveEntry.IsDirectory).Select(e => e.Key).ToList(); + 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 = FindCoverImageFilename(archivePath, entryNames); - var entry = archive.Entries.Single(e => e.Key == entryName); + var entryName = FindCoverImageFilename(archivePath, entryNames); + var entry = archive.Entries.Single(e => e.Key == entryName); - using var stream = entry.OpenEntryStream(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); - } - case ArchiveLibrary.NotSupported: - _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); - return string.Empty; - default: - _logger.LogWarning("[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); - return string.Empty; + using var stream = entry.OpenEntryStream(); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); } + case ArchiveLibrary.NotSupported: + _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); + return string.Empty; + default: + _logger.LogWarning("[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); + return string.Empty; } - catch (Exception ex) - { - _logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); - } - - return string.Empty; } - - /// - /// Given a list of image paths (assume within an archive), find the filename that corresponds to the cover - /// - /// - /// - /// - public static string FindCoverImageFilename(string archivePath, IEnumerable entryNames) + catch (Exception ex) { - var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); - return entryName; + _logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); } - /// - /// 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. - /// - /// An opened archive stream - /// - public bool ArchiveNeedsFlattening(ZipArchive archive) - { - // Sometimes ZipArchive will list the directory and others it will just keep it in the FullName - return archive.Entries.Count > 0 && - !Path.HasExtension(archive.Entries.ElementAt(0).FullName) || - archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar) && !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(e.FullName)); - } + return string.Empty; + } - /// - /// Creates a zip file form the listed files and outputs to the temp folder. - /// - /// List of files to be zipped up. Should be full file paths. - /// Temp folder name to use for preparing the files. Will be created and deleted - /// Path to the temp zip - /// - public string CreateZipForDownload(IEnumerable files, string tempFolder) - { - var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); + /// + /// Given a list of image paths (assume within an archive), find the filename that corresponds to the cover + /// + /// + /// + /// + public static string FindCoverImageFilename(string archivePath, IEnumerable entryNames) + { + var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); + return entryName; + } - var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); - var potentialExistingFile = _directoryService.FileSystem.FileInfo.FromFileName(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip")); - if (potentialExistingFile.Exists) - { - // A previous download exists, just return it immediately - return potentialExistingFile.FullName; - } + /// + /// 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. + /// + /// An opened archive stream + /// + public bool ArchiveNeedsFlattening(ZipArchive archive) + { + // Sometimes ZipArchive will list the directory and others it will just keep it in the FullName + return archive.Entries.Count > 0 && + !Path.HasExtension(archive.Entries.ElementAt(0).FullName) || + archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar) && !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(e.FullName)); + } - _directoryService.ExistOrCreate(tempLocation); + /// + /// Creates a zip file form the listed files and outputs to the temp folder. + /// + /// List of files to be zipped up. Should be full file paths. + /// Temp folder name to use for preparing the files. Will be created and deleted + /// Path to the temp zip + /// + public string CreateZipForDownload(IEnumerable files, string tempFolder) + { + var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); - if (!_directoryService.CopyFilesToDirectory(files, tempLocation)) - { - throw new KavitaException("Unable to copy files to temp directory archive download."); - } + var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); + var potentialExistingFile = _directoryService.FileSystem.FileInfo.FromFileName(Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip")); + if (potentialExistingFile.Exists) + { + // A previous download exists, just return it immediately + return potentialExistingFile.FullName; + } - var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"); - try - { - ZipFile.CreateFromDirectory(tempLocation, zipPath); - } - catch (AggregateException ex) - { - _logger.LogError(ex, "There was an issue creating temp archive"); - throw new KavitaException("There was an issue creating temp archive"); - } + _directoryService.ExistOrCreate(tempLocation); - return zipPath; + if (!_directoryService.CopyFilesToDirectory(files, tempLocation)) + { + throw new KavitaException("Unable to copy files to temp directory archive download."); } - - /// - /// Test if the archive path exists and an archive - /// - /// - /// - public bool IsValidArchive(string archivePath) + var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"); + try { - if (!File.Exists(archivePath)) - { - _logger.LogWarning("Archive {ArchivePath} could not be found", archivePath); - return false; - } + ZipFile.CreateFromDirectory(tempLocation, zipPath); + } + catch (AggregateException ex) + { + _logger.LogError(ex, "There was an issue creating temp archive"); + throw new KavitaException("There was an issue creating temp archive"); + } - if (Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath)) return true; + return zipPath; + } - _logger.LogWarning("Archive {ArchivePath} is not a valid archive", archivePath); - return false; - } - private static bool ValidComicInfoArchiveEntry(string fullName, string name) + /// + /// Test if the archive path exists and an archive + /// + /// + /// + public bool IsValidArchive(string archivePath) + { + if (!File.Exists(archivePath)) { - var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower(); - return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName) - && filenameWithoutExtension.Equals(ComicInfoFilename, StringComparison.InvariantCultureIgnoreCase) - && !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith) - && Tasks.Scanner.Parser.Parser.IsXml(name); + _logger.LogWarning("Archive {ArchivePath} could not be found", archivePath); + return false; } - /// - /// This can be null if nothing is found or any errors occur during access - /// - /// - /// - public ComicInfo? GetComicInfo(string archivePath) + if (Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath)) return true; + + _logger.LogWarning("Archive {ArchivePath} is not a valid archive", archivePath); + return false; + } + + private static bool ValidComicInfoArchiveEntry(string fullName, string name) + { + var filenameWithoutExtension = Path.GetFileNameWithoutExtension(name).ToLower(); + return !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(fullName) + && filenameWithoutExtension.Equals(ComicInfoFilename, StringComparison.InvariantCultureIgnoreCase) + && !filenameWithoutExtension.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith) + && Tasks.Scanner.Parser.Parser.IsXml(name); + } + + /// + /// This can be null if nothing is found or any errors occur during access + /// + /// + /// + public ComicInfo? GetComicInfo(string archivePath) + { + if (!IsValidArchive(archivePath)) return null; + + try { - if (!IsValidArchive(archivePath)) return null; + if (!File.Exists(archivePath)) return null; - try + var libraryHandler = CanOpen(archivePath); + switch (libraryHandler) { - if (!File.Exists(archivePath)) return null; - - var libraryHandler = CanOpen(archivePath); - switch (libraryHandler) + case ArchiveLibrary.Default: { - case ArchiveLibrary.Default: + using var archive = ZipFile.OpenRead(archivePath); + + var entry = archive.Entries.FirstOrDefault(x => ValidComicInfoArchiveEntry(x.FullName, x.Name)); + if (entry != null) { - using var archive = ZipFile.OpenRead(archivePath); - - var entry = archive.Entries.FirstOrDefault(x => ValidComicInfoArchiveEntry(x.FullName, x.Name)); - if (entry != null) - { - using var stream = entry.Open(); - var serializer = new XmlSerializer(typeof(ComicInfo)); - var info = (ComicInfo) serializer.Deserialize(stream); - ComicInfo.CleanComicInfo(info); - return info; - } - - break; + using var stream = entry.Open(); + var serializer = new XmlSerializer(typeof(ComicInfo)); + var info = (ComicInfo) serializer.Deserialize(stream); + ComicInfo.CleanComicInfo(info); + return info; } - case ArchiveLibrary.SharpCompress: + + break; + } + case ArchiveLibrary.SharpCompress: + { + using var archive = ArchiveFactory.Open(archivePath); + var entry = archive.Entries.FirstOrDefault(entry => + ValidComicInfoArchiveEntry(Path.GetDirectoryName(entry.Key), entry.Key)); + + if (entry != null) { - using var archive = ArchiveFactory.Open(archivePath); - var entry = archive.Entries.FirstOrDefault(entry => - ValidComicInfoArchiveEntry(Path.GetDirectoryName(entry.Key), entry.Key)); - - if (entry != null) - { - using var stream = entry.OpenEntryStream(); - var serializer = new XmlSerializer(typeof(ComicInfo)); - var info = (ComicInfo) serializer.Deserialize(stream); - ComicInfo.CleanComicInfo(info); - return info; - } - - break; + using var stream = entry.OpenEntryStream(); + var serializer = new XmlSerializer(typeof(ComicInfo)); + var info = (ComicInfo) serializer.Deserialize(stream); + ComicInfo.CleanComicInfo(info); + return info; } - case ArchiveLibrary.NotSupported: - _logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath); - return null; - default: - _logger.LogWarning( - "[GetComicInfo] There was an exception when reading archive stream: {ArchivePath}", - archivePath); - return null; + + break; } + case ArchiveLibrary.NotSupported: + _logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath); + return null; + default: + _logger.LogWarning( + "[GetComicInfo] There was an exception when reading archive stream: {ArchivePath}", + archivePath); + return null; } - catch (Exception ex) - { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath); - } - - return null; } + catch (Exception ex) + { + _logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath); + } + + return null; + } - private void ExtractArchiveEntities(IEnumerable entries, string extractPath) + private void ExtractArchiveEntities(IEnumerable entries, string extractPath) + { + _directoryService.ExistOrCreate(extractPath); + foreach (var entry in entries) { - _directoryService.ExistOrCreate(extractPath); - foreach (var entry in entries) + entry.WriteToDirectory(extractPath, new ExtractionOptions() { - entry.WriteToDirectory(extractPath, new ExtractionOptions() - { - ExtractFullPath = true, // Don't flatten, let the flatterner ensure correct order of nested folders - Overwrite = false - }); - } + ExtractFullPath = true, // Don't flatten, let the flatterner ensure correct order of nested folders + Overwrite = false + }); } + } - private void ExtractArchiveEntries(ZipArchive archive, string extractPath) - { - var needsFlattening = ArchiveNeedsFlattening(archive); - if (!archive.HasFiles() && !needsFlattening) return; + private void ExtractArchiveEntries(ZipArchive archive, string extractPath) + { + var needsFlattening = ArchiveNeedsFlattening(archive); + if (!archive.HasFiles() && !needsFlattening) return; - archive.ExtractToDirectory(extractPath, true); - if (!needsFlattening) return; + archive.ExtractToDirectory(extractPath, true); + if (!needsFlattening) return; - _logger.LogDebug("Extracted archive is nested in root folder, flattening..."); - _directoryService.Flatten(extractPath); - } + _logger.LogDebug("Extracted archive is nested in root folder, flattening..."); + _directoryService.Flatten(extractPath); + } - /// - /// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists, - /// will return that without performing an extraction. Returns empty string if there are any invalidations which would - /// prevent operations to perform correctly (missing archivePath file, empty archive, etc). - /// - /// A valid file to an archive file. - /// Path to extract to - /// - public void ExtractArchive(string archivePath, string extractPath) - { - if (!IsValidArchive(archivePath)) return; + /// + /// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists, + /// will return that without performing an extraction. Returns empty string if there are any invalidations which would + /// prevent operations to perform correctly (missing archivePath file, empty archive, etc). + /// + /// A valid file to an archive file. + /// Path to extract to + /// + public void ExtractArchive(string archivePath, string extractPath) + { + if (!IsValidArchive(archivePath)) return; - if (Directory.Exists(extractPath)) return; + if (Directory.Exists(extractPath)) return; - if (!_directoryService.FileSystem.File.Exists(archivePath)) - { - _logger.LogError("{Archive} does not exist on disk", archivePath); - throw new KavitaException($"{archivePath} does not exist on disk"); - } + if (!_directoryService.FileSystem.File.Exists(archivePath)) + { + _logger.LogError("{Archive} does not exist on disk", archivePath); + throw new KavitaException($"{archivePath} does not exist on disk"); + } - var sw = Stopwatch.StartNew(); + var sw = Stopwatch.StartNew(); - try + try + { + var libraryHandler = CanOpen(archivePath); + switch (libraryHandler) { - var libraryHandler = CanOpen(archivePath); - switch (libraryHandler) + case ArchiveLibrary.Default: { - case ArchiveLibrary.Default: - { - using var archive = ZipFile.OpenRead(archivePath); - ExtractArchiveEntries(archive, extractPath); - break; - } - case ArchiveLibrary.SharpCompress: - { - using var archive = ArchiveFactory.Open(archivePath); - ExtractArchiveEntities(archive.Entries.Where(entry => !entry.IsDirectory - && !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) - && Tasks.Scanner.Parser.Parser.IsImage(entry.Key)), extractPath); - break; - } - case ArchiveLibrary.NotSupported: - _logger.LogWarning("[ExtractArchive] This archive cannot be read: {ArchivePath}", archivePath); - return; - default: - _logger.LogWarning("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}", archivePath); - return; + using var archive = ZipFile.OpenRead(archivePath); + ExtractArchiveEntries(archive, extractPath); + break; } - - } - catch (Exception e) - { - _logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); - throw new KavitaException( - $"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters."); + case ArchiveLibrary.SharpCompress: + { + using var archive = ArchiveFactory.Open(archivePath); + ExtractArchiveEntities(archive.Entries.Where(entry => !entry.IsDirectory + && !Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) + && Tasks.Scanner.Parser.Parser.IsImage(entry.Key)), extractPath); + break; + } + case ArchiveLibrary.NotSupported: + _logger.LogWarning("[ExtractArchive] This archive cannot be read: {ArchivePath}", archivePath); + return; + default: + _logger.LogWarning("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}", archivePath); + return; } - _logger.LogDebug("Extracted archive to {ExtractPath} in {ElapsedMilliseconds} milliseconds", extractPath, sw.ElapsedMilliseconds); + + } + catch (Exception e) + { + _logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); + throw new KavitaException( + $"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters."); } + _logger.LogDebug("Extracted archive to {ExtractPath} in {ElapsedMilliseconds} milliseconds", extractPath, sw.ElapsedMilliseconds); } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index d28183f9e7..dfbfc33d2b 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -26,962 +26,961 @@ using VersOne.Epub.Options; using Image = SixLabors.ImageSharp.Image; -namespace API.Services +namespace API.Services; + +public interface IBookService +{ + int GetNumberOfPages(string filePath); + string GetCoverImage(string fileFilePath, string fileName, string outputDirectory); + Task> CreateKeyToPageMappingAsync(EpubBookRef book); + + /// + /// Scopes styles to .reading-section and replaces img src to the passed apiBase + /// + /// + /// + /// If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath. + /// Book Reference, needed for if you expect Import statements + /// + Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book); + ComicInfo GetComicInfo(string filePath); + ParserInfo ParseInfo(string filePath); + /// + /// Extracts a PDF file's pages as images to an target directory + /// + /// + /// Where the files will be extracted to. If doesn't exist, will be created. + [Obsolete("This method of reading is no longer supported. Please use native pdf reader")] + void ExtractPdfImages(string fileFilePath, string targetDirectory); + + Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page); + Task> GenerateTableOfContents(Chapter chapter); + + Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); +} + +public class BookService : IBookService { - public interface IBookService + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IImageService _imageService; + private readonly StylesheetParser _cssParser = new (); + private static readonly RecyclableMemoryStreamManager StreamManager = new (); + private const string CssScopeClass = ".book-content"; + private const string BookApiUrl = "book-resources?file="; + public static readonly EpubReaderOptions BookReaderOptions = new() { - int GetNumberOfPages(string filePath); - string GetCoverImage(string fileFilePath, string fileName, string outputDirectory); - Task> CreateKeyToPageMappingAsync(EpubBookRef book); - - /// - /// Scopes styles to .reading-section and replaces img src to the passed apiBase - /// - /// - /// - /// If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath. - /// Book Reference, needed for if you expect Import statements - /// - Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book); - ComicInfo GetComicInfo(string filePath); - ParserInfo ParseInfo(string filePath); - /// - /// Extracts a PDF file's pages as images to an target directory - /// - /// - /// Where the files will be extracted to. If doesn't exist, will be created. - [Obsolete("This method of reading is no longer supported. Please use native pdf reader")] - void ExtractPdfImages(string fileFilePath, string targetDirectory); - - Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page); - Task> GenerateTableOfContents(Chapter chapter); - - Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl); + PackageReaderOptions = new PackageReaderOptions() + { + IgnoreMissingToc = true + } + }; + + public BookService(ILogger logger, IDirectoryService directoryService, IImageService imageService) + { + _logger = logger; + _directoryService = directoryService; + _imageService = imageService; } - public class BookService : IBookService + private static bool HasClickableHrefPart(HtmlNode anchor) { - private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; - private readonly IImageService _imageService; - private readonly StylesheetParser _cssParser = new (); - private static readonly RecyclableMemoryStreamManager StreamManager = new (); - private const string CssScopeClass = ".book-content"; - private const string BookApiUrl = "book-resources?file="; - public static readonly EpubReaderOptions BookReaderOptions = new() - { - PackageReaderOptions = new PackageReaderOptions() - { - IgnoreMissingToc = true - } - }; + return anchor.GetAttributeValue("href", string.Empty).Contains("#") + && anchor.GetAttributeValue("tabindex", string.Empty) != "-1" + && anchor.GetAttributeValue("role", string.Empty) != "presentation"; + } - public BookService(ILogger logger, IDirectoryService directoryService, IImageService imageService) + public static string GetContentType(EpubContentType type) + { + string contentType; + switch (type) { - _logger = logger; - _directoryService = directoryService; - _imageService = imageService; + case EpubContentType.IMAGE_GIF: + contentType = "image/gif"; + break; + case EpubContentType.IMAGE_PNG: + contentType = "image/png"; + break; + case EpubContentType.IMAGE_JPEG: + contentType = "image/jpeg"; + break; + case EpubContentType.FONT_OPENTYPE: + contentType = "font/otf"; + break; + case EpubContentType.FONT_TRUETYPE: + contentType = "font/ttf"; + break; + case EpubContentType.IMAGE_SVG: + contentType = "image/svg+xml"; + break; + default: + contentType = "application/octet-stream"; + break; } - private static bool HasClickableHrefPart(HtmlNode anchor) - { - return anchor.GetAttributeValue("href", string.Empty).Contains("#") - && anchor.GetAttributeValue("tabindex", string.Empty) != "-1" - && anchor.GetAttributeValue("role", string.Empty) != "presentation"; - } + return contentType; + } + + private static void UpdateLinks(HtmlNode anchor, Dictionary mappings, int currentPage) + { + if (anchor.Name != "a") return; + var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty)) + .Split("#"); + // Some keys get uri encoded when parsed, so replace any of those characters with original + var mappingKey = HttpUtility.UrlDecode(hrefParts[0]); - public static string GetContentType(EpubContentType type) + if (!mappings.ContainsKey(mappingKey)) { - string contentType; - switch (type) + if (HasClickableHrefPart(anchor)) { - case EpubContentType.IMAGE_GIF: - contentType = "image/gif"; - break; - case EpubContentType.IMAGE_PNG: - contentType = "image/png"; - break; - case EpubContentType.IMAGE_JPEG: - contentType = "image/jpeg"; - break; - case EpubContentType.FONT_OPENTYPE: - contentType = "font/otf"; - break; - case EpubContentType.FONT_TRUETYPE: - contentType = "font/ttf"; - break; - case EpubContentType.IMAGE_SVG: - contentType = "image/svg+xml"; - break; - default: - contentType = "application/octet-stream"; - break; + var part = hrefParts.Length > 1 + ? hrefParts[1] + : anchor.GetAttributeValue("href", string.Empty); + anchor.Attributes.Add("kavita-page", $"{currentPage}"); + anchor.Attributes.Add("kavita-part", part); + anchor.Attributes.Remove("href"); + anchor.Attributes.Add("href", "javascript:void(0)"); + } + else + { + anchor.Attributes.Add("target", "_blank"); + anchor.Attributes.Add("rel", "noreferrer noopener"); } - return contentType; + return; } - private static void UpdateLinks(HtmlNode anchor, Dictionary mappings, int currentPage) + var mappedPage = mappings[mappingKey]; + anchor.Attributes.Add("kavita-page", $"{mappedPage}"); + if (hrefParts.Length > 1) { - if (anchor.Name != "a") return; - var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty)) - .Split("#"); - // Some keys get uri encoded when parsed, so replace any of those characters with original - var mappingKey = HttpUtility.UrlDecode(hrefParts[0]); + anchor.Attributes.Add("kavita-part", + hrefParts[1]); + } - if (!mappings.ContainsKey(mappingKey)) - { - if (HasClickableHrefPart(anchor)) - { - var part = hrefParts.Length > 1 - ? hrefParts[1] - : anchor.GetAttributeValue("href", string.Empty); - anchor.Attributes.Add("kavita-page", $"{currentPage}"); - anchor.Attributes.Add("kavita-part", part); - anchor.Attributes.Remove("href"); - anchor.Attributes.Add("href", "javascript:void(0)"); - } - else - { - anchor.Attributes.Add("target", "_blank"); - anchor.Attributes.Add("rel", "noreferrer noopener"); - } + anchor.Attributes.Remove("href"); + anchor.Attributes.Add("href", "javascript:void(0)"); + } - return; - } + public async Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book) + { + // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped + var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty; + var importBuilder = new StringBuilder(); + foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) + { + if (!match.Success) continue; - var mappedPage = mappings[mappingKey]; - anchor.Attributes.Add("kavita-page", $"{mappedPage}"); - if (hrefParts.Length > 1) + var importFile = match.Groups["Filename"].Value; + var key = CleanContentKeys(importFile); + if (!key.Contains(prepend)) { - anchor.Attributes.Add("kavita-part", - hrefParts[1]); + key = prepend + key; } + if (!book.Content.AllFiles.ContainsKey(key)) continue; - anchor.Attributes.Remove("href"); - anchor.Attributes.Add("href", "javascript:void(0)"); + var bookFile = book.Content.AllFiles[key]; + var content = await bookFile.ReadContentAsBytesAsync(); + importBuilder.Append(Encoding.UTF8.GetString(content)); } - public async Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book) - { - // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped - var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty; - var importBuilder = new StringBuilder(); - foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) - { - if (!match.Success) continue; - - var importFile = match.Groups["Filename"].Value; - var key = CleanContentKeys(importFile); - if (!key.Contains(prepend)) - { - key = prepend + key; - } - if (!book.Content.AllFiles.ContainsKey(key)) continue; - - var bookFile = book.Content.AllFiles[key]; - var content = await bookFile.ReadContentAsBytesAsync(); - importBuilder.Append(Encoding.UTF8.GetString(content)); - } - - stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); + stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); - EscapeCssImportReferences(ref stylesheetHtml, apiBase, prepend); + EscapeCssImportReferences(ref stylesheetHtml, apiBase, prepend); - EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); + EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); - // Check if there are any background images and rewrite those urls - EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); + // Check if there are any background images and rewrite those urls + EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); - var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml); + var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml); - styleContent = styleContent.Replace("body", CssScopeClass); + styleContent = styleContent.Replace("body", CssScopeClass); - if (string.IsNullOrEmpty(styleContent)) return string.Empty; + if (string.IsNullOrEmpty(styleContent)) return string.Empty; - var stylesheet = await _cssParser.ParseAsync(styleContent); - foreach (var styleRule in stylesheet.StyleRules) + var stylesheet = await _cssParser.ParseAsync(styleContent); + foreach (var styleRule in stylesheet.StyleRules) + { + if (styleRule.Selector.Text == CssScopeClass) continue; + if (styleRule.Selector.Text.Contains(",")) { - if (styleRule.Selector.Text == CssScopeClass) continue; - if (styleRule.Selector.Text.Contains(",")) - { - styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText, - string.Join(", ", - styleRule.Selector.Text.Split(",").Select(s => $"{CssScopeClass} " + s))); - continue; - } - styleRule.Text = $"{CssScopeClass} " + styleRule.Text; + styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText, + string.Join(", ", + styleRule.Selector.Text.Split(",").Select(s => $"{CssScopeClass} " + s))); + continue; } - return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); + styleRule.Text = $"{CssScopeClass} " + styleRule.Text; } + return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); + } - private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend) + private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend) + { + foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) { - foreach (Match match in Tasks.Scanner.Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) - { - if (!match.Success) continue; - var importFile = match.Groups["Filename"].Value; - stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); - } + if (!match.Success) continue; + var importFile = match.Groups["Filename"].Value; + stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); } + } - private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend) + private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend) + { + foreach (Match match in Tasks.Scanner.Parser.Parser.FontSrcUrlRegex.Matches(stylesheetHtml)) { - foreach (Match match in Tasks.Scanner.Parser.Parser.FontSrcUrlRegex.Matches(stylesheetHtml)) - { - if (!match.Success) continue; - var importFile = match.Groups["Filename"].Value; - stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); - } + if (!match.Success) continue; + var importFile = match.Groups["Filename"].Value; + stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); } + } - private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book) + private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book) + { + var matches = Tasks.Scanner.Parser.Parser.CssImageUrlRegex.Matches(stylesheetHtml); + foreach (Match match in matches) { - var matches = Tasks.Scanner.Parser.Parser.CssImageUrlRegex.Matches(stylesheetHtml); - foreach (Match match in matches) - { - if (!match.Success) continue; + if (!match.Success) continue; - var importFile = match.Groups["Filename"].Value; - var key = CleanContentKeys(importFile); - if (!book.Content.AllFiles.ContainsKey(key)) continue; + var importFile = match.Groups["Filename"].Value; + var key = CleanContentKeys(importFile); + if (!book.Content.AllFiles.ContainsKey(key)) continue; - stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key); - } + stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key); } + } - private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) - { - var images = doc.DocumentNode.SelectNodes("//img") - ?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg"); + private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) + { + var images = doc.DocumentNode.SelectNodes("//img") + ?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg"); + + if (images == null) return; - if (images == null) return; + var parent = images.First().ParentNode; - var parent = images.First().ParentNode; + foreach (var image in images) + { - foreach (var image in images) + string key = null; + if (image.Attributes["src"] != null) + { + key = "src"; + } + else if (image.Attributes["xlink:href"] != null) { + key = "xlink:href"; + } - string key = null; - if (image.Attributes["src"] != null) - { - key = "src"; - } - else if (image.Attributes["xlink:href"] != null) - { - key = "xlink:href"; - } + if (string.IsNullOrEmpty(key)) continue; - if (string.IsNullOrEmpty(key)) continue; + var imageFile = GetKeyForImage(book, image.Attributes[key].Value); + image.Attributes.Remove(key); + // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx + image.Attributes.Add(key, $"{apiBase}" + HttpUtility.UrlEncode(imageFile)); - var imageFile = GetKeyForImage(book, image.Attributes[key].Value); - image.Attributes.Remove(key); - // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx - image.Attributes.Add(key, $"{apiBase}" + HttpUtility.UrlEncode(imageFile)); + // Add a custom class that the reader uses to ensure images stay within reader + parent.AddClass("kavita-scale-width-container"); + image.AddClass("kavita-scale-width"); + } - // Add a custom class that the reader uses to ensure images stay within reader - parent.AddClass("kavita-scale-width-container"); - image.AddClass("kavita-scale-width"); - } + } - } + /// + /// Returns the image key associated with the file. Contains some basic fallback logic. + /// + /// + /// + /// + private static string GetKeyForImage(EpubBookRef book, string imageFile) + { + if (book.Content.Images.ContainsKey(imageFile)) return imageFile; - /// - /// Returns the image key associated with the file. Contains some basic fallback logic. - /// - /// - /// - /// - private static string GetKeyForImage(EpubBookRef book, string imageFile) + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) { - if (book.Content.Images.ContainsKey(imageFile)) return imageFile; - - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + 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; } - 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; - } - } - - return imageFile; } - private static string PrepareFinalHtml(HtmlDocument doc, HtmlNode body) - { - // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping - var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); - if (htmlNode == null || !htmlNode.Attributes.Contains("class")) return body.InnerHtml; + return imageFile; + } - var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; - var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; - body.Attributes.Add("class", $"{classes}"); - // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. - return $"
{body.InnerHtml}
"; - } + private static string PrepareFinalHtml(HtmlDocument doc, HtmlNode body) + { + // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping + var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); + if (htmlNode == null || !htmlNode.Attributes.Contains("class")) return body.InnerHtml; + + var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; + var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; + body.Attributes.Add("class", $"{classes}"); + // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. + return $"
{body.InnerHtml}
"; + } - private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary mappings) + private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary mappings) + { + var anchors = doc.DocumentNode.SelectNodes("//a"); + if (anchors == null) return; + + foreach (var anchor in anchors) { - var anchors = doc.DocumentNode.SelectNodes("//a"); - if (anchors == null) return; + UpdateLinks(anchor, mappings, page); + } + } - foreach (var anchor in anchors) + private async Task InlineStyles(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body) + { + var inlineStyles = doc.DocumentNode.SelectNodes("//style"); + if (inlineStyles != null) + { + foreach (var inlineStyle in inlineStyles) { - UpdateLinks(anchor, mappings, page); + var styleContent = await ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); + body.PrependChild(HtmlNode.CreateNode($"")); } } - private async Task InlineStyles(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body) + var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); + if (styleNodes != null) { - var inlineStyles = doc.DocumentNode.SelectNodes("//style"); - if (inlineStyles != null) + foreach (var styleLinks in styleNodes) { - foreach (var inlineStyle in inlineStyles) + var key = CleanContentKeys(styleLinks.Attributes["href"].Value); + // Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml + // In this case, we will do a search for the key that ends with + if (!book.Content.Css.ContainsKey(key)) { - var styleContent = await ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); - body.PrependChild(HtmlNode.CreateNode($"")); + var correctedKey = book.Content.Css.Keys.SingleOrDefault(s => s.EndsWith(key)); + if (correctedKey == null) + { + _logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key); + continue; + } + + key = correctedKey; } - } - var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); - if (styleNodes != null) - { - foreach (var styleLinks in styleNodes) + try { - var key = CleanContentKeys(styleLinks.Attributes["href"].Value); - // Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml - // In this case, we will do a search for the key that ends with - if (!book.Content.Css.ContainsKey(key)) - { - var correctedKey = book.Content.Css.Keys.SingleOrDefault(s => s.EndsWith(key)); - if (correctedKey == null) - { - _logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key); - continue; - } - - key = correctedKey; - } + var cssFile = book.Content.Css[key]; - try - { - var cssFile = book.Content.Css[key]; - - var styleContent = await ScopeStyles(await cssFile.ReadContentAsync(), apiBase, - cssFile.FileName, book); - if (styleContent != null) - { - body.PrependChild(HtmlNode.CreateNode($"")); - } - } - catch (Exception ex) + var styleContent = await ScopeStyles(await cssFile.ReadContentAsync(), apiBase, + cssFile.FileName, book); + if (styleContent != null) { - _logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata"); + body.PrependChild(HtmlNode.CreateNode($"")); } } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata"); + } } } + } + + public ComicInfo GetComicInfo(string filePath) + { + if (!IsValidFile(filePath) || Tasks.Scanner.Parser.Parser.IsPdf(filePath)) return null; - public ComicInfo GetComicInfo(string filePath) + try { - if (!IsValidFile(filePath) || Tasks.Scanner.Parser.Parser.IsPdf(filePath)) return null; + using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + var publicationDate = + epubBook.Schema.Package.Metadata.Dates.FirstOrDefault(date => date.Event == "publication")?.Date; - try + if (string.IsNullOrEmpty(publicationDate)) + { + publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; + } + var dateParsed = DateTime.TryParse(publicationDate, out var date); + var year = 0; + var month = 0; + var day = 0; + switch (dateParsed) { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); - var publicationDate = - epubBook.Schema.Package.Metadata.Dates.FirstOrDefault(date => date.Event == "publication")?.Date; + case true: + year = date.Year; + month = date.Month; + day = date.Day; + break; + case false when !string.IsNullOrEmpty(publicationDate) && publicationDate.Length == 4: + int.TryParse(publicationDate, out year); + break; + } - if (string.IsNullOrEmpty(publicationDate)) - { - publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; - } - var dateParsed = DateTime.TryParse(publicationDate, out var date); - var year = 0; - var month = 0; - var day = 0; - switch (dateParsed) + var info = new ComicInfo() + { + Summary = epubBook.Schema.Package.Metadata.Description, + Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Tasks.Scanner.Parser.Parser.CleanAuthor(c.Creator))), + Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), + Month = month, + Day = day, + Year = year, + Title = epubBook.Title, + Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), + LanguageISO = epubBook.Schema.Package.Metadata.Languages.FirstOrDefault() ?? string.Empty + }; + ComicInfo.CleanComicInfo(info); + + // Parse tags not exposed via Library + foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) + { + switch (metadataItem.Name) { - case true: - year = date.Year; - month = date.Month; - day = date.Day; + case "calibre:rating": + info.UserRating = float.Parse(metadataItem.Content); break; - case false when !string.IsNullOrEmpty(publicationDate) && publicationDate.Length == 4: - int.TryParse(publicationDate, out year); + case "calibre:title_sort": + info.TitleSort = metadataItem.Content; break; } - - var info = new ComicInfo() - { - Summary = epubBook.Schema.Package.Metadata.Description, - Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Tasks.Scanner.Parser.Parser.CleanAuthor(c.Creator))), - Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), - Month = month, - Day = day, - Year = year, - Title = epubBook.Title, - Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), - LanguageISO = epubBook.Schema.Package.Metadata.Languages.FirstOrDefault() ?? string.Empty - }; - ComicInfo.CleanComicInfo(info); - - // Parse tags not exposed via Library - foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) - { - switch (metadataItem.Name) - { - case "calibre:rating": - info.UserRating = float.Parse(metadataItem.Content); - break; - case "calibre:title_sort": - info.TitleSort = metadataItem.Content; - break; - } - } - - return info; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception getting metadata"); } - return null; + return info; } - - private bool IsValidFile(string filePath) + catch (Exception ex) { - if (!File.Exists(filePath)) - { - _logger.LogWarning("[BookService] Book {EpubFile} could not be found", filePath); - return false; - } + _logger.LogWarning(ex, "[GetComicInfo] There was an exception getting metadata"); + } - if (Tasks.Scanner.Parser.Parser.IsBook(filePath)) return true; + return null; + } - _logger.LogWarning("[BookService] Book {EpubFile} is not a valid EPUB/PDF", filePath); + private bool IsValidFile(string filePath) + { + if (!File.Exists(filePath)) + { + _logger.LogWarning("[BookService] Book {EpubFile} could not be found", filePath); return false; } - public int GetNumberOfPages(string filePath) - { - if (!IsValidFile(filePath)) return 0; + if (Tasks.Scanner.Parser.Parser.IsBook(filePath)) return true; - try - { - if (Tasks.Scanner.Parser.Parser.IsPdf(filePath)) - { - using var docReader = DocLib.Instance.GetDocReader(filePath, new PageDimensions(1080, 1920)); - return docReader.GetPageCount(); - } - - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); - return epubBook.Content.Html.Count; - } - catch (Exception ex) + _logger.LogWarning("[BookService] Book {EpubFile} is not a valid EPUB/PDF", filePath); + return false; + } + + public int GetNumberOfPages(string filePath) + { + if (!IsValidFile(filePath)) return 0; + + try + { + if (Tasks.Scanner.Parser.Parser.IsPdf(filePath)) { - _logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0"); + using var docReader = DocLib.Instance.GetDocReader(filePath, new PageDimensions(1080, 1920)); + return docReader.GetPageCount(); } - return 0; + using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + return epubBook.Content.Html.Count; } - - public static string EscapeTags(string content) + catch (Exception ex) { - content = Regex.Replace(content, @")", ""); - content = Regex.Replace(content, @")", ""); - return content; + _logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0"); } - /// - /// Removes all leading ../ - /// - /// - /// - public static string CleanContentKeys(string key) + return 0; + } + + public static string EscapeTags(string content) + { + content = Regex.Replace(content, @")", ""); + content = Regex.Replace(content, @")", ""); + return content; + } + + /// + /// Removes all leading ../ + /// + /// + /// + public static string CleanContentKeys(string key) + { + return key.Replace("../", string.Empty); + } + + public async Task> CreateKeyToPageMappingAsync(EpubBookRef book) + { + var dict = new Dictionary(); + var pageCount = 0; + foreach (var contentFileRef in await book.GetReadingOrderAsync()) { - return key.Replace("../", string.Empty); + if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue; + dict.Add(contentFileRef.FileName, pageCount); + pageCount += 1; } - public async Task> CreateKeyToPageMappingAsync(EpubBookRef book) + return dict; + } + + /// + /// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books) + /// then null is returned. This expects only an epub file + /// + /// + /// + public ParserInfo ParseInfo(string filePath) + { + if (!Tasks.Scanner.Parser.Parser.IsEpub(filePath)) return null; + + try { - var dict = new Dictionary(); - var pageCount = 0; - foreach (var contentFileRef in await book.GetReadingOrderAsync()) + using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + + // + // + // If all three are present, we can take that over dc:title and format as: + // Series = The Dark Tower, Volume = 5, Filename as "Wolves of the Calla" + // In addition, the following can exist and should parse as a series (EPUB 3.2 spec) + // + // The Lord of the Rings + // + // set + // 2 + try { - if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue; - dict.Add(contentFileRef.FileName, pageCount); - pageCount += 1; - } - - return dict; - } - - /// - /// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books) - /// then null is returned. This expects only an epub file - /// - /// - /// - public ParserInfo ParseInfo(string filePath) - { - if (!Tasks.Scanner.Parser.Parser.IsEpub(filePath)) return null; - - try - { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); - - // - // - // If all three are present, we can take that over dc:title and format as: - // Series = The Dark Tower, Volume = 5, Filename as "Wolves of the Calla" - // In addition, the following can exist and should parse as a series (EPUB 3.2 spec) - // - // The Lord of the Rings - // - // set - // 2 - try - { - var seriesIndex = string.Empty; - var series = string.Empty; - var specialName = string.Empty; - var groupPosition = string.Empty; - var titleSort = string.Empty; + var seriesIndex = string.Empty; + var series = string.Empty; + var specialName = string.Empty; + var groupPosition = string.Empty; + var titleSort = string.Empty; - foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) + foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) + { + // EPUB 2 and 3 + switch (metadataItem.Name) { - // EPUB 2 and 3 - switch (metadataItem.Name) - { - case "calibre:series_index": - seriesIndex = metadataItem.Content; - break; - case "calibre:series": - series = metadataItem.Content; - break; - case "calibre:title_sort": - specialName = metadataItem.Content; - titleSort = metadataItem.Content; - break; - } - - // EPUB 3.2+ only - switch (metadataItem.Property) - { - case "group-position": - seriesIndex = metadataItem.Content; - break; - case "belongs-to-collection": - series = metadataItem.Content; - break; - case "collection-type": - groupPosition = metadataItem.Content; - break; - } + case "calibre:series_index": + seriesIndex = metadataItem.Content; + break; + case "calibre:series": + series = metadataItem.Content; + break; + case "calibre:title_sort": + specialName = metadataItem.Content; + titleSort = metadataItem.Content; + break; } - if (!string.IsNullOrEmpty(series) && !string.IsNullOrEmpty(seriesIndex)) + // EPUB 3.2+ only + switch (metadataItem.Property) { - if (string.IsNullOrEmpty(specialName)) - { - specialName = epubBook.Title; - } - var info = new ParserInfo() - { - Chapters = Tasks.Scanner.Parser.Parser.DefaultChapter, - Edition = string.Empty, - Format = MangaFormat.Epub, - Filename = Path.GetFileName(filePath), - Title = specialName?.Trim(), - FullFilePath = filePath, - IsSpecial = false, - Series = series.Trim(), - Volumes = seriesIndex - }; - - // Don't set titleSort if the book belongs to a group - if (!string.IsNullOrEmpty(titleSort) && string.IsNullOrEmpty(seriesIndex) && (groupPosition.Equals("series") || groupPosition.Equals("set"))) - { - info.SeriesSort = titleSort; - } - - return info; + case "group-position": + seriesIndex = metadataItem.Content; + break; + case "belongs-to-collection": + series = metadataItem.Content; + break; + case "collection-type": + groupPosition = metadataItem.Content; + break; } } - catch (Exception) - { - // Swallow exception - } - return new ParserInfo() + if (!string.IsNullOrEmpty(series) && !string.IsNullOrEmpty(seriesIndex)) { - Chapters = Tasks.Scanner.Parser.Parser.DefaultChapter, - Edition = string.Empty, - Format = MangaFormat.Epub, - Filename = Path.GetFileName(filePath), - Title = epubBook.Title.Trim(), - FullFilePath = filePath, - IsSpecial = false, - Series = epubBook.Title.Trim(), - Volumes = Tasks.Scanner.Parser.Parser.DefaultVolume, - }; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath); - } - - return null; - } - - /// - /// Extracts a pdf into images to a target directory. Uses multi-threaded implementation since docnet is slow normally. - /// - /// - /// - public void ExtractPdfImages(string fileFilePath, string targetDirectory) - { - _directoryService.ExistOrCreate(targetDirectory); + if (string.IsNullOrEmpty(specialName)) + { + specialName = epubBook.Title; + } + var info = new ParserInfo() + { + Chapters = Tasks.Scanner.Parser.Parser.DefaultChapter, + Edition = string.Empty, + Format = MangaFormat.Epub, + Filename = Path.GetFileName(filePath), + Title = specialName?.Trim(), + FullFilePath = filePath, + IsSpecial = false, + Series = series.Trim(), + Volumes = seriesIndex + }; + + // Don't set titleSort if the book belongs to a group + if (!string.IsNullOrEmpty(titleSort) && string.IsNullOrEmpty(seriesIndex) && (groupPosition.Equals("series") || groupPosition.Equals("set"))) + { + info.SeriesSort = titleSort; + } - using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920)); - var pages = docReader.GetPageCount(); - Parallel.For(0, pages, pageNumber => + return info; + } + } + catch (Exception) { - using var stream = StreamManager.GetStream("BookService.GetPdfPage"); - GetPdfPage(docReader, pageNumber, stream); - using var fileStream = File.Create(Path.Combine(targetDirectory, "Page-" + pageNumber + ".png")); - stream.Seek(0, SeekOrigin.Begin); - stream.CopyTo(fileStream); - }); + // Swallow exception + } + + return new ParserInfo() + { + Chapters = Tasks.Scanner.Parser.Parser.DefaultChapter, + Edition = string.Empty, + Format = MangaFormat.Epub, + Filename = Path.GetFileName(filePath), + Title = epubBook.Title.Trim(), + FullFilePath = filePath, + IsSpecial = false, + Series = epubBook.Title.Trim(), + Volumes = Tasks.Scanner.Parser.Parser.DefaultVolume, + }; } + catch (Exception ex) + { + _logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath); + } + + return null; + } + + /// + /// Extracts a pdf into images to a target directory. Uses multi-threaded implementation since docnet is slow normally. + /// + /// + /// + public void ExtractPdfImages(string fileFilePath, string targetDirectory) + { + _directoryService.ExistOrCreate(targetDirectory); - /// - /// Responsible to scope all the css, links, tags, etc to prepare a self contained html file for the page - /// - /// Html Doc that will be appended to - /// Underlying epub - /// API Url for file loading to pass through - /// Body element from the epub - /// Epub mappings - /// Page number we are loading - /// - public async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page) + using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920)); + var pages = docReader.GetPageCount(); + Parallel.For(0, pages, pageNumber => { - await InlineStyles(doc, book, apiBase, body); + using var stream = StreamManager.GetStream("BookService.GetPdfPage"); + GetPdfPage(docReader, pageNumber, stream); + using var fileStream = File.Create(Path.Combine(targetDirectory, "Page-" + pageNumber + ".png")); + stream.Seek(0, SeekOrigin.Begin); + stream.CopyTo(fileStream); + }); + } - RewriteAnchors(page, doc, mappings); + /// + /// Responsible to scope all the css, links, tags, etc to prepare a self contained html file for the page + /// + /// Html Doc that will be appended to + /// Underlying epub + /// API Url for file loading to pass through + /// Body element from the epub + /// Epub mappings + /// Page number we are loading + /// + public async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page) + { + await InlineStyles(doc, book, apiBase, body); - ScopeImages(doc, book, apiBase); + RewriteAnchors(page, doc, mappings); - return PrepareFinalHtml(doc, body); - } + ScopeImages(doc, book, apiBase); - /// - /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order - /// this is used to rewrite anchors in the book text so that we always load properly in our reader. - /// - /// Chapter with at least one file - /// - public async Task> GenerateTableOfContents(Chapter chapter) - { - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); - var mappings = await CreateKeyToPageMappingAsync(book); + return PrepareFinalHtml(doc, body); + } + + /// + /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order + /// this is used to rewrite anchors in the book text so that we always load properly in our reader. + /// + /// Chapter with at least one file + /// + public async Task> GenerateTableOfContents(Chapter chapter) + { + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); + var mappings = await CreateKeyToPageMappingAsync(book); - var navItems = await book.GetNavigationAsync(); - var chaptersList = new List(); + var navItems = await book.GetNavigationAsync(); + var chaptersList = new List(); - foreach (var navigationItem in navItems) + foreach (var navigationItem in navItems) + { + if (navigationItem.NestedItems.Count == 0) { - if (navigationItem.NestedItems.Count == 0) - { - CreateToCChapter(navigationItem, Array.Empty(), chaptersList, mappings); - continue; - } + CreateToCChapter(navigationItem, Array.Empty(), chaptersList, mappings); + continue; + } - var nestedChapters = new List(); + var nestedChapters = new List(); - foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null)) + foreach (var nestedChapter in navigationItem.NestedItems.Where(n => n.Link != null)) + { + var key = BookService.CleanContentKeys(nestedChapter.Link.ContentFileName); + if (mappings.ContainsKey(key)) { - var key = BookService.CleanContentKeys(nestedChapter.Link.ContentFileName); - if (mappings.ContainsKey(key)) + nestedChapters.Add(new BookChapterItem() { - nestedChapters.Add(new BookChapterItem() - { - Title = nestedChapter.Title, - Page = mappings[key], - Part = nestedChapter.Link.Anchor ?? string.Empty, - Children = new List() - }); - } + Title = nestedChapter.Title, + Page = mappings[key], + Part = nestedChapter.Link.Anchor ?? string.Empty, + Children = new List() + }); } - - CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings); } - if (chaptersList.Count != 0) return chaptersList; - // Generate from TOC - var tocPage = book.Content.Html.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC")); - if (tocPage == null) return chaptersList; + CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings); + } - // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content - var doc = new HtmlDocument(); - var content = await book.Content.Html[tocPage].ReadContentAsync(); - doc.LoadHtml(content); - var anchors = doc.DocumentNode.SelectNodes("//a"); - if (anchors == null) return chaptersList; + if (chaptersList.Count != 0) return chaptersList; + // Generate from TOC + var tocPage = book.Content.Html.Keys.FirstOrDefault(k => k.ToUpper().Contains("TOC")); + if (tocPage == null) return chaptersList; - foreach (var anchor in anchors) - { - if (!anchor.Attributes.Contains("href")) continue; + // Find all anchor tags, for each anchor we get inner text, to lower then title case on UI. Get href and generate page content + var doc = new HtmlDocument(); + var content = await book.Content.Html[tocPage].ReadContentAsync(); + doc.LoadHtml(content); + var anchors = doc.DocumentNode.SelectNodes("//a"); + if (anchors == null) return chaptersList; - var key = BookService.CleanContentKeys(anchor.Attributes["href"].Value).Split("#")[0]; - if (!mappings.ContainsKey(key)) - { - // Fallback to searching for key (bad epub metadata) - var correctedKey = book.Content.Html.Keys.SingleOrDefault(s => s.EndsWith(key)); - if (!string.IsNullOrEmpty(correctedKey)) - { - key = correctedKey; - } - } + foreach (var anchor in anchors) + { + if (!anchor.Attributes.Contains("href")) continue; - if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue; - var part = string.Empty; - if (anchor.Attributes["href"].Value.Contains('#')) + var key = BookService.CleanContentKeys(anchor.Attributes["href"].Value).Split("#")[0]; + if (!mappings.ContainsKey(key)) + { + // Fallback to searching for key (bad epub metadata) + var correctedKey = book.Content.Html.Keys.SingleOrDefault(s => s.EndsWith(key)); + if (!string.IsNullOrEmpty(correctedKey)) { - part = anchor.Attributes["href"].Value.Split("#")[1]; + key = correctedKey; } - chaptersList.Add(new BookChapterItem() - { - Title = anchor.InnerText, - Page = mappings[key], - Part = part, - Children = new List() - }); } - return chaptersList; + if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue; + var part = string.Empty; + if (anchor.Attributes["href"].Value.Contains('#')) + { + part = anchor.Attributes["href"].Value.Split("#")[1]; + } + chaptersList.Add(new BookChapterItem() + { + Title = anchor.InnerText, + Page = mappings[key], + Part = part, + Children = new List() + }); } - /// - /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, - /// all css is scoped, etc. - /// - /// The requested page - /// The chapterId - /// The path to the cached epub file - /// The API base for Kavita, to rewrite urls to so we load though our endpoint - /// Full epub HTML Page, scoped to Kavita's reader - /// All exceptions throw this - public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) - { - using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); - var mappings = await CreateKeyToPageMappingAsync(book); - var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; + return chaptersList; + } - var counter = 0; - var doc = new HtmlDocument {OptionFixNestedTags = true}; + /// + /// This returns a single page within the epub book. All html will be rewritten to be scoped within our reader, + /// all css is scoped, etc. + /// + /// The requested page + /// The chapterId + /// The path to the cached epub file + /// The API base for Kavita, to rewrite urls to so we load though our endpoint + /// Full epub HTML Page, scoped to Kavita's reader + /// All exceptions throw this + public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) + { + using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); + var mappings = await CreateKeyToPageMappingAsync(book); + var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; + var counter = 0; + var doc = new HtmlDocument {OptionFixNestedTags = true}; - var bookPages = await book.GetReadingOrderAsync(); - foreach (var contentFileRef in bookPages) + + var bookPages = await book.GetReadingOrderAsync(); + foreach (var contentFileRef in bookPages) + { + if (page != counter) { - if (page != counter) - { - counter++; - continue; - } + counter++; + continue; + } - var content = await contentFileRef.ReadContentAsync(); - if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return content; + var content = await contentFileRef.ReadContentAsync(); + if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return content; - // In more cases than not, due to this being XML not HTML, we need to escape the script tags. - content = BookService.EscapeTags(content); + // In more cases than not, due to this being XML not HTML, we need to escape the script tags. + content = BookService.EscapeTags(content); - doc.LoadHtml(content); - var body = doc.DocumentNode.SelectSingleNode("//body"); + doc.LoadHtml(content); + var body = doc.DocumentNode.SelectSingleNode("//body"); - if (body == null) + if (body == null) + { + if (doc.ParseErrors.Any()) { - if (doc.ParseErrors.Any()) - { - LogBookErrors(book, contentFileRef, doc); - throw new KavitaException("The file is malformed! Cannot read."); - } - _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); - doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); - body = doc.DocumentNode.SelectSingleNode("/html/body"); + LogBookErrors(book, contentFileRef, doc); + throw new KavitaException("The file is malformed! Cannot read."); } - - return await ScopePage(doc, book, apiBase, body, mappings, page); + _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); + doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); + body = doc.DocumentNode.SelectSingleNode("/html/body"); } - throw new KavitaException("Could not find the appropriate html for that page"); + return await ScopePage(doc, book, apiBase, body, mappings, page); } - private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList nestedChapters, IList chaptersList, - IReadOnlyDictionary mappings) + throw new KavitaException("Could not find the appropriate html for that page"); + } + + private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList nestedChapters, IList chaptersList, + IReadOnlyDictionary mappings) + { + if (navigationItem.Link == null) { - if (navigationItem.Link == null) + var item = new BookChapterItem() { - var item = new BookChapterItem() - { - Title = navigationItem.Title, - Children = nestedChapters - }; - if (nestedChapters.Count > 0) - { - item.Page = nestedChapters[0].Page; - } - - chaptersList.Add(item); + Title = navigationItem.Title, + Children = nestedChapters + }; + if (nestedChapters.Count > 0) + { + item.Page = nestedChapters[0].Page; } - else + + chaptersList.Add(item); + } + else + { + var groupKey = CleanContentKeys(navigationItem.Link.ContentFileName); + if (mappings.ContainsKey(groupKey)) { - var groupKey = CleanContentKeys(navigationItem.Link.ContentFileName); - if (mappings.ContainsKey(groupKey)) + chaptersList.Add(new BookChapterItem() { - chaptersList.Add(new BookChapterItem() - { - Title = navigationItem.Title, - Page = mappings[groupKey], - Children = nestedChapters - }); - } + Title = navigationItem.Title, + Page = mappings[groupKey], + Children = nestedChapters + }); } } + } - /// - /// Extracts the cover image to covers directory and returns file path back - /// - /// - /// Name of the new file. - /// Where to output the file, defaults to covers directory - /// - public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory) - { - if (!IsValidFile(fileFilePath)) return string.Empty; - - if (Tasks.Scanner.Parser.Parser.IsPdf(fileFilePath)) - { - return GetPdfCoverImage(fileFilePath, fileName, outputDirectory); - } + /// + /// Extracts the cover image to covers directory and returns file path back + /// + /// + /// Name of the new file. + /// Where to output the file, defaults to covers directory + /// + public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory) + { + if (!IsValidFile(fileFilePath)) return string.Empty; - using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); + if (Tasks.Scanner.Parser.Parser.IsPdf(fileFilePath)) + { + return GetPdfCoverImage(fileFilePath, fileName, outputDirectory); + } - try - { - // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one. - var coverImageContent = epubBook.Content.Cover - ?? epubBook.Content.Images.Values.FirstOrDefault(file => Tasks.Scanner.Parser.Parser.IsCoverImage(file.FileName)) - ?? epubBook.Content.Images.Values.FirstOrDefault(); + using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); - if (coverImageContent == null) return string.Empty; - using var stream = coverImageContent.GetContentStream(); + try + { + // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one. + var coverImageContent = epubBook.Content.Cover + ?? epubBook.Content.Images.Values.FirstOrDefault(file => Tasks.Scanner.Parser.Parser.IsCoverImage(file.FileName)) + ?? epubBook.Content.Images.Values.FirstOrDefault(); - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); - } + if (coverImageContent == null) return string.Empty; + using var stream = coverImageContent.GetContentStream(); - return string.Empty; + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); } + return string.Empty; + } - private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory) - { - try - { - using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920)); - if (docReader.GetPageCount() == 0) return string.Empty; - using var stream = StreamManager.GetStream("BookService.GetPdfPage"); - GetPdfPage(docReader, 0, stream); + private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory) + { + try + { + using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920)); + if (docReader.GetPageCount() == 0) return string.Empty; - return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); + using var stream = StreamManager.GetStream("BookService.GetPdfPage"); + GetPdfPage(docReader, 0, stream); - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", - fileFilePath); - } + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); - return string.Empty; } - - /// - /// Returns an image raster of a page within a PDF - /// - /// - /// - /// - private static void GetPdfPage(IDocReader docReader, int pageNumber, Stream stream) + catch (Exception ex) { - using var pageReader = docReader.GetPageReader(pageNumber); - var rawBytes = pageReader.GetImage(new NaiveTransparencyRemover()); - var width = pageReader.GetPageWidth(); - var height = pageReader.GetPageHeight(); - var image = Image.LoadPixelData(rawBytes, width, height); - - stream.Seek(0, SeekOrigin.Begin); - image.SaveAsPng(stream); - stream.Seek(0, SeekOrigin.Begin); + _logger.LogWarning(ex, + "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", + fileFilePath); } - private static string RemoveWhiteSpaceFromStylesheets(string body) + return string.Empty; + } + + /// + /// Returns an image raster of a page within a PDF + /// + /// + /// + /// + private static void GetPdfPage(IDocReader docReader, int pageNumber, Stream stream) + { + using var pageReader = docReader.GetPageReader(pageNumber); + var rawBytes = pageReader.GetImage(new NaiveTransparencyRemover()); + var width = pageReader.GetPageWidth(); + var height = pageReader.GetPageHeight(); + var image = Image.LoadPixelData(rawBytes, width, height); + + stream.Seek(0, SeekOrigin.Begin); + image.SaveAsPng(stream); + stream.Seek(0, SeekOrigin.Begin); + } + + private static string RemoveWhiteSpaceFromStylesheets(string body) + { + if (string.IsNullOrEmpty(body)) { - if (string.IsNullOrEmpty(body)) - { - return string.Empty; - } + return string.Empty; + } - // Remove comments from CSS - body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty); + // Remove comments from CSS + body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty); - body = Regex.Replace(body, @"[a-zA-Z]+#", "#"); - body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty); - body = Regex.Replace(body, @"\s+", " "); - body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1"); - try - { - body = body.Replace(";}", "}"); - } - catch (Exception) - { - /* Swallow exception. Some css doesn't have style rules ending in ; */ - } + body = Regex.Replace(body, @"[a-zA-Z]+#", "#"); + body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty); + body = Regex.Replace(body, @"\s+", " "); + body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1"); + try + { + body = body.Replace(";}", "}"); + } + catch (Exception) + { + /* Swallow exception. Some css doesn't have style rules ending in ; */ + } - body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1"); + body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1"); - return body; - } + return body; + } - private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc) + private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc) + { + _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); + foreach (var error in doc.ParseErrors) { - _logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName); - foreach (var error in doc.ParseErrors) - { - _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); - } + _logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason); } } } diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index b81b87d91a..a150bde22f 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -10,245 +10,244 @@ using Kavita.Common; using Microsoft.Extensions.Logging; -namespace API.Services +namespace API.Services; + +public interface ICacheService +{ + /// + /// Ensures the cache is created for the given chapter and if not, will create it. Should be called before any other + /// cache operations (except cleanup). + /// + /// + /// Chapter for the passed chapterId. Side-effect from ensuring cache. + Task Ensure(int chapterId); + /// + /// Clears cache directory of all volumes. This can be invoked from deleting a library or a series. + /// + /// Volumes that belong to that library. Assume the library might have been deleted before this invocation. + void CleanupChapters(IEnumerable chapterIds); + void CleanupBookmarks(IEnumerable seriesIds); + string GetCachedPagePath(Chapter chapter, int page); + string GetCachedBookmarkPagePath(int seriesId, int page); + string GetCachedFile(Chapter chapter); + public void ExtractChapterFiles(string extractPath, IReadOnlyList files); + Task CacheBookmarkForSeries(int userId, int seriesId); + void CleanupBookmarkCache(int seriesId); +} +public class CacheService : ICacheService { - public interface ICacheService + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + private readonly IReadingItemService _readingItemService; + private readonly IBookmarkService _bookmarkService; + + public CacheService(ILogger logger, IUnitOfWork unitOfWork, + IDirectoryService directoryService, IReadingItemService readingItemService, + IBookmarkService bookmarkService) { - /// - /// Ensures the cache is created for the given chapter and if not, will create it. Should be called before any other - /// cache operations (except cleanup). - /// - /// - /// Chapter for the passed chapterId. Side-effect from ensuring cache. - Task Ensure(int chapterId); - /// - /// Clears cache directory of all volumes. This can be invoked from deleting a library or a series. - /// - /// Volumes that belong to that library. Assume the library might have been deleted before this invocation. - void CleanupChapters(IEnumerable chapterIds); - void CleanupBookmarks(IEnumerable seriesIds); - string GetCachedPagePath(Chapter chapter, int page); - string GetCachedBookmarkPagePath(int seriesId, int page); - string GetCachedFile(Chapter chapter); - public void ExtractChapterFiles(string extractPath, IReadOnlyList files); - Task CacheBookmarkForSeries(int userId, int seriesId); - void CleanupBookmarkCache(int seriesId); + _logger = logger; + _unitOfWork = unitOfWork; + _directoryService = directoryService; + _readingItemService = readingItemService; + _bookmarkService = bookmarkService; } - public class CacheService : ICacheService + + public string GetCachedBookmarkPagePath(int seriesId, int page) { - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - private readonly IDirectoryService _directoryService; - private readonly IReadingItemService _readingItemService; - private readonly IBookmarkService _bookmarkService; - - public CacheService(ILogger logger, IUnitOfWork unitOfWork, - IDirectoryService directoryService, IReadingItemService readingItemService, - IBookmarkService bookmarkService) + // Calculate what chapter the page belongs to + var path = GetBookmarkCachePath(seriesId); + var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions); + files = files + .AsEnumerable() + .OrderByNatural(Path.GetFileNameWithoutExtension) + .ToArray(); + + if (files.Length == 0) { - _logger = logger; - _unitOfWork = unitOfWork; - _directoryService = directoryService; - _readingItemService = readingItemService; - _bookmarkService = bookmarkService; + return string.Empty; } - public string GetCachedBookmarkPagePath(int seriesId, int page) - { - // Calculate what chapter the page belongs to - var path = GetBookmarkCachePath(seriesId); - var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions); - files = files - .AsEnumerable() - .OrderByNatural(Path.GetFileNameWithoutExtension) - .ToArray(); - - if (files.Length == 0) - { - return string.Empty; - } - - // Since array is 0 based, we need to keep that in account (only affects last image) - return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); - } + // Since array is 0 based, we need to keep that in account (only affects last image) + return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); + } - /// - /// Returns the full path to the cached file. If the file does not exist, will fallback to the original. - /// - /// - /// - public string GetCachedFile(Chapter chapter) + /// + /// Returns the full path to the cached file. If the file does not exist, will fallback to the original. + /// + /// + /// + public string GetCachedFile(Chapter chapter) + { + var extractPath = GetCachePath(chapter.Id); + var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath)); + if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists)) { - var extractPath = GetCachePath(chapter.Id); - var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath)); - if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists)) - { - path = chapter.Files.First().FilePath; - } - return path; + path = chapter.Files.First().FilePath; } + return path; + } - /// - /// Caches the files for the given chapter to CacheDirectory - /// - /// - /// This will always return the Chapter for the chapterId - public async Task Ensure(int chapterId) - { - _directoryService.ExistOrCreate(_directoryService.CacheDirectory); - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - var extractPath = GetCachePath(chapterId); + /// + /// Caches the files for the given chapter to CacheDirectory + /// + /// + /// This will always return the Chapter for the chapterId + public async Task Ensure(int chapterId) + { + _directoryService.ExistOrCreate(_directoryService.CacheDirectory); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + var extractPath = GetCachePath(chapterId); - if (_directoryService.Exists(extractPath)) return chapter; - var files = chapter.Files.ToList(); - ExtractChapterFiles(extractPath, files); + if (_directoryService.Exists(extractPath)) return chapter; + var files = chapter.Files.ToList(); + ExtractChapterFiles(extractPath, files); - return chapter; - } + return chapter; + } + + /// + /// This is an internal method for cache service for extracting chapter files to disk. The code is structured + /// for cache service, but can be re-used (download bookmarks) + /// + /// + /// + /// + public void ExtractChapterFiles(string extractPath, IReadOnlyList files) + { + var removeNonImages = true; + var fileCount = files.Count; + var extraPath = ""; + var extractDi = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(extractPath); - /// - /// This is an internal method for cache service for extracting chapter files to disk. The code is structured - /// for cache service, but can be re-used (download bookmarks) - /// - /// - /// - /// - public void ExtractChapterFiles(string extractPath, IReadOnlyList files) + if (files.Count > 0 && files[0].Format == MangaFormat.Image) { - var removeNonImages = true; - var fileCount = files.Count; - var extraPath = ""; - var extractDi = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(extractPath); + _readingItemService.Extract(files[0].FilePath, extractPath, MangaFormat.Image, files.Count); + _directoryService.Flatten(extractDi.FullName); + } - if (files.Count > 0 && files[0].Format == MangaFormat.Image) + foreach (var file in files) + { + if (fileCount > 1) { - _readingItemService.Extract(files[0].FilePath, extractPath, MangaFormat.Image, files.Count); - _directoryService.Flatten(extractDi.FullName); + extraPath = file.Id + string.Empty; } - foreach (var file in files) + switch (file.Format) { - if (fileCount > 1) + case MangaFormat.Archive: + _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format); + break; + case MangaFormat.Epub: + case MangaFormat.Pdf: { - extraPath = file.Id + string.Empty; - } - - switch (file.Format) - { - case MangaFormat.Archive: - _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format); - break; - case MangaFormat.Epub: - case MangaFormat.Pdf: + removeNonImages = false; + if (!_directoryService.FileSystem.File.Exists(files[0].FilePath)) { - removeNonImages = false; - if (!_directoryService.FileSystem.File.Exists(files[0].FilePath)) - { - _logger.LogError("{File} does not exist on disk", files[0].FilePath); - throw new KavitaException($"{files[0].FilePath} does not exist on disk"); - } - - _directoryService.ExistOrCreate(extractPath); - _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath); - break; + _logger.LogError("{File} does not exist on disk", files[0].FilePath); + throw new KavitaException($"{files[0].FilePath} does not exist on disk"); } - } - } - _directoryService.Flatten(extractDi.FullName); - if (removeNonImages) - { - _directoryService.RemoveNonImages(extractDi.FullName); + _directoryService.ExistOrCreate(extractPath); + _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath); + break; + } } } - /// - /// Removes the cached files and folders for a set of chapterIds - /// - /// - public void CleanupChapters(IEnumerable chapterIds) + _directoryService.Flatten(extractDi.FullName); + if (removeNonImages) { - foreach (var chapter in chapterIds) - { - _directoryService.ClearAndDeleteDirectory(GetCachePath(chapter)); - } + _directoryService.RemoveNonImages(extractDi.FullName); } + } - /// - /// Removes the cached files and folders for a set of chapterIds - /// - /// - public void CleanupBookmarks(IEnumerable seriesIds) + /// + /// Removes the cached files and folders for a set of chapterIds + /// + /// + public void CleanupChapters(IEnumerable chapterIds) + { + foreach (var chapter in chapterIds) { - foreach (var series in seriesIds) - { - _directoryService.ClearAndDeleteDirectory(GetBookmarkCachePath(series)); - } + _directoryService.ClearAndDeleteDirectory(GetCachePath(chapter)); } + } - - /// - /// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/ - /// - /// - /// - private string GetCachePath(int chapterId) + /// + /// Removes the cached files and folders for a set of chapterIds + /// + /// + public void CleanupBookmarks(IEnumerable seriesIds) + { + foreach (var series in seriesIds) { - return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{chapterId}/")); + _directoryService.ClearAndDeleteDirectory(GetBookmarkCachePath(series)); } + } - private string GetBookmarkCachePath(int seriesId) - { - return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{seriesId}_bookmarks/")); - } - /// - /// Returns the absolute path of a cached page. - /// - /// Chapter entity with Files populated. - /// Page number to look for - /// Page filepath or empty if no files found. - public string GetCachedPagePath(Chapter chapter, int page) - { - // Calculate what chapter the page belongs to - var path = GetCachePath(chapter.Id); - // TODO: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access - var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) - .OrderByNatural(Path.GetFileNameWithoutExtension) - .ToArray(); - - if (files.Length == 0) - { - return string.Empty; - } + /// + /// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/ + /// + /// + /// + private string GetCachePath(int chapterId) + { + return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{chapterId}/")); + } - // Since array is 0 based, we need to keep that in account (only affects last image) - return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); - } + private string GetBookmarkCachePath(int seriesId) + { + return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{seriesId}_bookmarks/")); + } - public async Task CacheBookmarkForSeries(int userId, int seriesId) + /// + /// Returns the absolute path of a cached page. + /// + /// Chapter entity with Files populated. + /// Page number to look for + /// Page filepath or empty if no files found. + public string GetCachedPagePath(Chapter chapter, int page) + { + // Calculate what chapter the page belongs to + var path = GetCachePath(chapter.Id); + // TODO: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access + var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) + .OrderByNatural(Path.GetFileNameWithoutExtension) + .ToArray(); + + if (files.Length == 0) { - var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks"); - if (_directoryService.Exists(destDirectory)) return _directoryService.GetFiles(destDirectory).Count(); - - var bookmarkDtos = await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(userId, seriesId); - var files = (await _bookmarkService.GetBookmarkFilesById(bookmarkDtos.Select(b => b.Id))).ToList(); - _directoryService.CopyFilesToDirectory(files, destDirectory); - _directoryService.Flatten(destDirectory); - return files.Count; + return string.Empty; } - /// - /// Clears a cached bookmarks for a series id folder - /// - /// - public void CleanupBookmarkCache(int seriesId) - { - var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks"); - if (!_directoryService.Exists(destDirectory)) return; + // Since array is 0 based, we need to keep that in account (only affects last image) + return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page); + } - _directoryService.ClearAndDeleteDirectory(destDirectory); - } + public async Task CacheBookmarkForSeries(int userId, int seriesId) + { + var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks"); + if (_directoryService.Exists(destDirectory)) return _directoryService.GetFiles(destDirectory).Count(); + + var bookmarkDtos = await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(userId, seriesId); + var files = (await _bookmarkService.GetBookmarkFilesById(bookmarkDtos.Select(b => b.Id))).ToList(); + _directoryService.CopyFilesToDirectory(files, destDirectory); + _directoryService.Flatten(destDirectory); + return files.Count; + } + + /// + /// Clears a cached bookmarks for a series id folder + /// + /// + public void CleanupBookmarkCache(int seriesId) + { + var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks"); + if (!_directoryService.Exists(destDirectory)) return; + + _directoryService.ClearAndDeleteDirectory(destDirectory); } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 54757f651f..af4ed6c3c2 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -14,928 +14,927 @@ using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; -namespace API.Services +namespace API.Services; + +public interface IDirectoryService { - public interface IDirectoryService - { - IFileSystem FileSystem { get; } - string CacheDirectory { get; } - string CoverImageDirectory { get; } - string LogDirectory { get; } - string TempDirectory { get; } - string ConfigDirectory { get; } - string SiteThemeDirectory { get; } - /// - /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. - /// - string BookmarkDirectory { get; } - /// - /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. - /// - /// Absolute path of directory to scan. - /// List of folder names - IEnumerable ListDirectory(string rootPath); - Task ReadFileAsync(string path); - bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = ""); - bool Exists(string directory); - void CopyFileToDirectory(string fullFilePath, string targetDirectory); - int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger); - bool IsDriveMounted(string path); - bool IsDirectoryEmpty(string path); - long GetTotalSize(IEnumerable paths); - void ClearDirectory(string directoryPath); - void ClearAndDeleteDirectory(string directoryPath); - string[] GetFilesWithExtension(string path, string searchPatternExpression = ""); - bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = ""); - - Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, - IList filePaths); - - IEnumerable GetFoldersTillRoot(string rootPath, string fullPath); - - IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); - - bool ExistOrCreate(string directoryPath); - void DeleteFiles(IEnumerable files); - void RemoveNonImages(string directoryName); - void Flatten(string directoryName); - Task CheckWriteAccess(string directoryName); - - IEnumerable GetFilesWithCertainExtensions(string path, - string searchPatternExpression = "", - SearchOption searchOption = SearchOption.TopDirectoryOnly); - - IEnumerable GetDirectories(string folderPath); - IEnumerable GetDirectories(string folderPath, GlobMatcher matcher); - string GetParentDirectoryName(string fileOrFolder); - #nullable enable - IList ScanFiles(string folderPath, GlobMatcher? matcher = null); - DateTime GetLastWriteTime(string folderPath); - GlobMatcher CreateMatcherFromFile(string filePath); + IFileSystem FileSystem { get; } + string CacheDirectory { get; } + string CoverImageDirectory { get; } + string LogDirectory { get; } + string TempDirectory { get; } + string ConfigDirectory { get; } + string SiteThemeDirectory { get; } + /// + /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. + /// + string BookmarkDirectory { get; } + /// + /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. + /// + /// Absolute path of directory to scan. + /// List of folder names + IEnumerable ListDirectory(string rootPath); + Task ReadFileAsync(string path); + bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = ""); + bool Exists(string directory); + void CopyFileToDirectory(string fullFilePath, string targetDirectory); + int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger); + bool IsDriveMounted(string path); + bool IsDirectoryEmpty(string path); + long GetTotalSize(IEnumerable paths); + void ClearDirectory(string directoryPath); + void ClearAndDeleteDirectory(string directoryPath); + string[] GetFilesWithExtension(string path, string searchPatternExpression = ""); + bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = ""); + + Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, + IList filePaths); + + IEnumerable GetFoldersTillRoot(string rootPath, string fullPath); + + IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); + + bool ExistOrCreate(string directoryPath); + void DeleteFiles(IEnumerable files); + void RemoveNonImages(string directoryName); + void Flatten(string directoryName); + Task CheckWriteAccess(string directoryName); + + IEnumerable GetFilesWithCertainExtensions(string path, + string searchPatternExpression = "", + SearchOption searchOption = SearchOption.TopDirectoryOnly); + + IEnumerable GetDirectories(string folderPath); + IEnumerable GetDirectories(string folderPath, GlobMatcher matcher); + string GetParentDirectoryName(string fileOrFolder); +#nullable enable + IList ScanFiles(string folderPath, GlobMatcher? matcher = null); + DateTime GetLastWriteTime(string folderPath); + GlobMatcher CreateMatcherFromFile(string filePath); #nullable disable +} +public class DirectoryService : IDirectoryService +{ + public const string KavitaIgnoreFile = ".kavitaignore"; + public IFileSystem FileSystem { get; } + public string CacheDirectory { get; } + public string CoverImageDirectory { get; } + public string LogDirectory { get; } + public string TempDirectory { get; } + public string ConfigDirectory { get; } + public string BookmarkDirectory { get; } + public string SiteThemeDirectory { get; } + private readonly ILogger _logger; + + private static readonly Regex ExcludeDirectories = new Regex( + @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); + + public DirectoryService(ILogger logger, IFileSystem fileSystem) + { + _logger = logger; + FileSystem = fileSystem; + CoverImageDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "covers"); + CacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache"); + LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs"); + TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); + ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); + BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); + SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); + + ExistOrCreate(SiteThemeDirectory); + ExistOrCreate(CoverImageDirectory); + ExistOrCreate(CacheDirectory); + ExistOrCreate(LogDirectory); + ExistOrCreate(TempDirectory); + ExistOrCreate(BookmarkDirectory); } - public class DirectoryService : IDirectoryService - { - public const string KavitaIgnoreFile = ".kavitaignore"; - public IFileSystem FileSystem { get; } - public string CacheDirectory { get; } - public string CoverImageDirectory { get; } - public string LogDirectory { get; } - public string TempDirectory { get; } - public string ConfigDirectory { get; } - public string BookmarkDirectory { get; } - public string SiteThemeDirectory { get; } - private readonly ILogger _logger; - - private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); - - public DirectoryService(ILogger logger, IFileSystem fileSystem) - { - _logger = logger; - FileSystem = fileSystem; - CoverImageDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "covers"); - CacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache"); - LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs"); - TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); - ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); - BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); - SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); - - ExistOrCreate(SiteThemeDirectory); - ExistOrCreate(CoverImageDirectory); - ExistOrCreate(CacheDirectory); - ExistOrCreate(LogDirectory); - ExistOrCreate(TempDirectory); - ExistOrCreate(BookmarkDirectory); - } - - /// - /// Given a set of regex search criteria, get files in the given path. - /// - /// This will always exclude patterns - /// Directory to search - /// Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files. - /// SearchOption to use, defaults to TopDirectoryOnly - /// List of file paths - public IEnumerable GetFilesWithCertainExtensions(string path, - string searchPatternExpression = "", - SearchOption searchOption = SearchOption.TopDirectoryOnly) - { - if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; - var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); - - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) - .Where(file => + + /// + /// Given a set of regex search criteria, get files in the given path. + /// + /// This will always exclude patterns + /// Directory to search + /// Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files. + /// SearchOption to use, defaults to TopDirectoryOnly + /// List of file paths + public IEnumerable GetFilesWithCertainExtensions(string path, + string searchPatternExpression = "", + SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; + var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); + + return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) + .Where(file => reSearchPattern.IsMatch(FileSystem.Path.GetExtension(file)) && !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); - } - - - /// - /// Returns a list of folders from end of fullPath to rootPath. If a file is passed at the end of the fullPath, it will be ignored. - /// - /// Example) (C:/Manga/, C:/Manga/Love Hina/Specials/Omake/) returns [Omake, Specials, Love Hina] - /// - /// - /// - /// - public IEnumerable GetFoldersTillRoot(string rootPath, string fullPath) - { - var separator = FileSystem.Path.AltDirectorySeparatorChar; - if (fullPath.Contains(FileSystem.Path.DirectorySeparatorChar)) - { - fullPath = fullPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); - } - - if (rootPath.Contains(Path.DirectorySeparatorChar)) - { - rootPath = rootPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); - } - - - - var path = fullPath.EndsWith(separator) ? fullPath.Substring(0, fullPath.Length - 1) : fullPath; - var root = rootPath.EndsWith(separator) ? rootPath.Substring(0, rootPath.Length - 1) : rootPath; - var paths = new List(); - // If a file is at the end of the path, remove it before we start processing folders - if (FileSystem.Path.GetExtension(path) != string.Empty) - { - path = path.Substring(0, path.LastIndexOf(separator)); - } - - while (FileSystem.Path.GetDirectoryName(path) != Path.GetDirectoryName(root)) - { - var folder = FileSystem.DirectoryInfo.FromDirectoryName(path).Name; - paths.Add(folder); - path = path.Substring(0, path.LastIndexOf(separator)); - } - - return paths; - } - - /// - /// Does Directory Exist - /// - /// - /// - public bool Exists(string directory) - { - var di = FileSystem.DirectoryInfo.FromDirectoryName(directory); - return di.Exists; - } - - /// - /// Get files given a path. - /// - /// This will automatically filter out restricted files, like MacOsMetadata files - /// - /// An optional regex string to search against. Will use file path to match against. - /// Defaults to top level directory only, can be given all to provide recursive searching - /// - public IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) - { - if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; - - if (fileNameRegex != string.Empty) - { - var reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase); - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) + } + + + /// + /// Returns a list of folders from end of fullPath to rootPath. If a file is passed at the end of the fullPath, it will be ignored. + /// + /// Example) (C:/Manga/, C:/Manga/Love Hina/Specials/Omake/) returns [Omake, Specials, Love Hina] + /// + /// + /// + /// + public IEnumerable GetFoldersTillRoot(string rootPath, string fullPath) + { + var separator = FileSystem.Path.AltDirectorySeparatorChar; + if (fullPath.Contains(FileSystem.Path.DirectorySeparatorChar)) + { + fullPath = fullPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); + } + + if (rootPath.Contains(Path.DirectorySeparatorChar)) + { + rootPath = rootPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); + } + + + + var path = fullPath.EndsWith(separator) ? fullPath.Substring(0, fullPath.Length - 1) : fullPath; + var root = rootPath.EndsWith(separator) ? rootPath.Substring(0, rootPath.Length - 1) : rootPath; + var paths = new List(); + // If a file is at the end of the path, remove it before we start processing folders + if (FileSystem.Path.GetExtension(path) != string.Empty) + { + path = path.Substring(0, path.LastIndexOf(separator)); + } + + while (FileSystem.Path.GetDirectoryName(path) != Path.GetDirectoryName(root)) + { + var folder = FileSystem.DirectoryInfo.FromDirectoryName(path).Name; + paths.Add(folder); + path = path.Substring(0, path.LastIndexOf(separator)); + } + + return paths; + } + + /// + /// Does Directory Exist + /// + /// + /// + public bool Exists(string directory) + { + var di = FileSystem.DirectoryInfo.FromDirectoryName(directory); + return di.Exists; + } + + /// + /// Get files given a path. + /// + /// This will automatically filter out restricted files, like MacOsMetadata files + /// + /// An optional regex string to search against. Will use file path to match against. + /// Defaults to top level directory only, can be given all to provide recursive searching + /// + public IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; + + if (fileNameRegex != string.Empty) + { + var reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase); + return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) .Where(file => { var fileName = FileSystem.Path.GetFileName(file); return reSearchPattern.IsMatch(fileName) && !fileName.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith); }); - } - - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption).Where(file => - !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); - } - - /// - /// Copies a file into a directory. Does not maintain parent folder of file. - /// Will create target directory if doesn't exist. Automatically overwrites what is there. - /// - /// - /// - public void CopyFileToDirectory(string fullFilePath, string targetDirectory) - { - try - { - var fileInfo = FileSystem.FileInfo.FromFileName(fullFilePath); - if (!fileInfo.Exists) return; - - ExistOrCreate(targetDirectory); - fileInfo.CopyTo(FileSystem.Path.Join(targetDirectory, fileInfo.Name), true); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was a critical error when copying {File} to {Directory}", fullFilePath, targetDirectory); - } - } - - /// - /// Copies all files and subdirectories within a directory to a target location - /// - /// Directory to copy from. Does not copy the parent folder - /// Destination to copy to. Will be created if doesn't exist - /// Defaults to all files - /// If was successful - /// Thrown when source directory does not exist - public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "") - { - if (string.IsNullOrEmpty(sourceDirName)) return false; - - // Get the subdirectories for the specified directory. - var dir = FileSystem.DirectoryInfo.FromDirectoryName(sourceDirName); - - if (!dir.Exists) - { - throw new DirectoryNotFoundException( - "Source directory does not exist or could not be found: " - + sourceDirName); - } - - var dirs = dir.GetDirectories(); - - // If the destination directory doesn't exist, create it. - ExistOrCreate(destDirName); - - // Get the files in the directory and copy them to the new location. - var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => FileSystem.FileInfo.FromFileName(n)); - foreach (var file in files) - { - var tempPath = FileSystem.Path.Combine(destDirName, file.Name); - file.CopyTo(tempPath, false); - } - - // If copying subdirectories, copy them and their contents to new location. - foreach (var subDir in dirs) - { - var tempPath = FileSystem.Path.Combine(destDirName, subDir.Name); - CopyDirectoryToDirectory(subDir.FullName, tempPath); - } - - return true; - } - - /// - /// Checks if the root path of a path exists or not. - /// - /// - /// - public bool IsDriveMounted(string path) - { - return FileSystem.DirectoryInfo.FromDirectoryName(FileSystem.Path.GetPathRoot(path) ?? string.Empty).Exists; - } - - - /// - /// Checks if the root path of a path is empty or not. - /// - /// - /// - public bool IsDirectoryEmpty(string path) - { - return FileSystem.Directory.Exists(path) && !FileSystem.Directory.EnumerateFileSystemEntries(path).Any(); - } - - public string[] GetFilesWithExtension(string path, string searchPatternExpression = "") - { - if (searchPatternExpression != string.Empty) - { - return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray(); - } - - return !FileSystem.Directory.Exists(path) ? Array.Empty() : FileSystem.Directory.GetFiles(path); - } - - /// - /// Returns the total number of bytes for a given set of full file paths - /// - /// - /// Total bytes - public long GetTotalSize(IEnumerable paths) - { - return paths.Sum(path => FileSystem.FileInfo.FromFileName(path).Length); - } - - /// - /// Returns true if the path exists and is a directory. If path does not exist, this will create it. Returns false in all fail cases. - /// - /// - /// - public bool ExistOrCreate(string directoryPath) - { - var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); - if (di.Exists) return true; - try - { - FileSystem.Directory.CreateDirectory(directoryPath); - } - catch (Exception) - { - return false; - } - return true; - } - - /// - /// Deletes all files within the directory, then the directory itself. - /// - /// - public void ClearAndDeleteDirectory(string directoryPath) - { - if (!FileSystem.Directory.Exists(directoryPath)) return; - - var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); - - ClearDirectory(directoryPath); - - di.Delete(true); - } - - /// - /// Deletes all files and folders within the directory path - /// - /// - /// - public void ClearDirectory(string directoryPath) - { - var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); - if (!di.Exists) return; - - foreach (var file in di.EnumerateFiles()) - { - file.Delete(); - } - foreach (var dir in di.EnumerateDirectories()) - { - dir.Delete(true); - } - } - - /// - /// Copies files to a destination directory. If the destination directory doesn't exist, this will create it. - /// - /// If a file already exists in dest, this will rename as (2). It does not support multiple iterations of this. Overwriting is not supported. - /// - /// - /// An optional string to prepend to the target file's name - /// - public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "") - { - ExistOrCreate(directoryPath); - string currentFile = null; - try - { - foreach (var file in filePaths) - { - currentFile = file; - - if (!FileSystem.File.Exists(file)) - { - _logger.LogError("Unable to copy {File} to {DirectoryPath} as it doesn't exist", file, directoryPath); - continue; - } - var fileInfo = FileSystem.FileInfo.FromFileName(file); - var targetFile = FileSystem.FileInfo.FromFileName(RenameFileForCopy(file, directoryPath, prepend)); - - fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, targetFile.Name)); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); - return false; - } - - return true; - } - - /// - /// Generates the combined filepath given a prepend (optional), output directory path, and a full input file path. - /// If the output file already exists, will append (1), (2), etc until it can be written out - /// - /// - /// - /// - /// - private string RenameFileForCopy(string fileToCopy, string directoryPath, string prepend = "") - { - var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy); - var filename = prepend + fileInfo.Name; - - var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename)); - if (!targetFile.Exists) - { - return targetFile.FullName; - } - - var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name); - if (FileCopyAppend.IsMatch(noExtension)) - { - var match = FileCopyAppend.Match(noExtension).Value; - var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty); - noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})"); - } - else - { - noExtension += " (1)"; - } - - var newFilename = prepend + noExtension + - FileSystem.Path.GetExtension(fileInfo.Name); - return RenameFileForCopy(FileSystem.Path.Join(directoryPath, newFilename), directoryPath, prepend); - } - - /// - /// Lists all directories in a root path. Will exclude Hidden or System directories. - /// - /// - /// - public IEnumerable ListDirectory(string rootPath) - { - if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList.Empty; - - var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath); - var dirs = di.GetDirectories() - .Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System))) - .Select(d => new DirectoryDto() - { - Name = d.Name, - FullPath = d.FullName, - }).ToImmutableList(); - - return dirs; - } - - /// - /// Reads a file's into byte[]. Returns empty array if file doesn't exist. - /// - /// - /// - public async Task ReadFileAsync(string path) - { - if (!FileSystem.File.Exists(path)) return Array.Empty(); - return await FileSystem.File.ReadAllBytesAsync(path); - } - - - /// - /// Finds the highest directories from a set of file paths. Does not return the root path, will always select the highest non-root path. - /// - /// If the file paths do not contain anything from libraryFolders, this returns an empty dictionary back - /// List of top level folders which files belong to - /// List of file paths that belong to libraryFolders - /// - public Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths) - { - var stopLookingForDirectories = false; - var dirs = new Dictionary(); - foreach (var folder in libraryFolders.Select(Tasks.Scanner.Parser.Parser.NormalizePath)) - { - if (stopLookingForDirectories) break; - foreach (var file in filePaths.Select(Tasks.Scanner.Parser.Parser.NormalizePath)) - { - if (!file.Contains(folder)) continue; - - var parts = GetFoldersTillRoot(folder, file).ToList(); - if (parts.Count == 0) - { - // Break from all loops, we done, just scan folder.Path (library root) - dirs.Add(folder, string.Empty); - stopLookingForDirectories = true; - break; - } - - var fullPath = Tasks.Scanner.Parser.Parser.NormalizePath(Path.Join(folder, parts.Last())); - if (!dirs.ContainsKey(fullPath)) - { - dirs.Add(fullPath, string.Empty); - } - } - } - - return dirs; - } - - /// - /// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope. - /// - /// - /// List of directory paths, empty if path doesn't exist - public IEnumerable GetDirectories(string folderPath) - { - if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; - return FileSystem.Directory.GetDirectories(folderPath) - .Where(path => ExcludeDirectories.Matches(path).Count == 0); - } - - /// - /// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope. - /// - /// - /// A set of glob rules that will filter directories out - /// List of directory paths, empty if path doesn't exist - public IEnumerable GetDirectories(string folderPath, GlobMatcher matcher) - { - if (matcher == null) return GetDirectories(folderPath); - - return GetDirectories(folderPath) - .Where(folder => !matcher.ExcludeMatches( - $"{FileSystem.DirectoryInfo.FromDirectoryName(folder).Name}{FileSystem.Path.AltDirectorySeparatorChar}")); - } - - /// - /// Returns all directories, including subdirectories. Automatically excludes directories that shouldn't be in scope. - /// - /// - /// - public IEnumerable GetAllDirectories(string folderPath) - { - if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; - var directories = new List(); - - var foundDirs = GetDirectories(folderPath); - foreach (var foundDir in foundDirs) - { - directories.Add(foundDir); - directories.AddRange(GetAllDirectories(foundDir)); - } - - return directories; - } - - /// - /// Returns the parent directories name for a file or folder. Empty string is path is not valid. - /// - /// - /// - public string GetParentDirectoryName(string fileOrFolder) - { - try - { - return Tasks.Scanner.Parser.Parser.NormalizePath(Directory.GetParent(fileOrFolder)?.FullName); - } - catch (Exception) - { - return string.Empty; - } - } - - /// - /// Scans a directory by utilizing a recursive folder search. If a .kavitaignore file is found, will ignore matching patterns - /// - /// - /// - /// - public IList ScanFiles(string folderPath, GlobMatcher? matcher = null) - { - _logger.LogDebug("[ScanFiles] called on {Path}", folderPath); - var files = new List(); - if (!Exists(folderPath)) return files; - - var potentialIgnoreFile = FileSystem.Path.Join(folderPath, KavitaIgnoreFile); - if (matcher == null) + } + + return FileSystem.Directory.EnumerateFiles(path, "*", searchOption).Where(file => + !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); + } + + /// + /// Copies a file into a directory. Does not maintain parent folder of file. + /// Will create target directory if doesn't exist. Automatically overwrites what is there. + /// + /// + /// + public void CopyFileToDirectory(string fullFilePath, string targetDirectory) + { + try + { + var fileInfo = FileSystem.FileInfo.FromFileName(fullFilePath); + if (!fileInfo.Exists) return; + + ExistOrCreate(targetDirectory); + fileInfo.CopyTo(FileSystem.Path.Join(targetDirectory, fileInfo.Name), true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was a critical error when copying {File} to {Directory}", fullFilePath, targetDirectory); + } + } + + /// + /// Copies all files and subdirectories within a directory to a target location + /// + /// Directory to copy from. Does not copy the parent folder + /// Destination to copy to. Will be created if doesn't exist + /// Defaults to all files + /// If was successful + /// Thrown when source directory does not exist + public bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "") + { + if (string.IsNullOrEmpty(sourceDirName)) return false; + + // Get the subdirectories for the specified directory. + var dir = FileSystem.DirectoryInfo.FromDirectoryName(sourceDirName); + + if (!dir.Exists) + { + throw new DirectoryNotFoundException( + "Source directory does not exist or could not be found: " + + sourceDirName); + } + + var dirs = dir.GetDirectories(); + + // If the destination directory doesn't exist, create it. + ExistOrCreate(destDirName); + + // Get the files in the directory and copy them to the new location. + var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => FileSystem.FileInfo.FromFileName(n)); + foreach (var file in files) + { + var tempPath = FileSystem.Path.Combine(destDirName, file.Name); + file.CopyTo(tempPath, false); + } + + // If copying subdirectories, copy them and their contents to new location. + foreach (var subDir in dirs) + { + var tempPath = FileSystem.Path.Combine(destDirName, subDir.Name); + CopyDirectoryToDirectory(subDir.FullName, tempPath); + } + + return true; + } + + /// + /// Checks if the root path of a path exists or not. + /// + /// + /// + public bool IsDriveMounted(string path) + { + return FileSystem.DirectoryInfo.FromDirectoryName(FileSystem.Path.GetPathRoot(path) ?? string.Empty).Exists; + } + + + /// + /// Checks if the root path of a path is empty or not. + /// + /// + /// + public bool IsDirectoryEmpty(string path) + { + return FileSystem.Directory.Exists(path) && !FileSystem.Directory.EnumerateFileSystemEntries(path).Any(); + } + + public string[] GetFilesWithExtension(string path, string searchPatternExpression = "") + { + if (searchPatternExpression != string.Empty) + { + return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray(); + } + + return !FileSystem.Directory.Exists(path) ? Array.Empty() : FileSystem.Directory.GetFiles(path); + } + + /// + /// Returns the total number of bytes for a given set of full file paths + /// + /// + /// Total bytes + public long GetTotalSize(IEnumerable paths) + { + return paths.Sum(path => FileSystem.FileInfo.FromFileName(path).Length); + } + + /// + /// Returns true if the path exists and is a directory. If path does not exist, this will create it. Returns false in all fail cases. + /// + /// + /// + public bool ExistOrCreate(string directoryPath) + { + var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); + if (di.Exists) return true; + try + { + FileSystem.Directory.CreateDirectory(directoryPath); + } + catch (Exception) + { + return false; + } + return true; + } + + /// + /// Deletes all files within the directory, then the directory itself. + /// + /// + public void ClearAndDeleteDirectory(string directoryPath) + { + if (!FileSystem.Directory.Exists(directoryPath)) return; + + var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); + + ClearDirectory(directoryPath); + + di.Delete(true); + } + + /// + /// Deletes all files and folders within the directory path + /// + /// + /// + public void ClearDirectory(string directoryPath) + { + var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); + if (!di.Exists) return; + + foreach (var file in di.EnumerateFiles()) + { + file.Delete(); + } + foreach (var dir in di.EnumerateDirectories()) + { + dir.Delete(true); + } + } + + /// + /// Copies files to a destination directory. If the destination directory doesn't exist, this will create it. + /// + /// If a file already exists in dest, this will rename as (2). It does not support multiple iterations of this. Overwriting is not supported. + /// + /// + /// An optional string to prepend to the target file's name + /// + public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "") + { + ExistOrCreate(directoryPath); + string currentFile = null; + try + { + foreach (var file in filePaths) { - matcher = CreateMatcherFromFile(potentialIgnoreFile); + currentFile = file; + + if (!FileSystem.File.Exists(file)) + { + _logger.LogError("Unable to copy {File} to {DirectoryPath} as it doesn't exist", file, directoryPath); + continue; + } + var fileInfo = FileSystem.FileInfo.FromFileName(file); + var targetFile = FileSystem.FileInfo.FromFileName(RenameFileForCopy(file, directoryPath, prepend)); + + fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, targetFile.Name)); } - else + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); + return false; + } + + return true; + } + + /// + /// Generates the combined filepath given a prepend (optional), output directory path, and a full input file path. + /// If the output file already exists, will append (1), (2), etc until it can be written out + /// + /// + /// + /// + /// + private string RenameFileForCopy(string fileToCopy, string directoryPath, string prepend = "") + { + var fileInfo = FileSystem.FileInfo.FromFileName(fileToCopy); + var filename = prepend + fileInfo.Name; + + var targetFile = FileSystem.FileInfo.FromFileName(FileSystem.Path.Join(directoryPath, filename)); + if (!targetFile.Exists) + { + return targetFile.FullName; + } + + var noExtension = FileSystem.Path.GetFileNameWithoutExtension(fileInfo.Name); + if (FileCopyAppend.IsMatch(noExtension)) + { + var match = FileCopyAppend.Match(noExtension).Value; + var matchNumber = match.Replace("(", string.Empty).Replace(")", string.Empty); + noExtension = noExtension.Replace(match, $"({int.Parse(matchNumber) + 1})"); + } + else + { + noExtension += " (1)"; + } + + var newFilename = prepend + noExtension + + FileSystem.Path.GetExtension(fileInfo.Name); + return RenameFileForCopy(FileSystem.Path.Join(directoryPath, newFilename), directoryPath, prepend); + } + + /// + /// Lists all directories in a root path. Will exclude Hidden or System directories. + /// + /// + /// + public IEnumerable ListDirectory(string rootPath) + { + if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList.Empty; + + var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath); + var dirs = di.GetDirectories() + .Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System))) + .Select(d => new DirectoryDto() { - matcher.Merge(CreateMatcherFromFile(potentialIgnoreFile)); - } + Name = d.Name, + FullPath = d.FullName, + }).ToImmutableList(); + return dirs; + } - var directories = GetDirectories(folderPath, matcher); + /// + /// Reads a file's into byte[]. Returns empty array if file doesn't exist. + /// + /// + /// + public async Task ReadFileAsync(string path) + { + if (!FileSystem.File.Exists(path)) return Array.Empty(); + return await FileSystem.File.ReadAllBytesAsync(path); + } - foreach (var directory in directories) + + /// + /// Finds the highest directories from a set of file paths. Does not return the root path, will always select the highest non-root path. + /// + /// If the file paths do not contain anything from libraryFolders, this returns an empty dictionary back + /// List of top level folders which files belong to + /// List of file paths that belong to libraryFolders + /// + public Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths) + { + var stopLookingForDirectories = false; + var dirs = new Dictionary(); + foreach (var folder in libraryFolders.Select(Tasks.Scanner.Parser.Parser.NormalizePath)) + { + if (stopLookingForDirectories) break; + foreach (var file in filePaths.Select(Tasks.Scanner.Parser.Parser.NormalizePath)) { - files.AddRange(ScanFiles(directory, matcher)); + if (!file.Contains(folder)) continue; + + var parts = GetFoldersTillRoot(folder, file).ToList(); + if (parts.Count == 0) + { + // Break from all loops, we done, just scan folder.Path (library root) + dirs.Add(folder, string.Empty); + stopLookingForDirectories = true; + break; + } + + var fullPath = Tasks.Scanner.Parser.Parser.NormalizePath(Path.Join(folder, parts.Last())); + if (!dirs.ContainsKey(fullPath)) + { + dirs.Add(fullPath, string.Empty); + } } + } + return dirs; + } - // Get the matcher from either ignore or global (default setup) - if (matcher == null) - { - files.AddRange(GetFilesWithCertainExtensions(folderPath, Tasks.Scanner.Parser.Parser.SupportedExtensions)); + /// + /// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope. + /// + /// + /// List of directory paths, empty if path doesn't exist + public IEnumerable GetDirectories(string folderPath) + { + if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; + return FileSystem.Directory.GetDirectories(folderPath) + .Where(path => ExcludeDirectories.Matches(path).Count == 0); + } + + /// + /// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope. + /// + /// + /// A set of glob rules that will filter directories out + /// List of directory paths, empty if path doesn't exist + public IEnumerable GetDirectories(string folderPath, GlobMatcher matcher) + { + if (matcher == null) return GetDirectories(folderPath); + + return GetDirectories(folderPath) + .Where(folder => !matcher.ExcludeMatches( + $"{FileSystem.DirectoryInfo.FromDirectoryName(folder).Name}{FileSystem.Path.AltDirectorySeparatorChar}")); + } + + /// + /// Returns all directories, including subdirectories. Automatically excludes directories that shouldn't be in scope. + /// + /// + /// + public IEnumerable GetAllDirectories(string folderPath) + { + if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; + var directories = new List(); + + var foundDirs = GetDirectories(folderPath); + foreach (var foundDir in foundDirs) + { + directories.Add(foundDir); + directories.AddRange(GetAllDirectories(foundDir)); + } + + return directories; + } + + /// + /// Returns the parent directories name for a file or folder. Empty string is path is not valid. + /// + /// + /// + public string GetParentDirectoryName(string fileOrFolder) + { + try + { + return Tasks.Scanner.Parser.Parser.NormalizePath(Directory.GetParent(fileOrFolder)?.FullName); + } + catch (Exception) + { + return string.Empty; + } + } + + /// + /// Scans a directory by utilizing a recursive folder search. If a .kavitaignore file is found, will ignore matching patterns + /// + /// + /// + /// + public IList ScanFiles(string folderPath, GlobMatcher? matcher = null) + { + _logger.LogDebug("[ScanFiles] called on {Path}", folderPath); + var files = new List(); + if (!Exists(folderPath)) return files; + + var potentialIgnoreFile = FileSystem.Path.Join(folderPath, KavitaIgnoreFile); + if (matcher == null) + { + matcher = CreateMatcherFromFile(potentialIgnoreFile); + } + else + { + matcher.Merge(CreateMatcherFromFile(potentialIgnoreFile)); + } + + + var directories = GetDirectories(folderPath, matcher); + + foreach (var directory in directories) + { + files.AddRange(ScanFiles(directory, matcher)); + } + + + // Get the matcher from either ignore or global (default setup) + if (matcher == null) + { + files.AddRange(GetFilesWithCertainExtensions(folderPath, Tasks.Scanner.Parser.Parser.SupportedExtensions)); + } + else + { + var foundFiles = GetFilesWithCertainExtensions(folderPath, + Tasks.Scanner.Parser.Parser.SupportedExtensions) + .Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.FromFileName(file).Name)); + files.AddRange(foundFiles); + } + + return files; + } + + /// + /// Recursively scans a folder and returns the max last write time on any folders and files + /// + /// + /// Max Last Write Time + public DateTime GetLastWriteTime(string folderPath) + { + if (!FileSystem.Directory.Exists(folderPath)) throw new IOException($"{folderPath} does not exist"); + return Directory.GetFileSystemEntries(folderPath, "*.*", SearchOption.AllDirectories).Max(path => FileSystem.File.GetLastWriteTime(path)); + } + + /// + /// Generates a GlobMatcher from a .kavitaignore file found at path. Returns null otherwise. + /// + /// + /// + public GlobMatcher CreateMatcherFromFile(string filePath) + { + if (!FileSystem.File.Exists(filePath)) + { + return null; + } + + // Read file in and add each line to Matcher + var lines = FileSystem.File.ReadAllLines(filePath); + if (lines.Length == 0) + { + return null; + } + + GlobMatcher matcher = new(); + foreach (var line in lines) + { + matcher.AddExclude(line); + } + + return matcher; + } + + + /// + /// Recursively scans files and applies an action on them. This uses as many cores the underlying PC has to speed + /// up processing. + /// NOTE: This is no longer parallel due to user's machines locking up + /// + /// Directory to scan + /// Action to apply on file path + /// Regex pattern to search against + /// + /// + public int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger) + { + //Count of files traversed and timer for diagnostic output + var fileCount = 0; + + + // Data structure to hold names of subfolders to be examined for files. + var dirs = new Stack(); + + if (!FileSystem.Directory.Exists(root)) { + throw new ArgumentException("The directory doesn't exist"); + } + + dirs.Push(root); + + while (dirs.Count > 0) { + var currentDir = dirs.Pop(); + IEnumerable subDirs; + string[] files; + + try { + subDirs = GetDirectories(currentDir); } - else - { - var foundFiles = GetFilesWithCertainExtensions(folderPath, - Tasks.Scanner.Parser.Parser.SupportedExtensions) - .Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.FromFileName(file).Name)); - files.AddRange(foundFiles); + // Thrown if we do not have discovery permission on the directory. + catch (UnauthorizedAccessException e) { + logger.LogCritical(e, "Unauthorized access on {Directory}", currentDir); + continue; + } + // Thrown if another process has deleted the directory after we retrieved its name. + catch (DirectoryNotFoundException e) { + logger.LogCritical(e, "Directory not found on {Directory}", currentDir); + continue; + } + + try { + files = GetFilesWithCertainExtensions(currentDir, searchPattern) + .ToArray(); + } + catch (UnauthorizedAccessException e) { + logger.LogCritical(e, "Unauthorized access on a file in {Directory}", currentDir); + continue; + } + catch (DirectoryNotFoundException e) { + logger.LogCritical(e, "Directory not found on a file in {Directory}", currentDir); + continue; + } + catch (IOException e) { + logger.LogCritical(e, "IO exception on a file in {Directory}", currentDir); + continue; } - return files; - } - - /// - /// Recursively scans a folder and returns the max last write time on any folders and files - /// - /// - /// Max Last Write Time - public DateTime GetLastWriteTime(string folderPath) - { - if (!FileSystem.Directory.Exists(folderPath)) throw new IOException($"{folderPath} does not exist"); - return Directory.GetFileSystemEntries(folderPath, "*.*", SearchOption.AllDirectories).Max(path => FileSystem.File.GetLastWriteTime(path)); - } - - /// - /// Generates a GlobMatcher from a .kavitaignore file found at path. Returns null otherwise. - /// - /// - /// - public GlobMatcher CreateMatcherFromFile(string filePath) - { - if (!FileSystem.File.Exists(filePath)) - { - return null; - } - - // Read file in and add each line to Matcher - var lines = FileSystem.File.ReadAllLines(filePath); - if (lines.Length == 0) - { - return null; - } - - GlobMatcher matcher = new(); - foreach (var line in lines) - { - matcher.AddExclude(line); - } - - return matcher; - } - - - /// - /// Recursively scans files and applies an action on them. This uses as many cores the underlying PC has to speed - /// up processing. - /// NOTE: This is no longer parallel due to user's machines locking up - /// - /// Directory to scan - /// Action to apply on file path - /// Regex pattern to search against - /// - /// - public int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger) - { - //Count of files traversed and timer for diagnostic output - var fileCount = 0; - - - // Data structure to hold names of subfolders to be examined for files. - var dirs = new Stack(); - - if (!FileSystem.Directory.Exists(root)) { - throw new ArgumentException("The directory doesn't exist"); + // Execute in parallel if there are enough files in the directory. + // Otherwise, execute sequentially. Files are opened and processed + // synchronously but this could be modified to perform async I/O. + try { + foreach (var file in files) { + action(file); + fileCount++; + } } + catch (AggregateException ae) { + ae.Handle((ex) => { + if (ex is not UnauthorizedAccessException) return false; + // Here we just output a message and go on. + _logger.LogError(ex, "Unauthorized access on file"); + return true; + // Handle other exceptions here if necessary... - dirs.Push(root); - - while (dirs.Count > 0) { - var currentDir = dirs.Pop(); - IEnumerable subDirs; - string[] files; - - try { - subDirs = GetDirectories(currentDir); - } - // Thrown if we do not have discovery permission on the directory. - catch (UnauthorizedAccessException e) { - logger.LogCritical(e, "Unauthorized access on {Directory}", currentDir); - continue; - } - // Thrown if another process has deleted the directory after we retrieved its name. - catch (DirectoryNotFoundException e) { - logger.LogCritical(e, "Directory not found on {Directory}", currentDir); - continue; - } - - try { - files = GetFilesWithCertainExtensions(currentDir, searchPattern) - .ToArray(); - } - catch (UnauthorizedAccessException e) { - logger.LogCritical(e, "Unauthorized access on a file in {Directory}", currentDir); - continue; - } - catch (DirectoryNotFoundException e) { - logger.LogCritical(e, "Directory not found on a file in {Directory}", currentDir); - continue; - } - catch (IOException e) { - logger.LogCritical(e, "IO exception on a file in {Directory}", currentDir); - continue; - } - - // Execute in parallel if there are enough files in the directory. - // Otherwise, execute sequentially. Files are opened and processed - // synchronously but this could be modified to perform async I/O. - try { - foreach (var file in files) { - action(file); - fileCount++; - } - } - catch (AggregateException ae) { - ae.Handle((ex) => { - if (ex is not UnauthorizedAccessException) return false; - // Here we just output a message and go on. - _logger.LogError(ex, "Unauthorized access on file"); - return true; - // Handle other exceptions here if necessary... - - }); - } - - // Push the subdirectories onto the stack for traversal. - // This could also be done before handing the files. - foreach (var str in subDirs) - dirs.Push(str); + }); } - return fileCount; - } - - /// - /// Attempts to delete the files passed to it. Swallows exceptions. - /// - /// Full path of files to delete - public void DeleteFiles(IEnumerable files) - { - foreach (var file in files) - { - try - { - FileSystem.FileInfo.FromFileName(file).Delete(); - } - catch (Exception) - { - /* Swallow exception */ - } - } - } - - /// - /// Returns the human-readable file size for an arbitrary, 64-bit file size - /// The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB" - /// - /// https://www.somacon.com/p576.php - /// - /// - public static string GetHumanReadableBytes(long bytes) - { - // Get absolute value - var absoluteBytes = (bytes < 0 ? -bytes : bytes); - // Determine the suffix and readable value - string suffix; - double readable; - switch (absoluteBytes) - { - // Exabyte - case >= 0x1000000000000000: - suffix = "EB"; - readable = (bytes >> 50); - break; - // Petabyte - case >= 0x4000000000000: - suffix = "PB"; - readable = (bytes >> 40); - break; - // Terabyte - case >= 0x10000000000: - suffix = "TB"; - readable = (bytes >> 30); - break; - // Gigabyte - case >= 0x40000000: - suffix = "GB"; - readable = (bytes >> 20); - break; - // Megabyte - case >= 0x100000: - suffix = "MB"; - readable = (bytes >> 10); - break; - // Kilobyte - case >= 0x400: - suffix = "KB"; - readable = bytes; - break; - default: - return bytes.ToString("0 B"); // Byte - } - // Divide by 1024 to get fractional value - readable = (readable / 1024); - // Return formatted number with suffix - return readable.ToString("0.## ") + suffix; - } - - /// - /// Removes all files except images from the directory. Includes sub directories. - /// - /// Fully qualified directory - public void RemoveNonImages(string directoryName) - { - DeleteFiles(GetFiles(directoryName, searchOption:SearchOption.AllDirectories).Where(file => !Tasks.Scanner.Parser.Parser.IsImage(file))); - } - - - /// - /// Flattens all files in subfolders to the passed directory recursively. - /// - /// - /// foo - /// ├── 1.txt - /// ├── 2.txt - /// ├── 3.txt - /// ├── 4.txt - /// └── bar - /// ├── 1.txt - /// ├── 2.txt - /// └── 5.txt - /// - /// becomes: - /// foo - /// ├── 1.txt - /// ├── 2.txt - /// ├── 3.txt - /// ├── 4.txt - /// ├── bar_1.txt - /// ├── bar_2.txt - /// └── bar_5.txt - /// - /// Fully qualified Directory name - public void Flatten(string directoryName) - { - if (string.IsNullOrEmpty(directoryName) || !FileSystem.Directory.Exists(directoryName)) return; - - var directory = FileSystem.DirectoryInfo.FromDirectoryName(directoryName); - - var index = 0; - FlattenDirectory(directory, directory, ref index); - } - - /// - /// Checks whether a directory has write permissions - /// - /// Fully qualified path - /// - public async Task CheckWriteAccess(string directoryName) + // Push the subdirectories onto the stack for traversal. + // This could also be done before handing the files. + foreach (var str in subDirs) + dirs.Push(str); + } + + return fileCount; + } + + /// + /// Attempts to delete the files passed to it. Swallows exceptions. + /// + /// Full path of files to delete + public void DeleteFiles(IEnumerable files) + { + foreach (var file in files) { try { - ExistOrCreate(directoryName); - await FileSystem.File.WriteAllTextAsync( - FileSystem.Path.Join(directoryName, "test.txt"), - string.Empty); + FileSystem.FileInfo.FromFileName(file).Delete(); } catch (Exception) { - ClearAndDeleteDirectory(directoryName); - return false; + /* Swallow exception */ } + } + } - ClearAndDeleteDirectory(directoryName); - return true; + /// + /// Returns the human-readable file size for an arbitrary, 64-bit file size + /// The default format is "0.## XB", e.g. "4.2 KB" or "1.43 GB" + /// + /// https://www.somacon.com/p576.php + /// + /// + public static string GetHumanReadableBytes(long bytes) + { + // Get absolute value + var absoluteBytes = (bytes < 0 ? -bytes : bytes); + // Determine the suffix and readable value + string suffix; + double readable; + switch (absoluteBytes) + { + // Exabyte + case >= 0x1000000000000000: + suffix = "EB"; + readable = (bytes >> 50); + break; + // Petabyte + case >= 0x4000000000000: + suffix = "PB"; + readable = (bytes >> 40); + break; + // Terabyte + case >= 0x10000000000: + suffix = "TB"; + readable = (bytes >> 30); + break; + // Gigabyte + case >= 0x40000000: + suffix = "GB"; + readable = (bytes >> 20); + break; + // Megabyte + case >= 0x100000: + suffix = "MB"; + readable = (bytes >> 10); + break; + // Kilobyte + case >= 0x400: + suffix = "KB"; + readable = bytes; + break; + default: + return bytes.ToString("0 B"); // Byte } + // Divide by 1024 to get fractional value + readable = (readable / 1024); + // Return formatted number with suffix + return readable.ToString("0.## ") + suffix; + } + + /// + /// Removes all files except images from the directory. Includes sub directories. + /// + /// Fully qualified directory + public void RemoveNonImages(string directoryName) + { + DeleteFiles(GetFiles(directoryName, searchOption:SearchOption.AllDirectories).Where(file => !Tasks.Scanner.Parser.Parser.IsImage(file))); + } - private static void FlattenDirectory(IFileSystemInfo root, IDirectoryInfo directory, ref int directoryIndex) + /// + /// Flattens all files in subfolders to the passed directory recursively. + /// + /// + /// foo + /// ├── 1.txt + /// ├── 2.txt + /// ├── 3.txt + /// ├── 4.txt + /// └── bar + /// ├── 1.txt + /// ├── 2.txt + /// └── 5.txt + /// + /// becomes: + /// foo + /// ├── 1.txt + /// ├── 2.txt + /// ├── 3.txt + /// ├── 4.txt + /// ├── bar_1.txt + /// ├── bar_2.txt + /// └── bar_5.txt + /// + /// Fully qualified Directory name + public void Flatten(string directoryName) + { + if (string.IsNullOrEmpty(directoryName) || !FileSystem.Directory.Exists(directoryName)) return; + + var directory = FileSystem.DirectoryInfo.FromDirectoryName(directoryName); + + var index = 0; + FlattenDirectory(directory, directory, ref index); + } + + /// + /// Checks whether a directory has write permissions + /// + /// Fully qualified path + /// + public async Task CheckWriteAccess(string directoryName) + { + try { - if (!root.FullName.Equals(directory.FullName)) - { - var fileIndex = 1; + ExistOrCreate(directoryName); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(directoryName, "test.txt"), + string.Empty); + } + catch (Exception) + { + ClearAndDeleteDirectory(directoryName); + return false; + } - foreach (var file in directory.EnumerateFiles().OrderByNatural(file => file.FullName)) - { - if (file.Directory == null) continue; - var paddedIndex = Tasks.Scanner.Parser.Parser.PadZeros(directoryIndex + ""); - // We need to rename the files so that after flattening, they are in the order we found them - var newName = $"{paddedIndex}_{Tasks.Scanner.Parser.Parser.PadZeros(fileIndex + "")}{file.Extension}"; - var newPath = Path.Join(root.FullName, newName); - if (!File.Exists(newPath)) file.MoveTo(newPath); - fileIndex++; - } + ClearAndDeleteDirectory(directoryName); + return true; + } - directoryIndex++; - } - foreach (var subDirectory in directory.EnumerateDirectories().OrderByNatural(d => d.FullName)) - { - // We need to check if the directory is not a blacklisted (ie __MACOSX) - if (Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(subDirectory.FullName)) continue; + private static void FlattenDirectory(IFileSystemInfo root, IDirectoryInfo directory, ref int directoryIndex) + { + if (!root.FullName.Equals(directory.FullName)) + { + var fileIndex = 1; - FlattenDirectory(root, subDirectory, ref directoryIndex); + foreach (var file in directory.EnumerateFiles().OrderByNatural(file => file.FullName)) + { + if (file.Directory == null) continue; + var paddedIndex = Tasks.Scanner.Parser.Parser.PadZeros(directoryIndex + ""); + // We need to rename the files so that after flattening, they are in the order we found them + var newName = $"{paddedIndex}_{Tasks.Scanner.Parser.Parser.PadZeros(fileIndex + "")}{file.Extension}"; + var newPath = Path.Join(root.FullName, newName); + if (!File.Exists(newPath)) file.MoveTo(newPath); + fileIndex++; } + + directoryIndex++; + } + + foreach (var subDirectory in directory.EnumerateDirectories().OrderByNatural(d => d.FullName)) + { + // We need to check if the directory is not a blacklisted (ie __MACOSX) + if (Tasks.Scanner.Parser.Parser.HasBlacklistedFolderInPath(subDirectory.FullName)) continue; + + FlattenDirectory(root, subDirectory, ref directoryIndex); } } } diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index df7692c7c8..43f181016f 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -6,55 +6,54 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace API.Services.HostedServices +namespace API.Services.HostedServices; + +public class StartupTasksHostedService : IHostedService { - public class StartupTasksHostedService : IHostedService - { - private readonly IServiceProvider _provider; + private readonly IServiceProvider _provider; - public StartupTasksHostedService(IServiceProvider serviceProvider) - { - _provider = serviceProvider; - } + public StartupTasksHostedService(IServiceProvider serviceProvider) + { + _provider = serviceProvider; + } - public async Task StartAsync(CancellationToken cancellationToken) - { - using var scope = _provider.CreateScope(); + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _provider.CreateScope(); - var taskScheduler = scope.ServiceProvider.GetRequiredService(); - await taskScheduler.ScheduleTasks(); - taskScheduler.ScheduleUpdaterTasks(); + var taskScheduler = scope.ServiceProvider.GetRequiredService(); + await taskScheduler.ScheduleTasks(); + taskScheduler.ScheduleUpdaterTasks(); - try - { - // These methods will automatically check if stat collection is disabled to prevent sending any data regardless - // of when setting was changed - await taskScheduler.ScheduleStatsTasks(); - await taskScheduler.RunStatCollection(); - } - catch (Exception) - { - //If stats startup fail the user can keep using the app - } + try + { + // These methods will automatically check if stat collection is disabled to prevent sending any data regardless + // of when setting was changed + await taskScheduler.ScheduleStatsTasks(); + await taskScheduler.RunStatCollection(); + } + catch (Exception) + { + //If stats startup fail the user can keep using the app + } - try - { - var unitOfWork = scope.ServiceProvider.GetRequiredService(); - if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching) - { - var libraryWatcher = scope.ServiceProvider.GetRequiredService(); - await libraryWatcher.StartWatching(); - } - } - catch (Exception) + try + { + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching) { - // Fail silently + var libraryWatcher = scope.ServiceProvider.GetRequiredService(); + await libraryWatcher.StartWatching(); } - + } + catch (Exception) + { + // Fail silently } - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index 7c10dc81bc..4bb371ec9e 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -7,6 +7,7 @@ using API.Data; using API.Entities.Enums; using API.Extensions; +using API.Logging; using API.SignalR; using Hangfire; using Microsoft.AspNetCore.SignalR; @@ -19,30 +20,27 @@ public interface IBackupService { Task BackupDatabase(); /// - /// Returns a list of full paths of the logs files detailed in . + /// Returns a list of all log files for Kavita /// - /// - /// + /// If file rolling is enabled. Defaults to True. /// - IEnumerable GetLogFiles(int maxRollingFiles, string logFileName); + IEnumerable GetLogFiles(bool rollFiles = LogLevelOptions.LogRollingEnabled); } public class BackupService : IBackupService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly IConfiguration _config; private readonly IEventHub _eventHub; private readonly IList _backupFiles; public BackupService(ILogger logger, IUnitOfWork unitOfWork, - IDirectoryService directoryService, IConfiguration config, IEventHub eventHub) + IDirectoryService directoryService, IEventHub eventHub) { _unitOfWork = unitOfWork; _logger = logger; _directoryService = directoryService; - _config = config; _eventHub = eventHub; _backupFiles = new List() @@ -56,12 +54,17 @@ public BackupService(ILogger logger, IUnitOfWork unitOfWork, }; } - public IEnumerable GetLogFiles(int maxRollingFiles, string logFileName) + /// + /// Returns a list of all log files for Kavita + /// + /// If file rolling is enabled. Defaults to True. + /// + public IEnumerable GetLogFiles(bool rollFiles = LogLevelOptions.LogRollingEnabled) { - var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty; - var fi = _directoryService.FileSystem.FileInfo.FromFileName(logFileName); + var multipleFileRegex = rollFiles ? @"\d*" : string.Empty; + var fi = _directoryService.FileSystem.FileInfo.FromFileName(LogLevelOptions.LogFile); - var files = maxRollingFiles > 0 + var files = rollFiles ? _directoryService.GetFiles(_directoryService.LogDirectory, $@"{_directoryService.FileSystem.Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") : new[] {_directoryService.FileSystem.Path.Join(_directoryService.LogDirectory, "kavita.log")}; @@ -137,9 +140,7 @@ await _eventHub.SendMessageAsync(MessageFactory.Error, private void CopyLogsToBackupDirectory(string tempDirectory) { - var maxRollingFiles = _config.GetMaxRollingFiles(); - var loggingSection = _config.GetLoggingFileName(); - var files = GetLogFiles(maxRollingFiles, loggingSection); + var files = GetLogFiles(); _directoryService.CopyFilesToDirectory(files, _directoryService.FileSystem.Path.Join(tempDirectory, "logs")); } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index c334596815..785cc49ed8 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -9,191 +9,190 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks +namespace API.Services.Tasks; + +public interface ICleanupService +{ + Task Cleanup(); + Task CleanupDbEntries(); + void CleanupCacheDirectory(); + Task DeleteSeriesCoverImages(); + Task DeleteChapterCoverImages(); + Task DeleteTagCoverImages(); + Task CleanupBackups(); + void CleanupTemp(); +} +/// +/// Cleans up after operations on reoccurring basis +/// +public class CleanupService : ICleanupService { - public interface ICleanupService + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + private readonly IDirectoryService _directoryService; + + public CleanupService(ILogger logger, + IUnitOfWork unitOfWork, IEventHub eventHub, + IDirectoryService directoryService) { - Task Cleanup(); - Task CleanupDbEntries(); - void CleanupCacheDirectory(); - Task DeleteSeriesCoverImages(); - Task DeleteChapterCoverImages(); - Task DeleteTagCoverImages(); - Task CleanupBackups(); - void CleanupTemp(); + _logger = logger; + _unitOfWork = unitOfWork; + _eventHub = eventHub; + _directoryService = directoryService; } + + /// - /// Cleans up after operations on reoccurring basis + /// Cleans up Temp, cache, deleted cover images, and old database backups /// - public class CleanupService : ICleanupService + [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] + public async Task Cleanup() { - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - private readonly IEventHub _eventHub; - private readonly IDirectoryService _directoryService; - - public CleanupService(ILogger logger, - IUnitOfWork unitOfWork, IEventHub eventHub, - IDirectoryService directoryService) - { - _logger = logger; - _unitOfWork = unitOfWork; - _eventHub = eventHub; - _directoryService = directoryService; - } + _logger.LogInformation("Starting Cleanup"); + await SendProgress(0F, "Starting cleanup"); + _logger.LogInformation("Cleaning temp directory"); + _directoryService.ClearDirectory(_directoryService.TempDirectory); + await SendProgress(0.1F, "Cleaning temp directory"); + CleanupCacheDirectory(); + await SendProgress(0.25F, "Cleaning old database backups"); + _logger.LogInformation("Cleaning old database backups"); + await CleanupBackups(); + await SendProgress(0.50F, "Cleaning deleted cover images"); + _logger.LogInformation("Cleaning deleted cover images"); + await DeleteSeriesCoverImages(); + await SendProgress(0.6F, "Cleaning deleted cover images"); + await DeleteChapterCoverImages(); + await SendProgress(0.7F, "Cleaning deleted cover images"); + await DeleteTagCoverImages(); + await DeleteReadingListCoverImages(); + await SendProgress(1F, "Cleanup finished"); + _logger.LogInformation("Cleanup finished"); + } + /// + /// Cleans up abandon rows in the DB + /// + public async Task CleanupDbEntries() + { + await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + } - /// - /// Cleans up Temp, cache, deleted cover images, and old database backups - /// - [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] - public async Task Cleanup() - { - _logger.LogInformation("Starting Cleanup"); - await SendProgress(0F, "Starting cleanup"); - _logger.LogInformation("Cleaning temp directory"); - _directoryService.ClearDirectory(_directoryService.TempDirectory); - await SendProgress(0.1F, "Cleaning temp directory"); - CleanupCacheDirectory(); - await SendProgress(0.25F, "Cleaning old database backups"); - _logger.LogInformation("Cleaning old database backups"); - await CleanupBackups(); - await SendProgress(0.50F, "Cleaning deleted cover images"); - _logger.LogInformation("Cleaning deleted cover images"); - await DeleteSeriesCoverImages(); - await SendProgress(0.6F, "Cleaning deleted cover images"); - await DeleteChapterCoverImages(); - await SendProgress(0.7F, "Cleaning deleted cover images"); - await DeleteTagCoverImages(); - await DeleteReadingListCoverImages(); - await SendProgress(1F, "Cleanup finished"); - _logger.LogInformation("Cleanup finished"); - } + private async Task SendProgress(float progress, string subtitle) + { + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CleanupProgressEvent(progress, subtitle)); + } - /// - /// Cleans up abandon rows in the DB - /// - public async Task CleanupDbEntries() - { - await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); - await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - } + /// + /// Removes all series images that are not in the database. They must follow filename pattern. + /// + public async Task DeleteSeriesCoverImages() + { + var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync(); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } - private async Task SendProgress(float progress, string subtitle) - { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.CleanupProgressEvent(progress, subtitle)); - } + /// + /// Removes all chapter/volume images that are not in the database. They must follow filename pattern. + /// + public async Task DeleteChapterCoverImages() + { + var images = await _unitOfWork.ChapterRepository.GetAllCoverImagesAsync(); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } - /// - /// Removes all series images that are not in the database. They must follow filename pattern. - /// - public async Task DeleteSeriesCoverImages() - { - var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync(); - var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex); - _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); - } + /// + /// Removes all collection tag images that are not in the database. They must follow filename pattern. + /// + public async Task DeleteTagCoverImages() + { + var images = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync(); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } + + /// + /// Removes all reading list images that are not in the database. They must follow filename pattern. + /// + public async Task DeleteReadingListCoverImages() + { + var images = await _unitOfWork.ReadingListRepository.GetAllCoverImagesAsync(); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ReadingListCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } + + /// + /// Removes all files and directories in the cache and temp directory + /// + public void CleanupCacheDirectory() + { + _logger.LogInformation("Performing cleanup of Cache directory"); + _directoryService.ExistOrCreate(_directoryService.CacheDirectory); + _directoryService.ExistOrCreate(_directoryService.TempDirectory); - /// - /// Removes all chapter/volume images that are not in the database. They must follow filename pattern. - /// - public async Task DeleteChapterCoverImages() + try { - var images = await _unitOfWork.ChapterRepository.GetAllCoverImagesAsync(); - var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex); - _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + _directoryService.ClearDirectory(_directoryService.CacheDirectory); + _directoryService.ClearDirectory(_directoryService.TempDirectory); } - - /// - /// Removes all collection tag images that are not in the database. They must follow filename pattern. - /// - public async Task DeleteTagCoverImages() + catch (Exception ex) { - var images = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync(); - var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex); - _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); } - /// - /// Removes all reading list images that are not in the database. They must follow filename pattern. - /// - public async Task DeleteReadingListCoverImages() + _logger.LogInformation("Cache directory purged"); + } + + /// + /// Removes Database backups older than configured total backups. If all backups are older than total backups days, only the latest is kept. + /// + public async Task CleanupBackups() + { + var dayThreshold = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).TotalBackups; + _logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now); + var backupDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value; + if (!_directoryService.Exists(backupDirectory)) return; + + var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold)); + var allBackups = _directoryService.GetFiles(backupDirectory).ToList(); + var expiredBackups = allBackups.Select(filename => _directoryService.FileSystem.FileInfo.FromFileName(filename)) + .Where(f => f.CreationTime < deltaTime) + .ToList(); + + if (expiredBackups.Count == allBackups.Count) { - var images = await _unitOfWork.ReadingListRepository.GetAllCoverImagesAsync(); - var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ReadingListCoverImageRegex); - _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + _logger.LogInformation("All expired backups are older than {Threshold} days. Removing all but last backup", dayThreshold); + var toDelete = expiredBackups.OrderByDescending(f => f.CreationTime).ToList(); + _directoryService.DeleteFiles(toDelete.Take(toDelete.Count - 1).Select(f => f.FullName)); } - - /// - /// Removes all files and directories in the cache and temp directory - /// - public void CleanupCacheDirectory() + else { - _logger.LogInformation("Performing cleanup of Cache directory"); - _directoryService.ExistOrCreate(_directoryService.CacheDirectory); - _directoryService.ExistOrCreate(_directoryService.TempDirectory); - - try - { - _directoryService.ClearDirectory(_directoryService.CacheDirectory); - _directoryService.ClearDirectory(_directoryService.TempDirectory); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); - } - - _logger.LogInformation("Cache directory purged"); + _directoryService.DeleteFiles(expiredBackups.Select(f => f.FullName)); } + _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); + } + + public void CleanupTemp() + { + _logger.LogInformation("Performing cleanup of Temp directory"); + _directoryService.ExistOrCreate(_directoryService.TempDirectory); - /// - /// Removes Database backups older than configured total backups. If all backups are older than total backups days, only the latest is kept. - /// - public async Task CleanupBackups() + try { - var dayThreshold = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).TotalBackups; - _logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now); - var backupDirectory = - (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value; - if (!_directoryService.Exists(backupDirectory)) return; - - var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(dayThreshold)); - var allBackups = _directoryService.GetFiles(backupDirectory).ToList(); - var expiredBackups = allBackups.Select(filename => _directoryService.FileSystem.FileInfo.FromFileName(filename)) - .Where(f => f.CreationTime < deltaTime) - .ToList(); - - if (expiredBackups.Count == allBackups.Count) - { - _logger.LogInformation("All expired backups are older than {Threshold} days. Removing all but last backup", dayThreshold); - var toDelete = expiredBackups.OrderByDescending(f => f.CreationTime).ToList(); - _directoryService.DeleteFiles(toDelete.Take(toDelete.Count - 1).Select(f => f.FullName)); - } - else - { - _directoryService.DeleteFiles(expiredBackups.Select(f => f.FullName)); - } - _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); + _directoryService.ClearDirectory(_directoryService.TempDirectory); } - - public void CleanupTemp() + catch (Exception ex) { - _logger.LogInformation("Performing cleanup of Temp directory"); - _directoryService.ExistOrCreate(_directoryService.TempDirectory); - - try - { - _directoryService.ClearDirectory(_directoryService.TempDirectory); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); - } - - _logger.LogInformation("Temp directory purged"); + _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); } + + _logger.LogInformation("Temp directory purged"); } } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index d31879e840..023bc1d2eb 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -9,353 +9,352 @@ using API.SignalR; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks.Scanner +namespace API.Services.Tasks.Scanner; + +public class ParsedSeries { - public class ParsedSeries - { - /// - /// Name of the Series - /// - public string Name { get; init; } - /// - /// Normalized Name of the Series - /// - public string NormalizedName { get; init; } - /// - /// Format of the Series - /// - public MangaFormat Format { get; init; } - } + /// + /// Name of the Series + /// + public string Name { get; init; } + /// + /// Normalized Name of the Series + /// + public string NormalizedName { get; init; } + /// + /// Format of the Series + /// + public MangaFormat Format { get; init; } +} + +public enum Modified +{ + Modified = 1, + NotModified = 2 +} + +public class SeriesModified +{ + public string FolderPath { get; set; } + public string SeriesName { get; set; } + public DateTime LastScanned { get; set; } + public MangaFormat Format { get; set; } +} - public enum Modified - { - Modified = 1, - NotModified = 2 - } - public class SeriesModified +public class ParseScannedFiles +{ + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IReadingItemService _readingItemService; + private readonly IEventHub _eventHub; + + /// + /// An instance of a pipeline for processing files and returning a Map of Series -> ParserInfos. + /// Each instance is separate from other threads, allowing for no cross over. + /// + /// Logger of the parent class that invokes this + /// Directory Service + /// ReadingItemService Service for extracting information on a number of formats + /// For firing off SignalR events + public ParseScannedFiles(ILogger logger, IDirectoryService directoryService, + IReadingItemService readingItemService, IEventHub eventHub) { - public string FolderPath { get; set; } - public string SeriesName { get; set; } - public DateTime LastScanned { get; set; } - public MangaFormat Format { get; set; } + _logger = logger; + _directoryService = directoryService; + _readingItemService = readingItemService; + _eventHub = eventHub; } - public class ParseScannedFiles + /// + /// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained + /// + /// Scan directory by directory and for each, call folderAction + /// A library folder or series folder + /// A callback async Task to be called once all files for each folder path are found + /// If we should bypass any folder last write time checks on the scan and force I/O + public async Task ProcessFiles(string folderPath, bool scanDirectoryByDirectory, + IDictionary> seriesPaths, Func, string,Task> folderAction, bool forceCheck = false) { - private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; - private readonly IReadingItemService _readingItemService; - private readonly IEventHub _eventHub; - - /// - /// An instance of a pipeline for processing files and returning a Map of Series -> ParserInfos. - /// Each instance is separate from other threads, allowing for no cross over. - /// - /// Logger of the parent class that invokes this - /// Directory Service - /// ReadingItemService Service for extracting information on a number of formats - /// For firing off SignalR events - public ParseScannedFiles(ILogger logger, IDirectoryService directoryService, - IReadingItemService readingItemService, IEventHub eventHub) + string normalizedPath; + if (scanDirectoryByDirectory) { - _logger = logger; - _directoryService = directoryService; - _readingItemService = readingItemService; - _eventHub = eventHub; - } - + // This is used in library scan, so we should check first for a ignore file and use that here as well + var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile); + var directories = _directoryService.GetDirectories(folderPath, _directoryService.CreateMatcherFromFile(potentialIgnoreFile)).ToList(); - /// - /// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained - /// - /// Scan directory by directory and for each, call folderAction - /// A library folder or series folder - /// A callback async Task to be called once all files for each folder path are found - /// If we should bypass any folder last write time checks on the scan and force I/O - public async Task ProcessFiles(string folderPath, bool scanDirectoryByDirectory, - IDictionary> seriesPaths, Func, string,Task> folderAction, bool forceCheck = false) - { - string normalizedPath; - if (scanDirectoryByDirectory) + foreach (var directory in directories) { - // This is used in library scan, so we should check first for a ignore file and use that here as well - var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile); - var directories = _directoryService.GetDirectories(folderPath, _directoryService.CreateMatcherFromFile(potentialIgnoreFile)).ToList(); - - foreach (var directory in directories) + normalizedPath = Parser.Parser.NormalizePath(directory); + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) { - normalizedPath = Parser.Parser.NormalizePath(directory); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) - { - await folderAction(new List(), directory); - } - else - { - // For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication - await folderAction(_directoryService.ScanFiles(directory), directory); - } + await folderAction(new List(), directory); + } + else + { + // For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication + await folderAction(_directoryService.ScanFiles(directory), directory); } - - return; } - normalizedPath = Parser.Parser.NormalizePath(folderPath); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) - { - await folderAction(new List(), folderPath); - return; - } - await folderAction(_directoryService.ScanFiles(folderPath), folderPath); + return; } - - /// - /// Attempts to either add a new instance of a show mapping to the _scannedSeries bag or adds to an existing. - /// This will check if the name matches an existing series name (multiple fields) - /// - /// A localized list of a series' parsed infos - /// - private void TrackSeries(ConcurrentDictionary> scannedSeries, ParserInfo info) + normalizedPath = Parser.Parser.NormalizePath(folderPath); + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) { - if (info.Series == string.Empty) return; + await folderAction(new List(), folderPath); + return; + } + await folderAction(_directoryService.ScanFiles(folderPath), folderPath); + } - // Check if normalized info.Series already exists and if so, update info to use that name instead - info.Series = MergeName(scannedSeries, info); - var normalizedSeries = Parser.Parser.Normalize(info.Series); - var normalizedSortSeries = Parser.Parser.Normalize(info.SeriesSort); - var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries); + /// + /// Attempts to either add a new instance of a show mapping to the _scannedSeries bag or adds to an existing. + /// This will check if the name matches an existing series name (multiple fields) + /// + /// A localized list of a series' parsed infos + /// + private void TrackSeries(ConcurrentDictionary> scannedSeries, ParserInfo info) + { + if (info.Series == string.Empty) return; - try - { - var existingKey = scannedSeries.Keys.SingleOrDefault(ps => - ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) - || ps.NormalizedName.Equals(normalizedLocalizedSeries) - || ps.NormalizedName.Equals(normalizedSortSeries))); - existingKey ??= new ParsedSeries() - { - Format = info.Format, - Name = info.Series, - NormalizedName = normalizedSeries - }; + // Check if normalized info.Series already exists and if so, update info to use that name instead + info.Series = MergeName(scannedSeries, info); - scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => - { - oldValue ??= new List(); - if (!oldValue.Contains(info)) - { - oldValue.Add(info); - } + var normalizedSeries = Parser.Parser.Normalize(info.Series); + var normalizedSortSeries = Parser.Parser.Normalize(info.SeriesSort); + var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries); - return oldValue; - }); - } - catch (Exception ex) + try + { + var existingKey = scannedSeries.Keys.SingleOrDefault(ps => + ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) + || ps.NormalizedName.Equals(normalizedLocalizedSeries) + || ps.NormalizedName.Equals(normalizedSortSeries))); + existingKey ??= new ParsedSeries() + { + Format = info.Format, + Name = info.Series, + NormalizedName = normalizedSeries + }; + + scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => { - _logger.LogCritical(ex, "{SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); - foreach (var seriesKey in scannedSeries.Keys.Where(ps => - ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) - || ps.NormalizedName.Equals(normalizedLocalizedSeries) - || ps.NormalizedName.Equals(normalizedSortSeries)))) + oldValue ??= new List(); + if (!oldValue.Contains(info)) { - _logger.LogCritical("Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name); + oldValue.Add(info); } + + return oldValue; + }); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "{SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); + foreach (var seriesKey in scannedSeries.Keys.Where(ps => + ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) + || ps.NormalizedName.Equals(normalizedLocalizedSeries) + || ps.NormalizedName.Equals(normalizedSortSeries)))) + { + _logger.LogCritical("Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name); } } + } - /// - /// Using a normalized name from the passed ParserInfo, this checks against all found series so far and if an existing one exists with - /// same normalized name, it merges into the existing one. This is important as some manga may have a slight difference with punctuation or capitalization. - /// - /// - /// Series Name to group this info into - private string MergeName(ConcurrentDictionary> scannedSeries, ParserInfo info) - { - var normalizedSeries = Parser.Parser.Normalize(info.Series); - var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries); + /// + /// Using a normalized name from the passed ParserInfo, this checks against all found series so far and if an existing one exists with + /// same normalized name, it merges into the existing one. This is important as some manga may have a slight difference with punctuation or capitalization. + /// + /// + /// Series Name to group this info into + private string MergeName(ConcurrentDictionary> scannedSeries, ParserInfo info) + { + var normalizedSeries = Parser.Parser.Normalize(info.Series); + var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries); - try + try + { + var existingName = + scannedSeries.SingleOrDefault(p => + (Parser.Parser.Normalize(p.Key.NormalizedName).Equals(normalizedSeries) || + Parser.Parser.Normalize(p.Key.NormalizedName).Equals(normalizedLocalSeries)) && + p.Key.Format == info.Format) + .Key; + + if (existingName != null && !string.IsNullOrEmpty(existingName.Name)) { - var existingName = - scannedSeries.SingleOrDefault(p => - (Parser.Parser.Normalize(p.Key.NormalizedName).Equals(normalizedSeries) || - Parser.Parser.Normalize(p.Key.NormalizedName).Equals(normalizedLocalSeries)) && - p.Key.Format == info.Format) - .Key; - - if (existingName != null && !string.IsNullOrEmpty(existingName.Name)) - { - return existingName.Name; - } + return existingName.Name; } - catch (Exception ex) + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); + var values = scannedSeries.Where(p => + (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || + Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && + p.Key.Format == info.Format); + foreach (var pair in values) { - _logger.LogCritical(ex, "Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); - var values = scannedSeries.Where(p => - (Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries || - Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && - p.Key.Format == info.Format); - foreach (var pair in values) - { - _logger.LogCritical("Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name); - } - + _logger.LogCritical("Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name); } - return info.Series; } + return info.Series; + } - /// - /// This will process series by folder groups. - /// - /// - /// - /// - /// - public async Task ScanLibrariesForSeries(LibraryType libraryType, - IEnumerable folders, string libraryName, bool isLibraryScan, - IDictionary> seriesPaths, Action>> processSeriesInfos, bool forceCheck = false) - { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", libraryName, ProgressEventType.Started)); + /// + /// This will process series by folder groups. + /// + /// + /// + /// + /// + public async Task ScanLibrariesForSeries(LibraryType libraryType, + IEnumerable folders, string libraryName, bool isLibraryScan, + IDictionary> seriesPaths, Action>> processSeriesInfos, bool forceCheck = false) + { + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", libraryName, ProgressEventType.Started)); - foreach (var folderPath in folders) + foreach (var folderPath in folders) + { + try { - try + await ProcessFiles(folderPath, isLibraryScan, seriesPaths, async (files, folder) => { - await ProcessFiles(folderPath, isLibraryScan, seriesPaths, async (files, folder) => + var normalizedFolder = Parser.Parser.NormalizePath(folder); + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedFolder, forceCheck)) { - var normalizedFolder = Parser.Parser.NormalizePath(folder); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedFolder, forceCheck)) - { - var parsedInfos = seriesPaths[normalizedFolder].Select(fp => new ParserInfo() - { - Series = fp.SeriesName, - Format = fp.Format, - }).ToList(); - processSeriesInfos.Invoke(new Tuple>(true, parsedInfos)); - _logger.LogDebug("Skipped File Scan for {Folder} as it hasn't changed since last scan", folder); - return; - } - _logger.LogDebug("Found {Count} files for {Folder}", files.Count, folder); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(folderPath, libraryName, ProgressEventType.Updated)); - if (files.Count == 0) + var parsedInfos = seriesPaths[normalizedFolder].Select(fp => new ParserInfo() { - _logger.LogInformation("[ScannerService] {Folder} is empty", folder); - return; - } - var scannedSeries = new ConcurrentDictionary>(); - var infos = files - .Select(file => _readingItemService.ParseFile(file, folderPath, libraryType)) - .Where(info => info != null) - .ToList(); + Series = fp.SeriesName, + Format = fp.Format, + }).ToList(); + processSeriesInfos.Invoke(new Tuple>(true, parsedInfos)); + _logger.LogDebug("Skipped File Scan for {Folder} as it hasn't changed since last scan", folder); + return; + } + _logger.LogDebug("Found {Count} files for {Folder}", files.Count, folder); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(folderPath, libraryName, ProgressEventType.Updated)); + if (files.Count == 0) + { + _logger.LogInformation("[ScannerService] {Folder} is empty", folder); + return; + } + var scannedSeries = new ConcurrentDictionary>(); + var infos = files + .Select(file => _readingItemService.ParseFile(file, folderPath, libraryType)) + .Where(info => info != null) + .ToList(); - MergeLocalizedSeriesWithSeries(infos); + MergeLocalizedSeriesWithSeries(infos); - foreach (var info in infos) + foreach (var info in infos) + { + try + { + TrackSeries(scannedSeries, info); + } + catch (Exception ex) { - try - { - TrackSeries(scannedSeries, info); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception that occurred during tracking {FilePath}. Skipping this file", info.FullFilePath); - } + _logger.LogError(ex, "There was an exception that occurred during tracking {FilePath}. Skipping this file", info.FullFilePath); } + } - // It would be really cool if we can emit an event when a folder hasn't been changed so we don't parse everything, but the first item to ensure we don't delete it - // Otherwise, we can do a last step in the DB where we validate all files on disk exist and if not, delete them. (easy but slow) - foreach (var series in scannedSeries.Keys) + // It would be really cool if we can emit an event when a folder hasn't been changed so we don't parse everything, but the first item to ensure we don't delete it + // Otherwise, we can do a last step in the DB where we validate all files on disk exist and if not, delete them. (easy but slow) + foreach (var series in scannedSeries.Keys) + { + if (scannedSeries[series].Count > 0 && processSeriesInfos != null) { - if (scannedSeries[series].Count > 0 && processSeriesInfos != null) - { - processSeriesInfos.Invoke(new Tuple>(false, scannedSeries[series])); - } + processSeriesInfos.Invoke(new Tuple>(false, scannedSeries[series])); } - }, forceCheck); - } - catch (ArgumentException ex) - { - _logger.LogError(ex, "The directory '{FolderPath}' does not exist", folderPath); - } + } + }, forceCheck); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, "The directory '{FolderPath}' does not exist", folderPath); } - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", libraryName, ProgressEventType.Ended)); } - /// - /// Checks against all folder paths on file if the last scanned is >= the directory's last write down to the second - /// - /// - /// - /// - /// - private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string normalizedFolder, bool forceCheck = false) - { - if (forceCheck) return false; + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", libraryName, ProgressEventType.Ended)); + } - return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= - _directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond)); - } + /// + /// Checks against all folder paths on file if the last scanned is >= the directory's last write down to the second + /// + /// + /// + /// + /// + private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string normalizedFolder, bool forceCheck = false) + { + if (forceCheck) return false; + + return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= + _directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond)); + } - /// - /// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so, - /// rewrites the infos with series name instead of the localized name, so they stack. - /// - /// - /// Accel World v01.cbz has Series "Accel World" and Localized Series "World of Acceleration" - /// World of Acceleration v02.cbz has Series "World of Acceleration" - /// After running this code, we'd have: - /// World of Acceleration v02.cbz having Series "Accel World" and Localized Series of "World of Acceleration" - /// - /// A collection of ParserInfos - private void MergeLocalizedSeriesWithSeries(IReadOnlyCollection infos) + /// + /// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so, + /// rewrites the infos with series name instead of the localized name, so they stack. + /// + /// + /// Accel World v01.cbz has Series "Accel World" and Localized Series "World of Acceleration" + /// World of Acceleration v02.cbz has Series "World of Acceleration" + /// After running this code, we'd have: + /// World of Acceleration v02.cbz having Series "Accel World" and Localized Series of "World of Acceleration" + /// + /// A collection of ParserInfos + private void MergeLocalizedSeriesWithSeries(IReadOnlyCollection infos) + { + var hasLocalizedSeries = infos.Any(i => !string.IsNullOrEmpty(i.LocalizedSeries)); + if (!hasLocalizedSeries) return; + + var localizedSeries = infos + .Where(i => !i.IsSpecial) + .Select(i => i.LocalizedSeries) + .Distinct() + .FirstOrDefault(i => !string.IsNullOrEmpty(i)); + if (string.IsNullOrEmpty(localizedSeries)) return; + + // NOTE: If we have multiple series in a folder with a localized title, then this will fail. It will group into one series. User needs to fix this themselves. + string nonLocalizedSeries; + // Normalize this as many of the cases is a capitalization difference + var nonLocalizedSeriesFound = infos + .Where(i => !i.IsSpecial) + .Select(i => i.Series).DistinctBy(Parser.Parser.Normalize).ToList(); + if (nonLocalizedSeriesFound.Count == 1) { - var hasLocalizedSeries = infos.Any(i => !string.IsNullOrEmpty(i.LocalizedSeries)); - if (!hasLocalizedSeries) return; - - var localizedSeries = infos - .Where(i => !i.IsSpecial) - .Select(i => i.LocalizedSeries) - .Distinct() - .FirstOrDefault(i => !string.IsNullOrEmpty(i)); - if (string.IsNullOrEmpty(localizedSeries)) return; - - // NOTE: If we have multiple series in a folder with a localized title, then this will fail. It will group into one series. User needs to fix this themselves. - string nonLocalizedSeries; - // Normalize this as many of the cases is a capitalization difference - var nonLocalizedSeriesFound = infos - .Where(i => !i.IsSpecial) - .Select(i => i.Series).DistinctBy(Parser.Parser.Normalize).ToList(); - if (nonLocalizedSeriesFound.Count == 1) - { - nonLocalizedSeries = nonLocalizedSeriesFound.First(); - } - else + nonLocalizedSeries = nonLocalizedSeriesFound.First(); + } + else + { + // There can be a case where there are multiple series in a folder that causes merging. + if (nonLocalizedSeriesFound.Count > 2) { - // There can be a case where there are multiple series in a folder that causes merging. - if (nonLocalizedSeriesFound.Count > 2) - { - _logger.LogError("[ScannerService] There are multiple series within one folder that contain localized series. This will cause them to group incorrectly. Please separate series into their own dedicated folder or ensure there is only 2 potential series (localized and series): {LocalizedSeries}", string.Join(", ", nonLocalizedSeriesFound)); - } - nonLocalizedSeries = nonLocalizedSeriesFound.FirstOrDefault(s => !s.Equals(localizedSeries)); + _logger.LogError("[ScannerService] There are multiple series within one folder that contain localized series. This will cause them to group incorrectly. Please separate series into their own dedicated folder or ensure there is only 2 potential series (localized and series): {LocalizedSeries}", string.Join(", ", nonLocalizedSeriesFound)); } + nonLocalizedSeries = nonLocalizedSeriesFound.FirstOrDefault(s => !s.Equals(localizedSeries)); + } - if (string.IsNullOrEmpty(nonLocalizedSeries)) return; + if (string.IsNullOrEmpty(nonLocalizedSeries)) return; - var normalizedNonLocalizedSeries = Parser.Parser.Normalize(nonLocalizedSeries); - foreach (var infoNeedingMapping in infos.Where(i => - !Parser.Parser.Normalize(i.Series).Equals(normalizedNonLocalizedSeries))) - { - infoNeedingMapping.Series = nonLocalizedSeries; - infoNeedingMapping.LocalizedSeries = localizedSeries; - } + var normalizedNonLocalizedSeries = Parser.Parser.Normalize(nonLocalizedSeries); + foreach (var infoNeedingMapping in infos.Where(i => + !Parser.Parser.Normalize(i.Series).Equals(normalizedNonLocalizedSeries))) + { + infoNeedingMapping.Series = nonLocalizedSeries; + infoNeedingMapping.LocalizedSeries = localizedSeries; } } } diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 8db88333ea..15e809d029 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -5,1085 +5,1084 @@ using System.Text.RegularExpressions; using API.Entities.Enums; -namespace API.Services.Tasks.Scanner.Parser +namespace API.Services.Tasks.Scanner.Parser; + +public static class Parser { - public static class Parser + public const string DefaultChapter = "0"; + public const string DefaultVolume = "0"; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); + + public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif)"; + public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; + private const string BookFileExtensions = @"\.epub|\.pdf"; + public const string MacOsMetadataFileStartsWith = @"._"; + + public const string SupportedExtensions = + ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions; + + private const RegexOptions MatchOptions = + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant; + + /// + /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data + /// + /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face + public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" + + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", + MatchOptions, RegexTimeout); + /// + /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import + /// + public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", + MatchOptions | RegexOptions.Multiline, RegexTimeout); + /// + /// Misc css image references, like background-image: url(), border-image, or list-style-image + /// + /// Original prepend: (background|border|list-style)-image:\s?)? + public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", + MatchOptions, RegexTimeout); + + + private const string XmlRegexExtensions = @"\.xml"; + private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, + MatchOptions, RegexTimeout); + private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, + MatchOptions, RegexTimeout); + private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", + MatchOptions, RegexTimeout); + private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, + MatchOptions, RegexTimeout); + private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, + MatchOptions, RegexTimeout); + private static readonly Regex CoverImageRegex = new Regex(@"(? + /// Recognizes the Special token only + /// + private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+", + MatchOptions, RegexTimeout); + + + private static readonly Regex[] MangaVolumeRegex = new[] { - public const string DefaultChapter = "0"; - public const string DefaultVolume = "0"; - private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); - - public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif)"; - public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; - private const string BookFileExtensions = @"\.epub|\.pdf"; - public const string MacOsMetadataFileStartsWith = @"._"; - - public const string SupportedExtensions = - ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions; - - private const RegexOptions MatchOptions = - RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant; - - /// - /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data - /// - /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face - public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" - + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", - MatchOptions, RegexTimeout); - /// - /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import - /// - public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", - MatchOptions | RegexOptions.Multiline, RegexTimeout); - /// - /// Misc css image references, like background-image: url(), border-image, or list-style-image - /// - /// Original prepend: (background|border|list-style)-image:\s?)? - public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", - MatchOptions, RegexTimeout); - - - private const string XmlRegexExtensions = @"\.xml"; - private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, - MatchOptions, RegexTimeout); - private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, - MatchOptions, RegexTimeout); - private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", - MatchOptions, RegexTimeout); - private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, - MatchOptions, RegexTimeout); - private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, - MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? - /// Recognizes the Special token only - /// - private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+", - MatchOptions, RegexTimeout); - - - private static readonly Regex[] MangaVolumeRegex = new[] - { - // Dance in the Vampire Bund v16-17 - new Regex( - @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", - MatchOptions, RegexTimeout), - // NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar - new Regex( - @"(?.*)(\b|_)(?!\[)(vol\.?)(?\d+(-\d+)?)(?!\])", - MatchOptions, RegexTimeout), - // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 - new Regex( - @"(?.*)(\b|_)(?!\[)v(?\d+(-\d+)?)(?!\])", - MatchOptions, RegexTimeout), - // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 - new Regex( - @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", - MatchOptions, RegexTimeout), - // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) - new Regex( - @"(vol\.? ?)(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Tonikaku Cawaii [Volume 11].cbz - new Regex( - @"(volume )(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Tower Of God S01 014 (CBT) (digital).cbz - new Regex( - @"(?.*)(\b|_|)(S(?\d+))", - MatchOptions, RegexTimeout), - // vol_001-1.cbz for MangaPy default naming convention - new Regex( - @"(vol_)(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 - new Regex( - @"第(?\d+)(卷|册)", - MatchOptions, RegexTimeout), - // Chinese Volume: 卷n -> Volume n, 册n -> Volume n - new Regex( - @"(卷|册)(?\d+)", - MatchOptions, RegexTimeout), - // Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) - new Regex( - @"제?(?\d+)권", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, - new Regex( - @"시즌(?\d+\-?\d+)", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, n시즌 -> season n - new Regex( - @"(?\d+(\-|~)?\d+?)시즌", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, n시즌 -> season n - new Regex( - @"시즌(?\d+(\-|~)?\d+?)", - MatchOptions, RegexTimeout), - // Japanese Volume: n巻 -> Volume n - new Regex( - @"(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), - }; - - private static readonly Regex[] MangaSeriesRegex = new[] - { - // Grand Blue Dreaming - SP02 - new Regex( - @"(?.*)(\b|_|-|\s)(?:sp)\d", - MatchOptions, RegexTimeout), - // [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz - new Regex( - @"^(?.*)( |_)Vol\.?(\d+|tbd)", - MatchOptions, RegexTimeout), - // Mad Chimera World - Volume 005 - Chapter 026.cbz (couldn't figure out how to get Volume negative lookaround working on below regex), - // The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake - new Regex( - @"(?.+?)(\s|_|-)+(?:Vol(ume|\.)?(\s|_|-)+\d+)(\s|_|-)+(?:(Ch|Chapter|Ch)\.?)(\s|_|-)+(?\d+)", - MatchOptions, - RegexTimeout), - // Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip - new Regex( - @"(?.*)(\b|_)v(?\d+-?\d*)(\s|_|-)", - MatchOptions, - RegexTimeout), - // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] - new Regex( - @"(?.*)( - )(?:v|vo|c|chapters)\d", - MatchOptions, RegexTimeout), - // Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip - new Regex( - @"(?.*)(?:, Chapter )(?\d+)", - MatchOptions, RegexTimeout), - // Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz, My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras - new Regex( - @"(?.+?)(\s|_|-)(?!Vol)(\s|_|-)((?:Chapter)|(?:Ch\.))(\s|_|-)(?\d+)", - MatchOptions, RegexTimeout), - // [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz - new Regex( - @"(?.*) (\b|_|-)(vol)\.?(\s|-|_)?\d+", - MatchOptions, RegexTimeout), - // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] - new Regex( - @"(?.*) (\b|_|-)(vol)(ume)", - MatchOptions, - RegexTimeout), - //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] - new Regex( - @"(?.*)(\bc\d+\b)", - MatchOptions, RegexTimeout), - //Tonikaku Cawaii [Volume 11], Darling in the FranXX - Volume 01.cbz - new Regex( - @"(?.*)(?: _|-|\[|\()\s?vol(ume)?", - MatchOptions, RegexTimeout), - // Momo The Blood Taker - Chapter 027 Violent Emotion.cbz, Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz - new Regex( - @"^(?(?!Vol).+?)(?:(ch(apter|\.)(\b|_|-|\s))|sp)\d", - MatchOptions, RegexTimeout), - // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) - new Regex( - @"(?.*) (\b|_|-)(v|ch\.?|c|s)\d+", - MatchOptions, RegexTimeout), - // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz - new Regex( - @"(?.*)\s+(?\d+)\s+(?:\(\d{4}\))\s", - MatchOptions, RegexTimeout), - // Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire) - new Regex( - @"(?.*) (-)?(?\d+(?:.\d+|-\d+)?) \(\d{4}\)", - MatchOptions, RegexTimeout), - // Noblesse - Episode 429 (74 Pages).7z - new Regex( - @"(?.*)(\s|_)(?:Episode|Ep\.?)(\s|_)(?\d+(?:.\d+|-\d+)?)", - MatchOptions, RegexTimeout), - // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) - new Regex( - @"(?.*)\(\d", - MatchOptions, RegexTimeout), - // Tonikaku Kawaii (Ch 59-67) (Ongoing) - new Regex( - @"(?.*)(\s|_)\((c\s|ch\s|chapter\s)", - MatchOptions, RegexTimeout), - // Fullmetal Alchemist chapters 101-108 - new Regex( - @"(?.+?)(\s|_|\-)+?chapters(\s|_|\-)+?\d+(\s|_|\-)+?", - MatchOptions, RegexTimeout), - // It's Witching Time! 001 (Digital) (Anonymous1234) - new Regex( - @"(?.+?)(\s|_|\-)+?\d+(\s|_|\-)\(", - MatchOptions, RegexTimeout), - //Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip must be before [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip - // due to duplicate version identifiers in file. - new Regex( - @"(?.*)(v|s)\d+(-\d+)?(_|\s)", - MatchOptions, RegexTimeout), - //[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip - new Regex( - @"(?.*)(v|s)\d+(-\d+)?", - MatchOptions, RegexTimeout), - // Black Bullet (This is very loose, keep towards bottom) - new Regex( - @"(?.*)(_)(v|vo|c|volume)( |_)\d+", - MatchOptions, RegexTimeout), - // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar - new Regex( - @"(?.*)( |_)(vol\d+)?( |_)(?:Chp\.? ?\d+)", - MatchOptions, RegexTimeout), - // Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1 - new Regex( - @"(?.*)( |_)(?:Chp.? ?\d+)", - MatchOptions, RegexTimeout), - // Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01 - new Regex( - @"^(?!Vol)(?.*)( |_)Chapter( |_)(\d+)", - MatchOptions, RegexTimeout), - - // Fullmetal Alchemist chapters 101-108.cbz - new Regex( - @"^(?!vol)(?.*)( |_)(chapters( |_)?)\d+-?\d*", - MatchOptions, RegexTimeout), - // Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1 - new Regex( - @"^(?!Vol\.?)(?.*)( |_|-)(?.*)ch\d+-?\d?", - MatchOptions, RegexTimeout), - // Magi - Ch.252-005.cbz - new Regex( - @"(?.*)( ?- ?)Ch\.\d+-?\d*", - MatchOptions, RegexTimeout), - // [BAA]_Darker_than_Black_Omake-1.zip - new Regex( - @"^(?!Vol)(?.*)(-)\d+-?\d*", // This catches a lot of stuff ^(?!Vol)(?.*)( |_)(\d+) - MatchOptions, RegexTimeout), - // Kodoja #001 (March 2016) - new Regex( - @"(?.*)(\s|_|-)#", - MatchOptions, RegexTimeout), - // Baketeriya ch01-05.zip, Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar, A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001) - new Regex( - @"^(?!Vol\.?)(?!Chapter)(?.+?)(\s|_|-)(?.*)( |_|-)(ch?)\d+", - MatchOptions, RegexTimeout), - // Japanese Volume: n巻 -> Volume n - new Regex( - @"(?.+?)第(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), - }; - - private static readonly Regex[] ComicSeriesRegex = new[] - { - // Tintin - T22 Vol 714 pour Sydney - new Regex( - @"(?.+?)\s?(\b|_|-)\s?((vol|tome|t)\.?)(?\d+(-\d+)?)", - MatchOptions, RegexTimeout), - // Invincible Vol 01 Family matters (2005) (Digital) - new Regex( - @"(?.+?)(\b|_)((vol|tome|t)\.?)(\s|_)(?\d+(-\d+)?)", - MatchOptions, RegexTimeout), - // Batman Beyond 2.0 001 (2013) - new Regex( - @"^(?.+?\S\.\d) (?\d+)", - MatchOptions, RegexTimeout), - // 04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS) - new Regex( + // Dance in the Vampire Bund v16-17 + new Regex( + @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", + MatchOptions, RegexTimeout), + // NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar + new Regex( + @"(?.*)(\b|_)(?!\[)(vol\.?)(?\d+(-\d+)?)(?!\])", + MatchOptions, RegexTimeout), + // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 + new Regex( + @"(?.*)(\b|_)(?!\[)v(?\d+(-\d+)?)(?!\])", + MatchOptions, RegexTimeout), + // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 + new Regex( + @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", + MatchOptions, RegexTimeout), + // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) + new Regex( + @"(vol\.? ?)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Tonikaku Cawaii [Volume 11].cbz + new Regex( + @"(volume )(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Tower Of God S01 014 (CBT) (digital).cbz + new Regex( + @"(?.*)(\b|_|)(S(?\d+))", + MatchOptions, RegexTimeout), + // vol_001-1.cbz for MangaPy default naming convention + new Regex( + @"(vol_)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 + new Regex( + @"第(?\d+)(卷|册)", + MatchOptions, RegexTimeout), + // Chinese Volume: 卷n -> Volume n, 册n -> Volume n + new Regex( + @"(卷|册)(?\d+)", + MatchOptions, RegexTimeout), + // Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + new Regex( + @"제?(?\d+)권", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, + new Regex( + @"시즌(?\d+\-?\d+)", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"(?\d+(\-|~)?\d+?)시즌", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"시즌(?\d+(\-|~)?\d+?)", + MatchOptions, RegexTimeout), + // Japanese Volume: n巻 -> Volume n + new Regex( + @"(?\d+(?:(\-)\d+)?)巻", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] MangaSeriesRegex = new[] + { + // Grand Blue Dreaming - SP02 + new Regex( + @"(?.*)(\b|_|-|\s)(?:sp)\d", + MatchOptions, RegexTimeout), + // [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz + new Regex( + @"^(?.*)( |_)Vol\.?(\d+|tbd)", + MatchOptions, RegexTimeout), + // Mad Chimera World - Volume 005 - Chapter 026.cbz (couldn't figure out how to get Volume negative lookaround working on below regex), + // The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake + new Regex( + @"(?.+?)(\s|_|-)+(?:Vol(ume|\.)?(\s|_|-)+\d+)(\s|_|-)+(?:(Ch|Chapter|Ch)\.?)(\s|_|-)+(?\d+)", + MatchOptions, + RegexTimeout), + // Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip + new Regex( + @"(?.*)(\b|_)v(?\d+-?\d*)(\s|_|-)", + MatchOptions, + RegexTimeout), + // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] + new Regex( + @"(?.*)( - )(?:v|vo|c|chapters)\d", + MatchOptions, RegexTimeout), + // Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip + new Regex( + @"(?.*)(?:, Chapter )(?\d+)", + MatchOptions, RegexTimeout), + // Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz, My Charms Are Wasted on Kuroiwa Medaka - Ch. 37.5 - Volume Extras + new Regex( + @"(?.+?)(\s|_|-)(?!Vol)(\s|_|-)((?:Chapter)|(?:Ch\.))(\s|_|-)(?\d+)", + MatchOptions, RegexTimeout), + // [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz + new Regex( + @"(?.*) (\b|_|-)(vol)\.?(\s|-|_)?\d+", + MatchOptions, RegexTimeout), + // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] + new Regex( + @"(?.*) (\b|_|-)(vol)(ume)", + MatchOptions, + RegexTimeout), + //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] + new Regex( + @"(?.*)(\bc\d+\b)", + MatchOptions, RegexTimeout), + //Tonikaku Cawaii [Volume 11], Darling in the FranXX - Volume 01.cbz + new Regex( + @"(?.*)(?: _|-|\[|\()\s?vol(ume)?", + MatchOptions, RegexTimeout), + // Momo The Blood Taker - Chapter 027 Violent Emotion.cbz, Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz + new Regex( + @"^(?(?!Vol).+?)(?:(ch(apter|\.)(\b|_|-|\s))|sp)\d", + MatchOptions, RegexTimeout), + // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) + new Regex( + @"(?.*) (\b|_|-)(v|ch\.?|c|s)\d+", + MatchOptions, RegexTimeout), + // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz + new Regex( + @"(?.*)\s+(?\d+)\s+(?:\(\d{4}\))\s", + MatchOptions, RegexTimeout), + // Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire) + new Regex( + @"(?.*) (-)?(?\d+(?:.\d+|-\d+)?) \(\d{4}\)", + MatchOptions, RegexTimeout), + // Noblesse - Episode 429 (74 Pages).7z + new Regex( + @"(?.*)(\s|_)(?:Episode|Ep\.?)(\s|_)(?\d+(?:.\d+|-\d+)?)", + MatchOptions, RegexTimeout), + // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) + new Regex( + @"(?.*)\(\d", + MatchOptions, RegexTimeout), + // Tonikaku Kawaii (Ch 59-67) (Ongoing) + new Regex( + @"(?.*)(\s|_)\((c\s|ch\s|chapter\s)", + MatchOptions, RegexTimeout), + // Fullmetal Alchemist chapters 101-108 + new Regex( + @"(?.+?)(\s|_|\-)+?chapters(\s|_|\-)+?\d+(\s|_|\-)+?", + MatchOptions, RegexTimeout), + // It's Witching Time! 001 (Digital) (Anonymous1234) + new Regex( + @"(?.+?)(\s|_|\-)+?\d+(\s|_|\-)\(", + MatchOptions, RegexTimeout), + //Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip must be before [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + // due to duplicate version identifiers in file. + new Regex( + @"(?.*)(v|s)\d+(-\d+)?(_|\s)", + MatchOptions, RegexTimeout), + //[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + new Regex( + @"(?.*)(v|s)\d+(-\d+)?", + MatchOptions, RegexTimeout), + // Black Bullet (This is very loose, keep towards bottom) + new Regex( + @"(?.*)(_)(v|vo|c|volume)( |_)\d+", + MatchOptions, RegexTimeout), + // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar + new Regex( + @"(?.*)( |_)(vol\d+)?( |_)(?:Chp\.? ?\d+)", + MatchOptions, RegexTimeout), + // Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1 + new Regex( + @"(?.*)( |_)(?:Chp.? ?\d+)", + MatchOptions, RegexTimeout), + // Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01 + new Regex( + @"^(?!Vol)(?.*)( |_)Chapter( |_)(\d+)", + MatchOptions, RegexTimeout), + + // Fullmetal Alchemist chapters 101-108.cbz + new Regex( + @"^(?!vol)(?.*)( |_)(chapters( |_)?)\d+-?\d*", + MatchOptions, RegexTimeout), + // Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1 + new Regex( + @"^(?!Vol\.?)(?.*)( |_|-)(?.*)ch\d+-?\d?", + MatchOptions, RegexTimeout), + // Magi - Ch.252-005.cbz + new Regex( + @"(?.*)( ?- ?)Ch\.\d+-?\d*", + MatchOptions, RegexTimeout), + // [BAA]_Darker_than_Black_Omake-1.zip + new Regex( + @"^(?!Vol)(?.*)(-)\d+-?\d*", // This catches a lot of stuff ^(?!Vol)(?.*)( |_)(\d+) + MatchOptions, RegexTimeout), + // Kodoja #001 (March 2016) + new Regex( + @"(?.*)(\s|_|-)#", + MatchOptions, RegexTimeout), + // Baketeriya ch01-05.zip, Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar, A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001) + new Regex( + @"^(?!Vol\.?)(?!Chapter)(?.+?)(\s|_|-)(?.*)( |_|-)(ch?)\d+", + MatchOptions, RegexTimeout), + // Japanese Volume: n巻 -> Volume n + new Regex( + @"(?.+?)第(?\d+(?:(\-)\d+)?)巻", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] ComicSeriesRegex = new[] + { + // Tintin - T22 Vol 714 pour Sydney + new Regex( + @"(?.+?)\s?(\b|_|-)\s?((vol|tome|t)\.?)(?\d+(-\d+)?)", + MatchOptions, RegexTimeout), + // Invincible Vol 01 Family matters (2005) (Digital) + new Regex( + @"(?.+?)(\b|_)((vol|tome|t)\.?)(\s|_)(?\d+(-\d+)?)", + MatchOptions, RegexTimeout), + // Batman Beyond 2.0 001 (2013) + new Regex( + @"^(?.+?\S\.\d) (?\d+)", + MatchOptions, RegexTimeout), + // 04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS) + new Regex( @"^(?\d+)\s(-\s|_)(?.*(\d{4})?)( |_)(\(|\d+)", - MatchOptions, RegexTimeout), - // 01 Spider-Man & Wolverine 01.cbr - new Regex( + MatchOptions, RegexTimeout), + // 01 Spider-Man & Wolverine 01.cbr + new Regex( @"^(?\d+)\s(?:-\s)(?.*) (\d+)?", - MatchOptions, RegexTimeout), - // Batman & Wildcat (1 of 3) - new Regex( + MatchOptions, RegexTimeout), + // Batman & Wildcat (1 of 3) + new Regex( @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", - MatchOptions, RegexTimeout), - // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus), Aldebaran-Antares-t6 - new Regex( - @"^(?.+?)(?: |_|-)(v|t)\d+", - MatchOptions, RegexTimeout), - // Amazing Man Comics chapter 25 - new Regex( - @"^(?.+?)(?: |_)c(hapter) \d+", - MatchOptions, RegexTimeout), - // Amazing Man Comics issue #25 - new Regex( - @"^(?.+?)(?: |_)i(ssue) #\d+", - MatchOptions, RegexTimeout), - // Batman Wayne Family Adventures - Ep. 001 - Moving In - new Regex( - @"^(?.+?)(\s|_|-)(?:Ep\.?)(\s|_|-)+\d+", - MatchOptions, RegexTimeout), - // Batgirl Vol.2000 #57 (December, 2004) - new Regex( - @"^(?.+?)Vol\.?\s?#?(?:\d+)", - MatchOptions, RegexTimeout), - // Batman & Robin the Teen Wonder #0 - new Regex( - @"^(?.*)(?: |_)#\d+", - MatchOptions, RegexTimeout), - // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) - new Regex( - @"^(?.+?)(?: \d+)", - MatchOptions, RegexTimeout), - // Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005) - new Regex( - @"^(?.+?)(?: |_)(?\d+)", - MatchOptions, RegexTimeout), - // The First Asterix Frieze (WebP by Doc MaKS) - new Regex( - @"^(?.*)(?: |_)(?!\(\d{4}|\d{4}-\d{2}\))\(", - MatchOptions, RegexTimeout), - // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) - new Regex( - @"^(?.+?)-(chapter-)?(?\d+)", - MatchOptions, RegexTimeout), - // MUST BE LAST: Batman & Daredevil - King of New York - new Regex( - @"^(?.*)", - MatchOptions, RegexTimeout), - }; - - private static readonly Regex[] ComicVolumeRegex = new[] - { - // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) - new Regex( - @"^(?.*)(?: |_)(t|v)(?\d+)", - MatchOptions, RegexTimeout), - // Batgirl Vol.2000 #57 (December, 2004) - new Regex( - @"^(?.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?\d+)", - MatchOptions, RegexTimeout), - // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 - new Regex( - @"第(?\d+)(卷|册)", - MatchOptions, RegexTimeout), - // Chinese Volume: 卷n -> Volume n, 册n -> Volume n - new Regex( - @"(卷|册)(?\d+)", - MatchOptions, RegexTimeout), - // Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip - new Regex( - @"제?(?\d+)권", - MatchOptions, RegexTimeout), - // Japanese Volume: n巻 -> Volume n - new Regex( - @"(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), - }; - - private static readonly Regex[] ComicChapterRegex = new[] - { - // Batman & Wildcat (1 of 3) - new Regex( - @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", - MatchOptions, RegexTimeout), - // Batman Beyond 04 (of 6) (1999) - new Regex( - @"(?.+?)(?\d+)(\s|_|-)?\(of", - MatchOptions, RegexTimeout), - // Batman Beyond 2.0 001 (2013) - new Regex( - @"^(?.+?\S\.\d) (?\d+)", - MatchOptions, RegexTimeout), - // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) - new Regex( - @"^(?.+?)(?: |_)v(?\d+)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)", - MatchOptions, RegexTimeout), - // Batman & Robin the Teen Wonder #0 - new Regex( - @"^(?.+?)(?:\s|_)#(?\d+)", - MatchOptions, RegexTimeout), - // Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01 - new Regex( - @"^(?.+?)((c(hapter)?)|issue)(_|\s)#?(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", - MatchOptions, RegexTimeout), - // Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr - new Regex( - @"^(?.+?)(?:\s|_)(c? ?(chapter)?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", - MatchOptions, RegexTimeout), - // Batgirl Vol.2000 #57 (December, 2004) - new Regex( - @"^(?.+?)(?:vol\.?\d+)\s#(?\d+)", - MatchOptions, - RegexTimeout), - // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) - new Regex( - @"^(?.+?)(?: (?\d+))", - MatchOptions, RegexTimeout), - - // Saga 001 (2012) (Digital) (Empire-Zone) - new Regex( - @"(?.+?)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)\s\(\d{4}", - MatchOptions, RegexTimeout), - // Amazing Man Comics chapter 25 - new Regex( - @"^(?!Vol)(?.+?)( |_)c(hapter)( |_)(?\d*)", - MatchOptions, RegexTimeout), - // Amazing Man Comics issue #25 - new Regex( - @"^(?!Vol)(?.+?)( |_)i(ssue)( |_) #(?\d*)", - MatchOptions, RegexTimeout), - // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) - new Regex( - @"^(?.+?)-(chapter-)?(?\d+)", - MatchOptions, RegexTimeout), - - }; - - private static readonly Regex[] ReleaseGroupRegex = new[] - { - // [TrinityBAKumA Finella&anon], [BAA]_, [SlowManga&OverloadScans], [batoto] - new Regex(@"(?:\[(?(?!\s).+?(?(?!\s).+?(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", - MatchOptions, RegexTimeout), - // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip - new Regex( - @"v\d+\.(?\d+(?:.\d+|-\d+)?)", - MatchOptions, RegexTimeout), - // Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz (Rare case, if causes issue remove) - new Regex( - @"^(?.*)(?: |_)#(?\d+)", - MatchOptions, RegexTimeout), - // Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10 - new Regex( - @"^(?!Vol)(?.*)\s?(?\d+(?:\.?[\d-]+)?)", - MatchOptions, RegexTimeout), - // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz - new Regex( - @"^(?!Vol)(?.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", - MatchOptions, RegexTimeout), - // Tower Of God S01 014 (CBT) (digital).cbz - new Regex( - @"(?.*)\sS(?\d+)\s(?\d+(?:.\d+|-\d+)?)", - MatchOptions, RegexTimeout), - // Beelzebub_01_[Noodles].zip, Beelzebub_153b_RHS.zip - new Regex( - @"^((?!v|vo|vol|Volume).)*(\s|_)(?\.?\d+(?:.\d+|-\d+)?)(?b)?(\s|_|\[|\()", - MatchOptions, RegexTimeout), - // Yumekui-Merry_DKThias_Chapter21.zip - new Regex( - @"Chapter(?\d+(-\d+)?)", //(?:.\d+|-\d+)? - MatchOptions, RegexTimeout), - // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar - new Regex( - @"(?.*)(\s|_)(vol\d+)?(\s|_)Chp\.? ?(?\d+)", - MatchOptions, RegexTimeout), - // Vol 1 Chapter 2 - new Regex( - @"(?((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?\d+)", - MatchOptions, RegexTimeout), - // Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话 - new Regex( - @"第(?\d+)话", - MatchOptions, RegexTimeout), - // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 - new Regex( - @"제?(?\d+\.?\d+)(화|장)", - MatchOptions, RegexTimeout), - // Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話 - new Regex( - @"第?(?\d+(?:.\d+|-\d+)?)話", - MatchOptions, RegexTimeout), - }; - private static readonly Regex[] MangaEditionRegex = { - // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz - new Regex( - @"(\b|_)(?Omnibus(( |_)?Edition)?)(\b|_)?", - MatchOptions, RegexTimeout), - // To Love Ru v01 Uncensored (Ch.001-007) - new Regex( - @"(\b|_)(?Uncensored)(\b|_)", - MatchOptions, RegexTimeout), - }; - - private static readonly Regex[] CleanupRegex = - { - // (), {}, [] - new Regex( - @"(?(\{\}|\[\]|\(\)))", - MatchOptions, RegexTimeout), - // (Complete) - new Regex( - @"(?(\{Complete\}|\[Complete\]|\(Complete\)))", - MatchOptions, RegexTimeout), - // Anything in parenthesis - new Regex( - @"\(.*\)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout), + // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus), Aldebaran-Antares-t6 + new Regex( + @"^(?.+?)(?: |_|-)(v|t)\d+", + MatchOptions, RegexTimeout), + // Amazing Man Comics chapter 25 + new Regex( + @"^(?.+?)(?: |_)c(hapter) \d+", + MatchOptions, RegexTimeout), + // Amazing Man Comics issue #25 + new Regex( + @"^(?.+?)(?: |_)i(ssue) #\d+", + MatchOptions, RegexTimeout), + // Batman Wayne Family Adventures - Ep. 001 - Moving In + new Regex( + @"^(?.+?)(\s|_|-)(?:Ep\.?)(\s|_|-)+\d+", + MatchOptions, RegexTimeout), + // Batgirl Vol.2000 #57 (December, 2004) + new Regex( + @"^(?.+?)Vol\.?\s?#?(?:\d+)", + MatchOptions, RegexTimeout), + // Batman & Robin the Teen Wonder #0 + new Regex( + @"^(?.*)(?: |_)#\d+", + MatchOptions, RegexTimeout), + // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + new Regex( + @"^(?.+?)(?: \d+)", + MatchOptions, RegexTimeout), + // Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005) + new Regex( + @"^(?.+?)(?: |_)(?\d+)", + MatchOptions, RegexTimeout), + // The First Asterix Frieze (WebP by Doc MaKS) + new Regex( + @"^(?.*)(?: |_)(?!\(\d{4}|\d{4}-\d{2}\))\(", + MatchOptions, RegexTimeout), + // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) + new Regex( + @"^(?.+?)-(chapter-)?(?\d+)", + MatchOptions, RegexTimeout), + // MUST BE LAST: Batman & Daredevil - King of New York + new Regex( + @"^(?.*)", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] ComicVolumeRegex = new[] + { + // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + new Regex( + @"^(?.*)(?: |_)(t|v)(?\d+)", + MatchOptions, RegexTimeout), + // Batgirl Vol.2000 #57 (December, 2004) + new Regex( + @"^(?.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?\d+)", + MatchOptions, RegexTimeout), + // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 + new Regex( + @"第(?\d+)(卷|册)", + MatchOptions, RegexTimeout), + // Chinese Volume: 卷n -> Volume n, 册n -> Volume n + new Regex( + @"(卷|册)(?\d+)", + MatchOptions, RegexTimeout), + // Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip + new Regex( + @"제?(?\d+)권", + MatchOptions, RegexTimeout), + // Japanese Volume: n巻 -> Volume n + new Regex( + @"(?\d+(?:(\-)\d+)?)巻", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] ComicChapterRegex = new[] + { + // Batman & Wildcat (1 of 3) + new Regex( + @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", + MatchOptions, RegexTimeout), + // Batman Beyond 04 (of 6) (1999) + new Regex( + @"(?.+?)(?\d+)(\s|_|-)?\(of", + MatchOptions, RegexTimeout), + // Batman Beyond 2.0 001 (2013) + new Regex( + @"^(?.+?\S\.\d) (?\d+)", + MatchOptions, RegexTimeout), + // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + new Regex( + @"^(?.+?)(?: |_)v(?\d+)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)", + MatchOptions, RegexTimeout), + // Batman & Robin the Teen Wonder #0 + new Regex( + @"^(?.+?)(?:\s|_)#(?\d+)", + MatchOptions, RegexTimeout), + // Batman 2016 - Chapter 01, Batman 2016 - Issue 01, Batman 2016 - Issue #01 + new Regex( + @"^(?.+?)((c(hapter)?)|issue)(_|\s)#?(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", + MatchOptions, RegexTimeout), + // Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr + new Regex( + @"^(?.+?)(?:\s|_)(c? ?(chapter)?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", + MatchOptions, RegexTimeout), + // Batgirl Vol.2000 #57 (December, 2004) + new Regex( + @"^(?.+?)(?:vol\.?\d+)\s#(?\d+)", + MatchOptions, + RegexTimeout), + // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + new Regex( + @"^(?.+?)(?: (?\d+))", + MatchOptions, RegexTimeout), + + // Saga 001 (2012) (Digital) (Empire-Zone) + new Regex( + @"(?.+?)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)\s\(\d{4}", + MatchOptions, RegexTimeout), + // Amazing Man Comics chapter 25 + new Regex( + @"^(?!Vol)(?.+?)( |_)c(hapter)( |_)(?\d*)", + MatchOptions, RegexTimeout), + // Amazing Man Comics issue #25 + new Regex( + @"^(?!Vol)(?.+?)( |_)i(ssue)( |_) #(?\d*)", + MatchOptions, RegexTimeout), + // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) + new Regex( + @"^(?.+?)-(chapter-)?(?\d+)", + MatchOptions, RegexTimeout), + + }; + + private static readonly Regex[] ReleaseGroupRegex = new[] + { + // [TrinityBAKumA Finella&anon], [BAA]_, [SlowManga&OverloadScans], [batoto] + new Regex(@"(?:\[(?(?!\s).+?(?(?!\s).+?(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", + MatchOptions, RegexTimeout), + // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + new Regex( + @"v\d+\.(?\d+(?:.\d+|-\d+)?)", + MatchOptions, RegexTimeout), + // Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz (Rare case, if causes issue remove) + new Regex( + @"^(?.*)(?: |_)#(?\d+)", + MatchOptions, RegexTimeout), + // Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10 + new Regex( + @"^(?!Vol)(?.*)\s?(?\d+(?:\.?[\d-]+)?)", + MatchOptions, RegexTimeout), + // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz + new Regex( + @"^(?!Vol)(?.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", + MatchOptions, RegexTimeout), + // Tower Of God S01 014 (CBT) (digital).cbz + new Regex( + @"(?.*)\sS(?\d+)\s(?\d+(?:.\d+|-\d+)?)", + MatchOptions, RegexTimeout), + // Beelzebub_01_[Noodles].zip, Beelzebub_153b_RHS.zip + new Regex( + @"^((?!v|vo|vol|Volume).)*(\s|_)(?\.?\d+(?:.\d+|-\d+)?)(?b)?(\s|_|\[|\()", + MatchOptions, RegexTimeout), + // Yumekui-Merry_DKThias_Chapter21.zip + new Regex( + @"Chapter(?\d+(-\d+)?)", //(?:.\d+|-\d+)? + MatchOptions, RegexTimeout), + // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar + new Regex( + @"(?.*)(\s|_)(vol\d+)?(\s|_)Chp\.? ?(?\d+)", + MatchOptions, RegexTimeout), + // Vol 1 Chapter 2 + new Regex( + @"(?((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?\d+)", + MatchOptions, RegexTimeout), + // Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话 + new Regex( + @"第(?\d+)话", + MatchOptions, RegexTimeout), + // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 + new Regex( + @"제?(?\d+\.?\d+)(화|장)", + MatchOptions, RegexTimeout), + // Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話 + new Regex( + @"第?(?\d+(?:.\d+|-\d+)?)話", + MatchOptions, RegexTimeout), + }; + private static readonly Regex[] MangaEditionRegex = { + // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz + new Regex( + @"(\b|_)(?Omnibus(( |_)?Edition)?)(\b|_)?", + MatchOptions, RegexTimeout), + // To Love Ru v01 Uncensored (Ch.001-007) + new Regex( + @"(\b|_)(?Uncensored)(\b|_)", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] CleanupRegex = + { + // (), {}, [] + new Regex( + @"(?(\{\}|\[\]|\(\)))", + MatchOptions, RegexTimeout), + // (Complete) + new Regex( + @"(?(\{Complete\}|\[Complete\]|\(Complete\)))", + MatchOptions, RegexTimeout), + // Anything in parenthesis + new Regex( + @"\(.*\)", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] MangaSpecialRegex = + { + // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. + new Regex( + @"(?Specials?|OneShot|One\-Shot|Omake|Extra(?:(\sChapter)?[^\S])|Art Collection|Side( |_)Stories|Bonus)", + MatchOptions, RegexTimeout), + }; - private static readonly Regex[] MangaSpecialRegex = - { - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - new Regex( - @"(?Specials?|OneShot|One\-Shot|Omake|Extra(?:(\sChapter)?[^\S])|Art Collection|Side( |_)Stories|Bonus)", - MatchOptions, RegexTimeout), - }; + private static readonly Regex[] ComicSpecialRegex = + { + // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. + new Regex( + @"(?Specials?|OneShot|One\-Shot|\d.+?(\W|_|-)Annual|Annual(\W|_|-)\d.+?|Extra(?:(\sChapter)?[^\S])|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side(\s|_)Stories|Bonus|Hors Série|(\W|_|-)HS(\W|_|-)|(\W|_|-)THS(\W|_|-))", + MatchOptions, RegexTimeout), + }; - private static readonly Regex[] ComicSpecialRegex = - { - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - new Regex( - @"(?Specials?|OneShot|One\-Shot|\d.+?(\W|_|-)Annual|Annual(\W|_|-)\d.+?|Extra(?:(\sChapter)?[^\S])|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side(\s|_)Stories|Bonus|Hors Série|(\W|_|-)HS(\W|_|-)|(\W|_|-)THS(\W|_|-))", - MatchOptions, RegexTimeout), - }; + private static readonly Regex[] EuropeanComicRegex = + { + // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. + new Regex( + @"(?Bd(\s|_|-)Fr)", + MatchOptions, RegexTimeout), + }; + + // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. + private static readonly Regex SpecialMarkerRegex = new Regex( + @"(?SP\d+)", + MatchOptions, RegexTimeout + ); + + private static readonly Regex EmptySpaceRegex = new Regex( + @"(?!=.+)(\s{2,})(?!=.+)", + MatchOptions, RegexTimeout + ); + + private static readonly ImmutableArray FormatTagSpecialKeywords = ImmutableArray.Create( + "Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue", + "One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", + "GN", "FCBD"); + + private static readonly char[] LeadingZeroesTrimChars = new[] { '0' }; + + public static MangaFormat ParseFormat(string filePath) + { + if (IsArchive(filePath)) return MangaFormat.Archive; + if (IsImage(filePath)) return MangaFormat.Image; + if (IsEpub(filePath)) return MangaFormat.Epub; + if (IsPdf(filePath)) return MangaFormat.Pdf; + return MangaFormat.Unknown; + } - private static readonly Regex[] EuropeanComicRegex = + public static string ParseEdition(string filePath) + { + foreach (var regex in MangaEditionRegex) { - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - new Regex( - @"(?Bd(\s|_|-)Fr)", - MatchOptions, RegexTimeout), - }; - - // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. - private static readonly Regex SpecialMarkerRegex = new Regex( - @"(?SP\d+)", - MatchOptions, RegexTimeout - ); - - private static readonly Regex EmptySpaceRegex = new Regex( - @"(?!=.+)(\s{2,})(?!=.+)", - MatchOptions, RegexTimeout - ); - - private static readonly ImmutableArray FormatTagSpecialKeywords = ImmutableArray.Create( - "Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue", - "One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", - "GN", "FCBD"); + var matches = regex.Matches(filePath); + foreach (var group in matches.Select(match => match.Groups["Edition"]) + .Where(group => group.Success && group != Match.Empty)) + { + return group.Value + .Replace("{", "").Replace("}", "") + .Replace("[", "").Replace("]", "") + .Replace("(", "").Replace(")", ""); + } + } - private static readonly char[] LeadingZeroesTrimChars = new[] { '0' }; + return string.Empty; + } - public static MangaFormat ParseFormat(string filePath) - { - if (IsArchive(filePath)) return MangaFormat.Archive; - if (IsImage(filePath)) return MangaFormat.Image; - if (IsEpub(filePath)) return MangaFormat.Epub; - if (IsPdf(filePath)) return MangaFormat.Pdf; - return MangaFormat.Unknown; - } + /// + /// If the file has SP marker. + /// + /// + /// + public static bool HasSpecialMarker(string filePath) + { + var matches = SpecialMarkerRegex.Matches(filePath); + return matches.Select(match => match.Groups["Special"]) + .Any(group => group.Success && group != Match.Empty); + } - public static string ParseEdition(string filePath) + public static string ParseMangaSpecial(string filePath) + { + foreach (var regex in MangaSpecialRegex) { - foreach (var regex in MangaEditionRegex) + var matches = regex.Matches(filePath); + foreach (var group in matches.Select(match => match.Groups["Special"]) + .Where(group => group.Success && group != Match.Empty)) { - var matches = regex.Matches(filePath); - foreach (var group in matches.Select(match => match.Groups["Edition"]) - .Where(group => group.Success && group != Match.Empty)) - { - return group.Value - .Replace("{", "").Replace("}", "") - .Replace("[", "").Replace("]", "") - .Replace("(", "").Replace(")", ""); - } + return group.Value; } - - return string.Empty; } - /// - /// If the file has SP marker. - /// - /// - /// - public static bool HasSpecialMarker(string filePath) - { - var matches = SpecialMarkerRegex.Matches(filePath); - return matches.Select(match => match.Groups["Special"]) - .Any(group => group.Success && group != Match.Empty); - } + return string.Empty; + } - public static string ParseMangaSpecial(string filePath) + public static string ParseComicSpecial(string filePath) + { + foreach (var regex in ComicSpecialRegex) { - foreach (var regex in MangaSpecialRegex) + var matches = regex.Matches(filePath); + foreach (var group in matches.Select(match => match.Groups["Special"]) + .Where(group => group.Success && group != Match.Empty)) { - var matches = regex.Matches(filePath); - foreach (var group in matches.Select(match => match.Groups["Special"]) - .Where(group => group.Success && group != Match.Empty)) - { - return group.Value; - } + return group.Value; } - - return string.Empty; } - public static string ParseComicSpecial(string filePath) + return string.Empty; + } + + public static string ParseSeries(string filename) + { + foreach (var regex in MangaSeriesRegex) { - foreach (var regex in ComicSpecialRegex) + var matches = regex.Matches(filename); + foreach (var group in matches.Select(match => match.Groups["Series"]) + .Where(group => group.Success && group != Match.Empty)) { - var matches = regex.Matches(filePath); - foreach (var group in matches.Select(match => match.Groups["Special"]) - .Where(group => group.Success && group != Match.Empty)) - { - return group.Value; - } + return CleanTitle(group.Value); } - - return string.Empty; } - public static string ParseSeries(string filename) + return string.Empty; + } + public static string ParseComicSeries(string filename) + { + foreach (var regex in ComicSeriesRegex) { - foreach (var regex in MangaSeriesRegex) + var matches = regex.Matches(filename); + foreach (var group in matches.Select(match => match.Groups["Series"]) + .Where(group => group.Success && group != Match.Empty)) { - var matches = regex.Matches(filename); - foreach (var group in matches.Select(match => match.Groups["Series"]) - .Where(group => group.Success && group != Match.Empty)) - { - return CleanTitle(group.Value); - } + return CleanTitle(group.Value, true); } - - return string.Empty; } - public static string ParseComicSeries(string filename) - { - foreach (var regex in ComicSeriesRegex) - { - var matches = regex.Matches(filename); - foreach (var group in matches.Select(match => match.Groups["Series"]) - .Where(group => group.Success && group != Match.Empty)) - { - return CleanTitle(group.Value, true); - } - } - return string.Empty; - } + return string.Empty; + } - public static string ParseVolume(string filename) + public static string ParseVolume(string filename) + { + foreach (var regex in MangaVolumeRegex) { - foreach (var regex in MangaVolumeRegex) + var matches = regex.Matches(filename); + foreach (Match match in matches) { - var matches = regex.Matches(filename); - foreach (Match match in matches) - { - if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue; + if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue; - var value = match.Groups["Volume"].Value; - var hasPart = match.Groups["Part"].Success; - return FormatValue(value, hasPart); - } + var value = match.Groups["Volume"].Value; + var hasPart = match.Groups["Part"].Success; + return FormatValue(value, hasPart); } - - return DefaultVolume; } - public static string ParseComicVolume(string filename) + return DefaultVolume; + } + + public static string ParseComicVolume(string filename) + { + foreach (var regex in ComicVolumeRegex) { - foreach (var regex in ComicVolumeRegex) + var matches = regex.Matches(filename); + foreach (var group in matches.Select(match => match.Groups)) { - var matches = regex.Matches(filename); - foreach (var group in matches.Select(match => match.Groups)) - { - if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue; + if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue; - var value = group["Volume"].Value; - var hasPart = group["Part"].Success; - return FormatValue(value, hasPart); - } + var value = group["Volume"].Value; + var hasPart = group["Part"].Success; + return FormatValue(value, hasPart); } - - return DefaultVolume; } - private static string FormatValue(string value, bool hasPart) + return DefaultVolume; + } + + private static string FormatValue(string value, bool hasPart) + { + if (!value.Contains('-')) { - if (!value.Contains('-')) - { - return RemoveLeadingZeroes(hasPart ? AddChapterPart(value) : value); - } + return RemoveLeadingZeroes(hasPart ? AddChapterPart(value) : value); + } - var tokens = value.Split("-"); - var from = RemoveLeadingZeroes(tokens[0]); - if (tokens.Length != 2) return from; + var tokens = value.Split("-"); + var from = RemoveLeadingZeroes(tokens[0]); + if (tokens.Length != 2) return from; - var to = RemoveLeadingZeroes(hasPart ? AddChapterPart(tokens[1]) : tokens[1]); - return $"{from}-{to}"; - } + var to = RemoveLeadingZeroes(hasPart ? AddChapterPart(tokens[1]) : tokens[1]); + return $"{from}-{to}"; + } - public static string ParseChapter(string filename) + public static string ParseChapter(string filename) + { + foreach (var regex in MangaChapterRegex) { - foreach (var regex in MangaChapterRegex) + var matches = regex.Matches(filename); + foreach (Match match in matches) { - var matches = regex.Matches(filename); - foreach (Match match in matches) - { - if (!match.Groups["Chapter"].Success || match.Groups["Chapter"] == Match.Empty) continue; + if (!match.Groups["Chapter"].Success || match.Groups["Chapter"] == Match.Empty) continue; - var value = match.Groups["Chapter"].Value; - var hasPart = match.Groups["Part"].Success; + var value = match.Groups["Chapter"].Value; + var hasPart = match.Groups["Part"].Success; - return FormatValue(value, hasPart); - } + return FormatValue(value, hasPart); } - - return DefaultChapter; } - private static string AddChapterPart(string value) - { - if (value.Contains('.')) - { - return value; - } - - return $"{value}.5"; - } + return DefaultChapter; + } - public static string ParseComicChapter(string filename) + private static string AddChapterPart(string value) + { + if (value.Contains('.')) { - foreach (var regex in ComicChapterRegex) - { - var matches = regex.Matches(filename); - foreach (Match match in matches) - { - if (match.Groups["Chapter"].Success && match.Groups["Chapter"] != Match.Empty) - { - var value = match.Groups["Chapter"].Value; - var hasPart = match.Groups["Part"].Success; - return FormatValue(value, hasPart); - } - - } - } - - return DefaultChapter; + return value; } - private static string RemoveEditionTagHolders(string title) + return $"{value}.5"; + } + + public static string ParseComicChapter(string filename) + { + foreach (var regex in ComicChapterRegex) { - foreach (var regex in CleanupRegex) + var matches = regex.Matches(filename); + foreach (Match match in matches) { - var matches = regex.Matches(title); - foreach (Match match in matches) + if (match.Groups["Chapter"].Success && match.Groups["Chapter"] != Match.Empty) { - if (match.Success) - { - title = title.Replace(match.Value, string.Empty).Trim(); - } + var value = match.Groups["Chapter"].Value; + var hasPart = match.Groups["Part"].Success; + return FormatValue(value, hasPart); } - } - foreach (var regex in MangaEditionRegex) - { - var matches = regex.Matches(title); - foreach (Match match in matches) - { - if (match.Success) - { - title = title.Replace(match.Value, string.Empty).Trim(); - } - } } - - return title; } - private static string RemoveMangaSpecialTags(string title) + return DefaultChapter; + } + + private static string RemoveEditionTagHolders(string title) + { + foreach (var regex in CleanupRegex) { - foreach (var regex in MangaSpecialRegex) + var matches = regex.Matches(title); + foreach (Match match in matches) { - var matches = regex.Matches(title); - foreach (var match in matches.Where(m => m.Success)) + if (match.Success) { title = title.Replace(match.Value, string.Empty).Trim(); } } - - return title; } - private static string RemoveEuropeanTags(string title) + foreach (var regex in MangaEditionRegex) { - foreach (var regex in EuropeanComicRegex) + var matches = regex.Matches(title); + foreach (Match match in matches) { - var matches = regex.Matches(title); - foreach (var match in matches.Where(m => m.Success)) + if (match.Success) { title = title.Replace(match.Value, string.Empty).Trim(); } } - - return title; } - private static string RemoveComicSpecialTags(string title) + return title; + } + + private static string RemoveMangaSpecialTags(string title) + { + foreach (var regex in MangaSpecialRegex) { - foreach (var regex in ComicSpecialRegex) + var matches = regex.Matches(title); + foreach (var match in matches.Where(m => m.Success)) { - var matches = regex.Matches(title); - foreach (var match in matches.Where(m => m.Success)) - { - title = title.Replace(match.Value, string.Empty).Trim(); - } + title = title.Replace(match.Value, string.Empty).Trim(); } - - return title; } + return title; + } - - /// - /// Translates _ -> spaces, trims front and back of string, removes release groups - /// - /// Hippos_the_Great [Digital], -> Hippos the Great - /// - /// - /// - /// - /// - public static string CleanTitle(string title, bool isComic = false) + private static string RemoveEuropeanTags(string title) + { + foreach (var regex in EuropeanComicRegex) { - title = RemoveReleaseGroup(title); - - title = RemoveEditionTagHolders(title); - - title = isComic ? RemoveComicSpecialTags(title) : RemoveMangaSpecialTags(title); - - if (isComic) - { - title = RemoveComicSpecialTags(title); - title = RemoveEuropeanTags(title); - } - else + var matches = regex.Matches(title); + foreach (var match in matches.Where(m => m.Success)) { - title = RemoveMangaSpecialTags(title); + title = title.Replace(match.Value, string.Empty).Trim(); } + } + return title; + } - title = title.Replace("_", " ").Trim(); - if (title.EndsWith("-") || title.EndsWith(",")) + private static string RemoveComicSpecialTags(string title) + { + foreach (var regex in ComicSpecialRegex) + { + var matches = regex.Matches(title); + foreach (var match in matches.Where(m => m.Success)) { - title = title.Substring(0, title.Length - 1); + title = title.Replace(match.Value, string.Empty).Trim(); } + } - if (title.StartsWith("-") || title.StartsWith(",")) - { - title = title.Substring(1); - } + return title; + } - title = EmptySpaceRegex.Replace(title, " "); - return title.Trim(); - } - private static string RemoveReleaseGroup(string title) - { - foreach (var regex in ReleaseGroupRegex) - { - var matches = regex.Matches(title); - foreach (var match in matches.Where(m => m.Success)) - { - title = title.Replace(match.Value, string.Empty); - } - } + /// + /// Translates _ -> spaces, trims front and back of string, removes release groups + /// + /// Hippos_the_Great [Digital], -> Hippos the Great + /// + /// + /// + /// + /// + public static string CleanTitle(string title, bool isComic = false) + { + title = RemoveReleaseGroup(title); - return title; - } + title = RemoveEditionTagHolders(title); + title = isComic ? RemoveComicSpecialTags(title) : RemoveMangaSpecialTags(title); - /// - /// Pads the start of a number string with 0's so ordering works fine if there are over 100 items. - /// Handles ranges (ie 4-8) -> (004-008). - /// - /// - /// A zero padded number - public static string PadZeros(string number) + if (isComic) { - if (!number.Contains('-')) return PerformPadding(number); - - var tokens = number.Split("-"); - return $"{PerformPadding(tokens[0])}-{PerformPadding(tokens[1])}"; + title = RemoveComicSpecialTags(title); + title = RemoveEuropeanTags(title); } - - private static string PerformPadding(string number) + else { - var num = int.Parse(number); - return num switch - { - < 10 => "00" + num, - < 100 => "0" + num, - _ => number - }; + title = RemoveMangaSpecialTags(title); } - public static string RemoveLeadingZeroes(string title) - { - var ret = title.TrimStart(LeadingZeroesTrimChars); - return string.IsNullOrEmpty(ret) ? "0" : ret; - } - public static bool IsArchive(string filePath) - { - return ArchiveFileRegex.IsMatch(Path.GetExtension(filePath)); - } - public static bool IsComicInfoExtension(string filePath) - { - return ComicInfoArchiveRegex.IsMatch(Path.GetExtension(filePath)); - } - public static bool IsBook(string filePath) + title = title.Replace("_", " ").Trim(); + if (title.EndsWith("-") || title.EndsWith(",")) { - return BookFileRegex.IsMatch(Path.GetExtension(filePath)); + title = title.Substring(0, title.Length - 1); } - public static bool IsImage(string filePath) + if (title.StartsWith("-") || title.StartsWith(",")) { - return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath)); + title = title.Substring(1); } - public static bool IsXml(string filePath) - { - return XmlRegex.IsMatch(Path.GetExtension(filePath)); - } + title = EmptySpaceRegex.Replace(title, " "); + return title.Trim(); + } - public static float MinNumberFromRange(string range) + private static string RemoveReleaseGroup(string title) + { + foreach (var regex in ReleaseGroupRegex) { - try - { - if (!Regex.IsMatch(range, @"^[\d-.]+$")) - { - return (float) 0.0; - } - - var tokens = range.Replace("_", string.Empty).Split("-"); - return tokens.Min(float.Parse); - } - catch + var matches = regex.Matches(title); + foreach (var match in matches.Where(m => m.Success)) { - return (float) 0.0; + title = title.Replace(match.Value, string.Empty); } } - public static float MaxNumberFromRange(string range) + return title; + } + + + /// + /// Pads the start of a number string with 0's so ordering works fine if there are over 100 items. + /// Handles ranges (ie 4-8) -> (004-008). + /// + /// + /// A zero padded number + public static string PadZeros(string number) + { + if (!number.Contains('-')) return PerformPadding(number); + + var tokens = number.Split("-"); + return $"{PerformPadding(tokens[0])}-{PerformPadding(tokens[1])}"; + } + + private static string PerformPadding(string number) + { + var num = int.Parse(number); + return num switch { - try - { - if (!Regex.IsMatch(range, @"^[\d-.]+$")) - { - return (float) 0.0; - } + < 10 => "00" + num, + < 100 => "0" + num, + _ => number + }; + } - var tokens = range.Replace("_", string.Empty).Split("-"); - return tokens.Max(float.Parse); - } - catch + public static string RemoveLeadingZeroes(string title) + { + var ret = title.TrimStart(LeadingZeroesTrimChars); + return string.IsNullOrEmpty(ret) ? "0" : ret; + } + + public static bool IsArchive(string filePath) + { + return ArchiveFileRegex.IsMatch(Path.GetExtension(filePath)); + } + public static bool IsComicInfoExtension(string filePath) + { + return ComicInfoArchiveRegex.IsMatch(Path.GetExtension(filePath)); + } + public static bool IsBook(string filePath) + { + return BookFileRegex.IsMatch(Path.GetExtension(filePath)); + } + + public static bool IsImage(string filePath) + { + return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath)); + } + + public static bool IsXml(string filePath) + { + return XmlRegex.IsMatch(Path.GetExtension(filePath)); + } + + + public static float MinNumberFromRange(string range) + { + try + { + if (!Regex.IsMatch(range, @"^[\d-.]+$")) { return (float) 0.0; } - } - public static string Normalize(string name) + var tokens = range.Replace("_", string.Empty).Split("-"); + return tokens.Min(float.Parse); + } + catch { - return NormalizeRegex.Replace(name, string.Empty).ToLower(); + return (float) 0.0; } + } - /// - /// Responsible for preparing special title for rendering to the UI. Replaces _ with ' ' and strips out SP\d+ - /// - /// - /// - public static string CleanSpecialTitle(string name) + public static float MaxNumberFromRange(string range) + { + try { - if (string.IsNullOrEmpty(name)) return name; - var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); - var lastIndex = cleaned.LastIndexOf('.'); - if (lastIndex > 0) + if (!Regex.IsMatch(range, @"^[\d-.]+$")) { - cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim(); + return (float) 0.0; } - return string.IsNullOrEmpty(cleaned) ? name : cleaned; + var tokens = range.Replace("_", string.Empty).Split("-"); + return tokens.Max(float.Parse); } - - - /// - /// Tests whether the file is a cover image such that: contains "cover", is named "folder", and is an image - /// - /// If the path has "backcover" in it, it will be ignored - /// Filename with extension - /// - public static bool IsCoverImage(string filename) + catch { - return IsImage(filename) && CoverImageRegex.IsMatch(filename); + return (float) 0.0; } + } - /// - /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc and that if a full path, the filename - /// doesn't start with ._, which is a metadata file on MACOSX. - /// - /// - /// - public static bool HasBlacklistedFolderInPath(string path) + public static string Normalize(string name) + { + return NormalizeRegex.Replace(name, string.Empty).ToLower(); + } + + /// + /// Responsible for preparing special title for rendering to the UI. Replaces _ with ' ' and strips out SP\d+ + /// + /// + /// + public static string CleanSpecialTitle(string name) + { + if (string.IsNullOrEmpty(name)) return name; + var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); + var lastIndex = cleaned.LastIndexOf('.'); + if (lastIndex > 0) { - return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg"); + cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim(); } + return string.IsNullOrEmpty(cleaned) ? name : cleaned; + } - public static bool IsEpub(string filePath) - { - return Path.GetExtension(filePath).Equals(".epub", StringComparison.InvariantCultureIgnoreCase); - } - public static bool IsPdf(string filePath) - { - return Path.GetExtension(filePath).Equals(".pdf", StringComparison.InvariantCultureIgnoreCase); - } + /// + /// Tests whether the file is a cover image such that: contains "cover", is named "folder", and is an image + /// + /// If the path has "backcover" in it, it will be ignored + /// Filename with extension + /// + public static bool IsCoverImage(string filename) + { + return IsImage(filename) && CoverImageRegex.IsMatch(filename); + } - /// - /// Cleans an author's name - /// - /// If the author is Last, First, this will not reverse - /// - /// - public static string CleanAuthor(string author) - { - return string.IsNullOrEmpty(author) ? string.Empty : author.Trim(); - } + /// + /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc and that if a full path, the filename + /// doesn't start with ._, which is a metadata file on MACOSX. + /// + /// + /// + public static bool HasBlacklistedFolderInPath(string path) + { + return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg"); + } - /// - /// 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) - .Replace(@"//", Path.AltDirectorySeparatorChar + string.Empty); - } - /// - /// Checks against a set of strings to validate if a ComicInfo.Format should receive special treatment - /// - /// - /// - public static bool HasComicInfoSpecial(string comicInfoFormat) - { - return FormatTagSpecialKeywords.Contains(comicInfoFormat); - } + public static bool IsEpub(string filePath) + { + return Path.GetExtension(filePath).Equals(".epub", StringComparison.InvariantCultureIgnoreCase); + } + + public static bool IsPdf(string filePath) + { + return Path.GetExtension(filePath).Equals(".pdf", StringComparison.InvariantCultureIgnoreCase); + } + + /// + /// Cleans an author's name + /// + /// If the author is Last, First, this will not reverse + /// + /// + public static string CleanAuthor(string author) + { + return string.IsNullOrEmpty(author) ? string.Empty : 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) + .Replace(@"//", Path.AltDirectorySeparatorChar + string.Empty); + } + + /// + /// Checks against a set of strings to validate if a ComicInfo.Format should receive special treatment + /// + /// + /// + public static bool HasComicInfoSpecial(string comicInfoFormat) + { + return FormatTagSpecialKeywords.Contains(comicInfoFormat); } } diff --git a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs index 4a0a3fdc68..1f0a9d6928 100644 --- a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs +++ b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs @@ -2,100 +2,99 @@ using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; -namespace API.Parser +namespace API.Parser; + +/// +/// This represents all parsed information from a single file +/// +public class ParserInfo { /// - /// This represents all parsed information from a single file + /// Represents the parsed chapters from a file. By default, will be 0 which means nothing could be parsed. + /// The chapters can only be a single float or a range of float ie) 1-2. Mainly floats should be multiples of 0.5 representing specials /// - public class ParserInfo - { - /// - /// Represents the parsed chapters from a file. By default, will be 0 which means nothing could be parsed. - /// The chapters can only be a single float or a range of float ie) 1-2. Mainly floats should be multiples of 0.5 representing specials - /// - public string Chapters { get; set; } = ""; - /// - /// Represents the parsed series from the file or folder - /// - public string Series { get; set; } = string.Empty; - /// - /// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the SortName field on - /// - public string SeriesSort { get; set; } = string.Empty; - /// - /// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the LocalizedName field on - /// - public string LocalizedSeries { get; set; } = string.Empty; - /// - /// Represents the parsed volumes from a file. By default, will be 0 which means that nothing could be parsed. - /// If Volumes is 0 and Chapters is 0, the file is a special. If Chapters is non-zero, then no volume could be parsed. - /// Beastars Vol 3-4 will map to "3-4" - /// The volumes can only be a single int or a range of ints ie) 1-2. Float based volumes are not supported. - /// - public string Volumes { get; set; } = ""; - /// - /// Filename of the underlying file - /// Beastars v01 (digital).cbz - /// - public string Filename { get; init; } = ""; - /// - /// Full filepath of the underlying file - /// C:/Manga/Beastars v01 (digital).cbz - /// - public string FullFilePath { get; set; } = ""; + public string Chapters { get; set; } = ""; + /// + /// Represents the parsed series from the file or folder + /// + public string Series { get; set; } = string.Empty; + /// + /// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the SortName field on + /// + public string SeriesSort { get; set; } = string.Empty; + /// + /// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the LocalizedName field on + /// + public string LocalizedSeries { get; set; } = string.Empty; + /// + /// Represents the parsed volumes from a file. By default, will be 0 which means that nothing could be parsed. + /// If Volumes is 0 and Chapters is 0, the file is a special. If Chapters is non-zero, then no volume could be parsed. + /// Beastars Vol 3-4 will map to "3-4" + /// The volumes can only be a single int or a range of ints ie) 1-2. Float based volumes are not supported. + /// + public string Volumes { get; set; } = ""; + /// + /// Filename of the underlying file + /// Beastars v01 (digital).cbz + /// + public string Filename { get; init; } = ""; + /// + /// Full filepath of the underlying file + /// C:/Manga/Beastars v01 (digital).cbz + /// + public string FullFilePath { get; set; } = ""; - /// - /// that represents the type of the file - /// Mainly used to show in the UI and so caching service knows how to cache for reading. - /// - public MangaFormat Format { get; set; } = MangaFormat.Unknown; + /// + /// that represents the type of the file + /// Mainly used to show in the UI and so caching service knows how to cache for reading. + /// + public MangaFormat Format { get; set; } = MangaFormat.Unknown; - /// - /// This can potentially story things like "Omnibus, Color, Full Contact Edition, Extra, Final, etc" - /// - /// Not Used in Database - public string Edition { get; set; } = ""; + /// + /// This can potentially story things like "Omnibus, Color, Full Contact Edition, Extra, Final, etc" + /// + /// Not Used in Database + public string Edition { get; set; } = ""; - /// - /// If the file contains no volume/chapter information or contains Special Keywords - /// - public bool IsSpecial { get; set; } + /// + /// If the file contains no volume/chapter information or contains Special Keywords + /// + public bool IsSpecial { get; set; } - /// - /// Used for specials or books, stores what the UI should show. - /// Manga does not use this field - /// - public string Title { get; set; } = string.Empty; + /// + /// Used for specials or books, stores what the UI should show. + /// Manga does not use this field + /// + public string Title { get; set; } = string.Empty; - /// - /// If the ParserInfo has the IsSpecial tag or both volumes and chapters are default aka 0 - /// - /// - public bool IsSpecialInfo() - { - return (IsSpecial || (Volumes == "0" && Chapters == "0")); - } + /// + /// If the ParserInfo has the IsSpecial tag or both volumes and chapters are default aka 0 + /// + /// + public bool IsSpecialInfo() + { + return (IsSpecial || (Volumes == "0" && Chapters == "0")); + } - /// - /// This will contain any EXTRA comicInfo information parsed from the epub or archive. If there is an archive with comicInfo.xml AND it contains - /// series, volume information, that will override what we parsed. - /// - public ComicInfo ComicInfo { get; set; } + /// + /// This will contain any EXTRA comicInfo information parsed from the epub or archive. If there is an archive with comicInfo.xml AND it contains + /// series, volume information, that will override what we parsed. + /// + public ComicInfo ComicInfo { get; set; } - /// - /// Merges non empty/null properties from info2 into this entity. - /// - /// This does not merge ComicInfo as they should always be the same - /// - public void Merge(ParserInfo info2) - { - if (info2 == null) return; - Chapters = string.IsNullOrEmpty(Chapters) || Chapters == "0" ? info2.Chapters: Chapters; - Volumes = string.IsNullOrEmpty(Volumes) || Volumes == "0" ? info2.Volumes : Volumes; - Edition = string.IsNullOrEmpty(Edition) ? info2.Edition : Edition; - Title = string.IsNullOrEmpty(Title) ? info2.Title : Title; - Series = string.IsNullOrEmpty(Series) ? info2.Series : Series; - IsSpecial = IsSpecial || info2.IsSpecial; - } + /// + /// Merges non empty/null properties from info2 into this entity. + /// + /// This does not merge ComicInfo as they should always be the same + /// + public void Merge(ParserInfo info2) + { + if (info2 == null) return; + Chapters = string.IsNullOrEmpty(Chapters) || Chapters == "0" ? info2.Chapters: Chapters; + Volumes = string.IsNullOrEmpty(Volumes) || Volumes == "0" ? info2.Volumes : Volumes; + Edition = string.IsNullOrEmpty(Edition) ? info2.Edition : Edition; + Title = string.IsNullOrEmpty(Title) ? info2.Title : Title; + Series = string.IsNullOrEmpty(Series) ? info2.Series : Series; + IsSpecial = IsSpecial || info2.IsSpecial; } } diff --git a/API/SignalR/EventHub.cs b/API/SignalR/EventHub.cs index 3beba3d24a..1f7790581a 100644 --- a/API/SignalR/EventHub.cs +++ b/API/SignalR/EventHub.cs @@ -56,4 +56,5 @@ public async Task SendMessageToAsync(string method, SignalRMessage message, int var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); await _messageHub.Clients.User(user.UserName).SendAsync(method, message); } + } diff --git a/API/SignalR/LogHub.cs b/API/SignalR/LogHub.cs new file mode 100644 index 0000000000..15a30afdb6 --- /dev/null +++ b/API/SignalR/LogHub.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using API.Extensions; +using API.SignalR.Presence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace API.SignalR; + +public interface ILogHub : Serilog.Sinks.AspNetCore.SignalR.Interfaces.IHub +{ +} + +[Authorize] +public class LogHub : Hub +{ + private readonly IEventHub _eventHub; + private readonly IPresenceTracker _tracker; + + public LogHub(IEventHub eventHub, IPresenceTracker tracker) + { + _eventHub = eventHub; + _tracker = tracker; + } + + + public override async Task OnConnectedAsync() + { + await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); + await base.OnDisconnectedAsync(exception); + } + + public async Task SendLogAsString(string message) + { + await _eventHub.SendMessageAsync("LogString", new SignalRMessage() + { + Body = message, + EventType = "LogString", + Name = "LogString", + }, true); + } + + public async Task SendLogAsObject(object messageObject) + { + await _eventHub.SendMessageAsync("LogObject", new SignalRMessage() + { + Body = messageObject, + EventType = "LogString", + Name = "LogString", + }, true); + } +} diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 74ee4cc0f8..1784b17adb 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -6,476 +6,475 @@ using API.Entities; using API.Extensions; -namespace API.SignalR +namespace API.SignalR; + +public static class MessageFactoryEntityTypes { - public static class MessageFactoryEntityTypes - { - public const string Series = "series"; - public const string Volume = "volume"; - public const string Chapter = "chapter"; - public const string CollectionTag = "collection"; - public const string ReadingList = "readingList"; - } - public static class MessageFactory - { - /// - /// An update is available for the Kavita instance - /// - public const string UpdateAvailable = "UpdateAvailable"; - /// - /// Used to tell when a scan series completes. This also informs UI to update series metadata - /// - public const string ScanSeries = "ScanSeries"; - /// - /// Event sent out during Refresh Metadata for progress tracking - /// - private const string CoverUpdateProgress = "CoverUpdateProgress"; - /// - /// Series is added to server - /// - public const string SeriesAdded = "SeriesAdded"; - /// - /// Series is removed from server - /// - public const string SeriesRemoved = "SeriesRemoved"; - /// - /// When a user is connects/disconnects from server - /// - public const string OnlineUsers = "OnlineUsers"; - /// - /// When a series is added to a collection - /// - public const string SeriesAddedToCollection = "SeriesAddedToCollection"; - /// - /// Event sent out during backing up the database - /// - private const string BackupDatabaseProgress = "BackupDatabaseProgress"; - /// - /// Event sent out during cleaning up temp and cache folders - /// - private const string CleanupProgress = "CleanupProgress"; - /// - /// Event sent out during downloading of files - /// - private const string DownloadProgress = "DownloadProgress"; - /// - /// A cover was updated - /// - public const string CoverUpdate = "CoverUpdate"; - /// - /// A custom site theme was removed or added - /// - private const string SiteThemeProgress = "SiteThemeProgress"; - /// - /// A custom book theme was removed or added - /// - private const string BookThemeProgress = "BookThemeProgress"; - /// - /// A type of event that has progress (determinate or indeterminate). - /// The underlying event will have a name to give details on how to handle. - /// - /// This is not an Event Name, it is used as the method only - public const string NotificationProgress = "NotificationProgress"; - /// - /// Event sent out when Scan Loop is parsing a file - /// - private const string FileScanProgress = "FileScanProgress"; - /// - /// A generic error that can occur in background processing - /// - public const string Error = "Error"; - /// - /// When DB updates are occuring during a library/series scan - /// - private const string ScanProgress = "ScanProgress"; - /// - /// When a library is created/deleted in the Server - /// - public const string LibraryModified = "LibraryModified"; - /// - /// A user's progress was modified - /// - public const string UserProgressUpdate = "UserProgressUpdate"; - /// - /// A user's account or preferences were updated and UI needs to refresh to stay in sync - /// - public const string UserUpdate = "UserUpdate"; - /// - /// When bulk bookmarks are being converted - /// - private const string ConvertBookmarksProgress = "ConvertBookmarksProgress"; - /// - /// When files are being scanned to calculate word count - /// - private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress"; - /// - /// A generic message that can occur in background processing to inform user, but no direct action is needed - /// - public const string Info = "Info"; + public const string Series = "series"; + public const string Volume = "volume"; + public const string Chapter = "chapter"; + public const string CollectionTag = "collection"; + public const string ReadingList = "readingList"; +} +public static class MessageFactory +{ + /// + /// An update is available for the Kavita instance + /// + public const string UpdateAvailable = "UpdateAvailable"; + /// + /// Used to tell when a scan series completes. This also informs UI to update series metadata + /// + public const string ScanSeries = "ScanSeries"; + /// + /// Event sent out during Refresh Metadata for progress tracking + /// + private const string CoverUpdateProgress = "CoverUpdateProgress"; + /// + /// Series is added to server + /// + public const string SeriesAdded = "SeriesAdded"; + /// + /// Series is removed from server + /// + public const string SeriesRemoved = "SeriesRemoved"; + /// + /// When a user is connects/disconnects from server + /// + public const string OnlineUsers = "OnlineUsers"; + /// + /// When a series is added to a collection + /// + public const string SeriesAddedToCollection = "SeriesAddedToCollection"; + /// + /// Event sent out during backing up the database + /// + private const string BackupDatabaseProgress = "BackupDatabaseProgress"; + /// + /// Event sent out during cleaning up temp and cache folders + /// + private const string CleanupProgress = "CleanupProgress"; + /// + /// Event sent out during downloading of files + /// + private const string DownloadProgress = "DownloadProgress"; + /// + /// A cover was updated + /// + public const string CoverUpdate = "CoverUpdate"; + /// + /// A custom site theme was removed or added + /// + private const string SiteThemeProgress = "SiteThemeProgress"; + /// + /// A custom book theme was removed or added + /// + private const string BookThemeProgress = "BookThemeProgress"; + /// + /// A type of event that has progress (determinate or indeterminate). + /// The underlying event will have a name to give details on how to handle. + /// + /// This is not an Event Name, it is used as the method only + public const string NotificationProgress = "NotificationProgress"; + /// + /// Event sent out when Scan Loop is parsing a file + /// + private const string FileScanProgress = "FileScanProgress"; + /// + /// A generic error that can occur in background processing + /// + public const string Error = "Error"; + /// + /// When DB updates are occuring during a library/series scan + /// + private const string ScanProgress = "ScanProgress"; + /// + /// When a library is created/deleted in the Server + /// + public const string LibraryModified = "LibraryModified"; + /// + /// A user's progress was modified + /// + public const string UserProgressUpdate = "UserProgressUpdate"; + /// + /// A user's account or preferences were updated and UI needs to refresh to stay in sync + /// + public const string UserUpdate = "UserUpdate"; + /// + /// When bulk bookmarks are being converted + /// + private const string ConvertBookmarksProgress = "ConvertBookmarksProgress"; + /// + /// When files are being scanned to calculate word count + /// + private const string WordCountAnalyzerProgress = "WordCountAnalyzerProgress"; + /// + /// A generic message that can occur in background processing to inform user, but no direct action is needed + /// + public const string Info = "Info"; - public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName) + public static SignalRMessage ScanSeriesEvent(int libraryId, int seriesId, string seriesName) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = ScanSeries, + EventType = ProgressEventType.Single, + Body = new { - Name = ScanSeries, - EventType = ProgressEventType.Single, - Body = new - { - LibraryId = libraryId, - SeriesId = seriesId, - SeriesName = seriesName - } - }; - } + LibraryId = libraryId, + SeriesId = seriesId, + SeriesName = seriesName + } + }; + } - public static SignalRMessage SeriesAddedEvent(int seriesId, string seriesName, int libraryId) + public static SignalRMessage SeriesAddedEvent(int seriesId, string seriesName, int libraryId) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = SeriesAdded, + Body = new { - Name = SeriesAdded, - Body = new - { - SeriesId = seriesId, - SeriesName = seriesName, - LibraryId = libraryId - } - }; - } + SeriesId = seriesId, + SeriesName = seriesName, + LibraryId = libraryId + } + }; + } - public static SignalRMessage SeriesRemovedEvent(int seriesId, string seriesName, int libraryId) + public static SignalRMessage SeriesRemovedEvent(int seriesId, string seriesName, int libraryId) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = SeriesRemoved, + Body = new { - Name = SeriesRemoved, - Body = new - { - SeriesId = seriesId, - SeriesName = seriesName, - LibraryId = libraryId - } - }; - } + SeriesId = seriesId, + SeriesName = seriesName, + LibraryId = libraryId + } + }; + } - public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") + public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") + { + return new SignalRMessage() { - return new SignalRMessage() + Name = WordCountAnalyzerProgress, + Title = "Analyzing Word count", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new { - Name = WordCountAnalyzerProgress, - Title = "Analyzing Word count", - SubTitle = subtitle, - EventType = eventType, - Progress = ProgressType.Determinate, - Body = new - { - LibraryId = libraryId, - Progress = progress, - EventTime = DateTime.Now - } - }; - } + LibraryId = libraryId, + Progress = progress, + EventTime = DateTime.Now + } + }; + } - public static SignalRMessage CoverUpdateProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") + public static SignalRMessage CoverUpdateProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") + { + return new SignalRMessage() { - return new SignalRMessage() + Name = CoverUpdateProgress, + Title = "Refreshing Covers", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new { - Name = CoverUpdateProgress, - Title = "Refreshing Covers", - SubTitle = subtitle, - EventType = eventType, - Progress = ProgressType.Determinate, - Body = new - { - LibraryId = libraryId, - Progress = progress, - EventTime = DateTime.Now - } - }; - } + LibraryId = libraryId, + Progress = progress, + EventTime = DateTime.Now + } + }; + } - public static SignalRMessage BackupDatabaseProgressEvent(float progress, string subtitle = "") + public static SignalRMessage BackupDatabaseProgressEvent(float progress, string subtitle = "") + { + return new SignalRMessage() { - return new SignalRMessage() + Name = BackupDatabaseProgress, + Title = "Backing up Database", + SubTitle = subtitle, + EventType = progress switch { - Name = BackupDatabaseProgress, - Title = "Backing up Database", - SubTitle = subtitle, - EventType = progress switch - { - 0f => "started", - 1f => "ended", - _ => "updated" - }, - Progress = ProgressType.Determinate, - Body = new - { - Progress = progress - } - }; - } - public static SignalRMessage CleanupProgressEvent(float progress, string subtitle = "") + 0f => "started", + 1f => "ended", + _ => "updated" + }, + Progress = ProgressType.Determinate, + Body = new + { + Progress = progress + } + }; + } + public static SignalRMessage CleanupProgressEvent(float progress, string subtitle = "") + { + return new SignalRMessage() { - return new SignalRMessage() + Name = CleanupProgress, + Title = "Performing Cleanup", + SubTitle = subtitle, + EventType = progress switch { - Name = CleanupProgress, - Title = "Performing Cleanup", - SubTitle = subtitle, - EventType = progress switch - { - 0f => "started", - 1f => "ended", - _ => "updated" - }, - Progress = ProgressType.Determinate, - Body = new - { - Progress = progress - } - }; - } + 0f => "started", + 1f => "ended", + _ => "updated" + }, + Progress = ProgressType.Determinate, + Body = new + { + Progress = progress + } + }; + } - public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update) + public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update) + { + return new SignalRMessage { - return new SignalRMessage - { - Name = UpdateAvailable, - Title = "Update Available", - SubTitle = update.UpdateTitle, - EventType = ProgressEventType.Single, - Progress = ProgressType.None, - Body = update - }; - } + Name = UpdateAvailable, + Title = "Update Available", + SubTitle = update.UpdateTitle, + EventType = ProgressEventType.Single, + Progress = ProgressType.None, + Body = update + }; + } - public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId) + public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId) + { + return new SignalRMessage { - return new SignalRMessage + Name = SeriesAddedToCollection, + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new { - Name = SeriesAddedToCollection, - Progress = ProgressType.None, - EventType = ProgressEventType.Single, - Body = new - { - TagId = tagId, - SeriesId = seriesId - } - }; - } + TagId = tagId, + SeriesId = seriesId + } + }; + } - public static SignalRMessage ErrorEvent(string title, string subtitle) + public static SignalRMessage ErrorEvent(string title, string subtitle) + { + return new SignalRMessage { - return new SignalRMessage + Name = Error, + Title = title, + SubTitle = subtitle, + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new { - Name = Error, Title = title, SubTitle = subtitle, - Progress = ProgressType.None, - EventType = ProgressEventType.Single, - Body = new - { - Title = title, - SubTitle = subtitle, - } - }; - } + } + }; + } - public static SignalRMessage InfoEvent(string title, string subtitle) + public static SignalRMessage InfoEvent(string title, string subtitle) + { + return new SignalRMessage { - return new SignalRMessage + Name = Info, + Title = title, + SubTitle = subtitle, + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new { - Name = Info, Title = title, SubTitle = subtitle, - Progress = ProgressType.None, - EventType = ProgressEventType.Single, - Body = new - { - Title = title, - SubTitle = subtitle, - } - }; - } + } + }; + } - public static SignalRMessage LibraryModifiedEvent(int libraryId, string action) + public static SignalRMessage LibraryModifiedEvent(int libraryId, string action) + { + return new SignalRMessage { - return new SignalRMessage + Name = LibraryModified, + Title = "Library modified", + Progress = ProgressType.None, + EventType = ProgressEventType.Single, + Body = new { - Name = LibraryModified, - Title = "Library modified", - Progress = ProgressType.None, - EventType = ProgressEventType.Single, - Body = new - { - LibrayId = libraryId, - Action = action, - } - }; - } + LibrayId = libraryId, + Action = action, + } + }; + } - public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress, string eventType = "updated") + public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress, string eventType = "updated") + { + return new SignalRMessage() { - return new SignalRMessage() + Name = DownloadProgress, + Title = $"Downloading {downloadName}", + SubTitle = $"Preparing {username.SentenceCase()} the download of {downloadName}", + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new { - Name = DownloadProgress, - Title = $"Downloading {downloadName}", - SubTitle = $"Preparing {username.SentenceCase()} the download of {downloadName}", - EventType = eventType, - Progress = ProgressType.Determinate, - Body = new - { - UserName = username, - DownloadName = downloadName, - Progress = progress - } - }; - } + UserName = username, + DownloadName = downloadName, + Progress = progress + } + }; + } - /// - /// Represents a file being scanned by Kavita for processing and grouping - /// - /// Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate - /// - /// - /// - /// - public static SignalRMessage FileScanProgressEvent(string folderPath, string libraryName, string eventType) + /// + /// Represents a file being scanned by Kavita for processing and grouping + /// + /// Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate + /// + /// + /// + /// + public static SignalRMessage FileScanProgressEvent(string folderPath, string libraryName, string eventType) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = FileScanProgress, + Title = $"Scanning {libraryName}", + SubTitle = folderPath, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new { - Name = FileScanProgress, Title = $"Scanning {libraryName}", - SubTitle = folderPath, - EventType = eventType, - Progress = ProgressType.Indeterminate, - Body = new - { - Title = $"Scanning {libraryName}", - Subtitle = folderPath, - Filename = folderPath, - EventTime = DateTime.Now, - } - }; - } + Subtitle = folderPath, + Filename = folderPath, + EventTime = DateTime.Now, + } + }; + } - /// - /// This informs the UI with details about what is being processed by the Scanner - /// - /// - /// - /// - /// - public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "") + /// + /// This informs the UI with details about what is being processed by the Scanner + /// + /// + /// + /// + /// + public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "") + { + return new SignalRMessage() { - return new SignalRMessage() - { - Name = ScanProgress, - Title = $"Processing {seriesName}", - SubTitle = seriesName, - EventType = eventType, - Progress = ProgressType.Indeterminate, - Body = null - }; - } + Name = ScanProgress, + Title = $"Processing {seriesName}", + SubTitle = seriesName, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = null + }; + } - public static SignalRMessage CoverUpdateEvent(int id, string entityType) + public static SignalRMessage CoverUpdateEvent(int id, string entityType) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = CoverUpdate, + Title = "Updating Cover", + Progress = ProgressType.None, + Body = new { - Name = CoverUpdate, - Title = "Updating Cover", - Progress = ProgressType.None, - Body = new - { - Id = id, - EntityType = entityType, - } - }; - } + Id = id, + EntityType = entityType, + } + }; + } - public static SignalRMessage UserProgressUpdateEvent(int userId, string username, int seriesId, int volumeId, int chapterId, int pagesRead) + public static SignalRMessage UserProgressUpdateEvent(int userId, string username, int seriesId, int volumeId, int chapterId, int pagesRead) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = UserProgressUpdate, + Title = "Updating User Progress", + Progress = ProgressType.None, + Body = new { - Name = UserProgressUpdate, - Title = "Updating User Progress", - Progress = ProgressType.None, - Body = new - { - UserId = userId, - Username = username, - SeriesId = seriesId, - VolumeId = volumeId, - ChapterId = chapterId, - PagesRead = pagesRead, - } - }; - } + UserId = userId, + Username = username, + SeriesId = seriesId, + VolumeId = volumeId, + ChapterId = chapterId, + PagesRead = pagesRead, + } + }; + } - public static SignalRMessage SiteThemeProgressEvent(string subtitle, string themeName, string eventType) + public static SignalRMessage SiteThemeProgressEvent(string subtitle, string themeName, string eventType) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = SiteThemeProgress, + Title = "Scanning Site Theme", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new { - Name = SiteThemeProgress, - Title = "Scanning Site Theme", - SubTitle = subtitle, - EventType = eventType, - Progress = ProgressType.Indeterminate, - Body = new - { - ThemeName = themeName, - } - }; - } + ThemeName = themeName, + } + }; + } - public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType) + public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = BookThemeProgress, + Title = "Scanning Book Theme", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new { - Name = BookThemeProgress, - Title = "Scanning Book Theme", - SubTitle = subtitle, - EventType = eventType, - Progress = ProgressType.Indeterminate, - Body = new - { - ThemeName = themeName, - } - }; - } + ThemeName = themeName, + } + }; + } - public static SignalRMessage UserUpdateEvent(int userId, string userName) + public static SignalRMessage UserUpdateEvent(int userId, string userName) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = UserUpdate, + Title = "User Update", + Progress = ProgressType.None, + Body = new { - Name = UserUpdate, - Title = "User Update", - Progress = ProgressType.None, - Body = new - { - UserId = userId, - UserName = userName - } - }; - } + UserId = userId, + UserName = userName + } + }; + } - public static SignalRMessage ConvertBookmarksProgressEvent(float progress, string eventType) + public static SignalRMessage ConvertBookmarksProgressEvent(float progress, string eventType) + { + return new SignalRMessage() { - return new SignalRMessage() + Name = ConvertBookmarksProgress, + Title = "Converting Bookmarks to WebP", + SubTitle = string.Empty, + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new { - Name = ConvertBookmarksProgress, - Title = "Converting Bookmarks to WebP", - SubTitle = string.Empty, - EventType = eventType, - Progress = ProgressType.Determinate, - Body = new - { - Progress = progress, - EventTime = DateTime.Now - } - }; - } + Progress = progress, + EventTime = DateTime.Now + } + }; } } diff --git a/API/SignalR/MessageHub.cs b/API/SignalR/MessageHub.cs index dd2e2b768f..e56dfeaa0c 100644 --- a/API/SignalR/MessageHub.cs +++ b/API/SignalR/MessageHub.cs @@ -1,47 +1,45 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; -using API.Data; using API.Extensions; using API.SignalR.Presence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; -namespace API.SignalR +namespace API.SignalR; + +/// +/// Generic hub for sending messages to UI +/// +[Authorize] +public class MessageHub : Hub { - /// - /// Generic hub for sending messages to UI - /// - [Authorize] - public class MessageHub : Hub - { - private readonly IPresenceTracker _tracker; + private readonly IPresenceTracker _tracker; - public MessageHub(IPresenceTracker tracker) - { - _tracker = tracker; - } + public MessageHub(IPresenceTracker tracker) + { + _tracker = tracker; + } - public override async Task OnConnectedAsync() - { - await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); + public override async Task OnConnectedAsync() + { + await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); - var currentUsers = await PresenceTracker.GetOnlineUsers(); - await Clients.All.SendAsync(MessageFactory.OnlineUsers, currentUsers); + var currentUsers = await PresenceTracker.GetOnlineUsers(); + await Clients.All.SendAsync(MessageFactory.OnlineUsers, currentUsers); - await base.OnConnectedAsync(); - } + await base.OnConnectedAsync(); + } - public override async Task OnDisconnectedAsync(Exception exception) - { - await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); + public override async Task OnDisconnectedAsync(Exception exception) + { + await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); - var currentUsers = await PresenceTracker.GetOnlineUsers(); - await Clients.All.SendAsync(MessageFactory.OnlineUsers, currentUsers); + var currentUsers = await PresenceTracker.GetOnlineUsers(); + await Clients.All.SendAsync(MessageFactory.OnlineUsers, currentUsers); - await base.OnDisconnectedAsync(exception); - } + await base.OnDisconnectedAsync(exception); } } + diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs index 40cec42d08..eb21a528c7 100644 --- a/API/SignalR/Presence/PresenceTracker.cs +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -4,112 +4,111 @@ using System.Threading.Tasks; using API.Data; -namespace API.SignalR.Presence +namespace API.SignalR.Presence; + +public interface IPresenceTracker { - public interface IPresenceTracker - { - Task UserConnected(string username, string connectionId); - Task UserDisconnected(string username, string connectionId); - Task GetOnlineAdmins(); - Task> GetConnectionsForUser(string username); + Task UserConnected(string username, string connectionId); + Task UserDisconnected(string username, string connectionId); + Task GetOnlineAdmins(); + Task> GetConnectionsForUser(string username); - } +} - internal class ConnectionDetail +internal class ConnectionDetail +{ + public List ConnectionIds { get; set; } + public bool IsAdmin { get; set; } +} + +// TODO: This can respond to UserRoleUpdate events to handle online users +/// +/// This is a singleton service for tracking what users have a SignalR connection and their difference connectionIds +/// +public class PresenceTracker : IPresenceTracker +{ + private readonly IUnitOfWork _unitOfWork; + private static readonly Dictionary OnlineUsers = new Dictionary(); + + public PresenceTracker(IUnitOfWork unitOfWork) { - public List ConnectionIds { get; set; } - public bool IsAdmin { get; set; } + _unitOfWork = unitOfWork; } - // TODO: This can respond to UserRoleUpdate events to handle online users - /// - /// This is a singleton service for tracking what users have a SignalR connection and their difference connectionIds - /// - public class PresenceTracker : IPresenceTracker + public async Task UserConnected(string username, string connectionId) { - private readonly IUnitOfWork _unitOfWork; - private static readonly Dictionary OnlineUsers = new Dictionary(); - - public PresenceTracker(IUnitOfWork unitOfWork) + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); + if (user == null) return; + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + lock (OnlineUsers) { - _unitOfWork = unitOfWork; - } - - public async Task UserConnected(string username, string connectionId) - { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); - if (user == null) return; - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - lock (OnlineUsers) + if (OnlineUsers.ContainsKey(username)) { - if (OnlineUsers.ContainsKey(username)) - { - OnlineUsers[username].ConnectionIds.Add(connectionId); - } - else + OnlineUsers[username].ConnectionIds.Add(connectionId); + } + else + { + OnlineUsers.Add(username, new ConnectionDetail() { - OnlineUsers.Add(username, new ConnectionDetail() - { - ConnectionIds = new List() {connectionId}, - IsAdmin = isAdmin - }); - } + ConnectionIds = new List() {connectionId}, + IsAdmin = isAdmin + }); } - - // Update the last active for the user - user.LastActive = DateTime.Now; - await _unitOfWork.CommitAsync(); } - public Task UserDisconnected(string username, string connectionId) + // Update the last active for the user + user.LastActive = DateTime.Now; + await _unitOfWork.CommitAsync(); + } + + public Task UserDisconnected(string username, string connectionId) + { + lock (OnlineUsers) { - lock (OnlineUsers) - { - if (!OnlineUsers.ContainsKey(username)) return Task.CompletedTask; + if (!OnlineUsers.ContainsKey(username)) return Task.CompletedTask; - OnlineUsers[username].ConnectionIds.Remove(connectionId); + OnlineUsers[username].ConnectionIds.Remove(connectionId); - if (OnlineUsers[username].ConnectionIds.Count == 0) - { - OnlineUsers.Remove(username); - } + if (OnlineUsers[username].ConnectionIds.Count == 0) + { + OnlineUsers.Remove(username); } - return Task.CompletedTask; } + return Task.CompletedTask; + } - public static Task GetOnlineUsers() + public static Task GetOnlineUsers() + { + string[] onlineUsers; + lock (OnlineUsers) { - string[] onlineUsers; - lock (OnlineUsers) - { - onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); - } - - return Task.FromResult(onlineUsers); + onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); } - public Task GetOnlineAdmins() + return Task.FromResult(onlineUsers); + } + + public Task GetOnlineAdmins() + { + // TODO: This might end in stale data, we want to get the online users, query against DB to check if they are admins then return + string[] onlineUsers; + lock (OnlineUsers) { - // TODO: This might end in stale data, we want to get the online users, query against DB to check if they are admins then return - string[] onlineUsers; - lock (OnlineUsers) - { - onlineUsers = OnlineUsers.Where(pair => pair.Value.IsAdmin).OrderBy(k => k.Key).Select(k => k.Key).ToArray(); - } + onlineUsers = OnlineUsers.Where(pair => pair.Value.IsAdmin).OrderBy(k => k.Key).Select(k => k.Key).ToArray(); + } - return Task.FromResult(onlineUsers); - } + return Task.FromResult(onlineUsers); + } - public Task> GetConnectionsForUser(string username) + public Task> GetConnectionsForUser(string username) + { + List connectionIds; + lock (OnlineUsers) { - List connectionIds; - lock (OnlineUsers) - { - connectionIds = OnlineUsers.GetValueOrDefault(username)?.ConnectionIds; - } - - return Task.FromResult(connectionIds ?? new List()); + connectionIds = OnlineUsers.GetValueOrDefault(username)?.ConnectionIds; } + + return Task.FromResult(connectionIds ?? new List()); } } diff --git a/API/SignalR/SignalRMessage.cs b/API/SignalR/SignalRMessage.cs index d9564d0277..6c8afe8445 100644 --- a/API/SignalR/SignalRMessage.cs +++ b/API/SignalR/SignalRMessage.cs @@ -1,39 +1,38 @@ using System; -namespace API.SignalR +namespace API.SignalR; + +/// +/// Payload for SignalR messages to Frontend +/// +public class SignalRMessage { /// - /// Payload for SignalR messages to Frontend + /// Body of the event type + /// + public object Body { get; set; } + public string Name { get; set; } + /// + /// User friendly Title of the Event + /// + /// Scanning Manga + public string Title { get; set; } = string.Empty; + /// + /// User friendly subtitle. Should have extra info + /// + /// C:/manga/Accel World V01.cbz + public string SubTitle { get; set; } = string.Empty; + /// + /// Represents what this represents. started | updated | ended | single + /// + /// + public string EventType { get; set; } = ProgressEventType.Updated; + /// + /// How should progress be represented. If Determinate, the Body MUST have a Progress float on it. + /// + public string Progress { get; set; } = ProgressType.None; + /// + /// When event took place /// - public class SignalRMessage - { - /// - /// Body of the event type - /// - public object Body { get; set; } - public string Name { get; set; } - /// - /// User friendly Title of the Event - /// - /// Scanning Manga - public string Title { get; set; } = string.Empty; - /// - /// User friendly subtitle. Should have extra info - /// - /// C:/manga/Accel World V01.cbz - public string SubTitle { get; set; } = string.Empty; - /// - /// Represents what this represents. started | updated | ended | single - /// - /// - public string EventType { get; set; } = ProgressEventType.Updated; - /// - /// How should progress be represented. If Determinate, the Body MUST have a Progress float on it. - /// - public string Progress { get; set; } = ProgressType.None; - /// - /// When event took place - /// - public readonly DateTime EventTime = DateTime.Now; - } + public readonly DateTime EventTime = DateTime.Now; } diff --git a/API/Startup.cs b/API/Startup.cs index 6297453a09..277613af87 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -10,6 +10,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Logging; using API.Middleware; using API.Services; using API.Services.HostedServices; @@ -35,149 +36,150 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; +using Serilog; using TaskScheduler = API.Services.TaskScheduler; -namespace API +namespace API; + +public class Startup { - public class Startup - { - private readonly IConfiguration _config; - private readonly IWebHostEnvironment _env; + private readonly IConfiguration _config; + private readonly IWebHostEnvironment _env; - public Startup(IConfiguration config, IWebHostEnvironment env) - { - _config = config; - _env = env; - } + public Startup(IConfiguration config, IWebHostEnvironment env) + { + _config = config; + _env = env; + } - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) + // This method gets called by the runtime. Use this method to add services to the container. + public void ConfigureServices(IServiceCollection services) + { + services.AddApplicationServices(_config, _env); + services.AddControllers(options => { - services.AddApplicationServices(_config, _env); - services.AddControllers(options => - { - options.CacheProfiles.Add("Images", - new CacheProfile() - { - Duration = 60, - Location = ResponseCacheLocation.None, - NoStore = false - }); - options.CacheProfiles.Add("Hour", - new CacheProfile() - { - Duration = 60 * 10, - Location = ResponseCacheLocation.None, - NoStore = false - }); - }); - services.Configure(options => - { - options.ForwardedHeaders = ForwardedHeaders.All; - foreach(var proxy in _config.GetSection("KnownProxies").AsEnumerable().Where(c => c.Value != null)) { - options.KnownProxies.Add(IPAddress.Parse(proxy.Value)); - } - }); - services.AddCors(); - services.AddIdentityServices(_config); - services.AddSwaggerGen(c => - { - c.SwaggerDoc("v1", new OpenApiInfo() + options.CacheProfiles.Add("Images", + new CacheProfile() { - Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", - Title = "Kavita API", - Version = "v1", - }); - - - var filePath = Path.Combine(AppContext.BaseDirectory, "API.xml"); - c.IncludeXmlComments(filePath, true); - c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { - In = ParameterLocation.Header, - Description = "Please insert JWT with Bearer into field", - Name = "Authorization", - Type = SecuritySchemeType.ApiKey - }); - c.AddSecurityRequirement(new OpenApiSecurityRequirement { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer" - } - }, - Array.Empty() - } + Duration = 60, + Location = ResponseCacheLocation.None, + NoStore = false }); - - c.AddServer(new OpenApiServer() + options.CacheProfiles.Add("Hour", + new CacheProfile() { - Description = "Custom Url", - Url = "/" + Duration = 60 * 10, + Location = ResponseCacheLocation.None, + NoStore = false }); + }); + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.All; + foreach(var proxy in _config.GetSection("KnownProxies").AsEnumerable().Where(c => c.Value != null)) { + options.KnownProxies.Add(IPAddress.Parse(proxy.Value)); + } + }); + services.AddCors(); + services.AddIdentityServices(_config); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo() + { + Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", + Title = "Kavita API", + Version = "v1", + }); - c.AddServer(new OpenApiServer() - { - Description = "Local Server", - Url = "http://localhost:5000/", - }); - c.AddServer(new OpenApiServer() + var filePath = Path.Combine(AppContext.BaseDirectory, "API.xml"); + c.IncludeXmlComments(filePath, true); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { + In = ParameterLocation.Header, + Description = "Please insert JWT with Bearer into field", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement { { - Url = "https://demo.kavitareader.com/", - Description = "Kavita Demo" - }); + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); - c.AddServer(new OpenApiServer() - { - Url = "http://" + GetLocalIpAddress() + ":5000/", - Description = "Local IP" - }); + c.AddServer(new OpenApiServer() + { + Description = "Custom Url", + Url = "/" + }); + c.AddServer(new OpenApiServer() + { + Description = "Local Server", + Url = "http://localhost:5000/", }); - services.AddResponseCompression(options => + + c.AddServer(new OpenApiServer() { - options.Providers.Add(); - options.Providers.Add(); - options.MimeTypes = - ResponseCompressionDefaults.MimeTypes.Concat( - new[] { "image/jpeg", "image/jpg" }); - options.EnableForHttps = true; + Url = "https://demo.kavitareader.com/", + Description = "Kavita Demo" }); - services.Configure(options => + + c.AddServer(new OpenApiServer() { - options.Level = CompressionLevel.Fastest; + Url = "http://" + GetLocalIpAddress() + ":5000/", + Description = "Local IP" }); - services.AddResponseCaching(); + }); + services.AddResponseCompression(options => + { + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = + ResponseCompressionDefaults.MimeTypes.Concat( + new[] { "image/jpeg", "image/jpg" }); + options.EnableForHttps = true; + }); + services.Configure(options => + { + options.Level = CompressionLevel.Fastest; + }); - services.AddHangfire(configuration => configuration - .UseSimpleAssemblyNameTypeSerializer() - .UseRecommendedSerializerSettings() - .UseMemoryStorage()); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted + services.AddResponseCaching(); - // Add the processing server as IHostedService - services.AddHangfireServer(options => - { - options.Queues = new[] {TaskScheduler.ScanQueue, TaskScheduler.DefaultQueue}; - }); - // Add IHostedService for startup tasks - // Any services that should be bootstrapped go here - services.AddHostedService(); - } + services.AddHangfire(configuration => configuration + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseMemoryStorage()); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, - IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, - IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) + // Add the processing server as IHostedService + services.AddHangfireServer(options => { + options.Queues = new[] {TaskScheduler.ScanQueue, TaskScheduler.DefaultQueue}; + }); + // Add IHostedService for startup tasks + // Any services that should be bootstrapped go here + services.AddHostedService(); + } - // Apply Migrations - try - { - Task.Run(async () => + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, + IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, + IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) + { + + // Apply Migrations + try + { + Task.Run(async () => { // Apply all migrations on startup var logger = serviceProvider.GetRequiredService>(); @@ -201,134 +203,136 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo await unitOfWork.CommitAsync(); }).GetAwaiter() - .GetResult(); - } - catch (Exception ex) - { - var logger = serviceProvider.GetRequiredService>(); - logger.LogCritical(ex, "An error occurred during migration"); - } - + .GetResult(); + } + catch (Exception ex) + { + var logger = serviceProvider.GetRequiredService>(); + logger.LogCritical(ex, "An error occurred during migration"); + } - app.UseMiddleware(); - Task.Run(async () => - { - var allowSwaggerUi = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()) - .EnableSwaggerUi; + app.UseMiddleware(); - if (env.IsDevelopment() || allowSwaggerUi) - { - app.UseSwagger(); - app.UseSwaggerUI(c => - { - c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version); - }); - } - }); + Task.Run(async () => + { + var allowSwaggerUi = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()) + .EnableSwaggerUi; - if (env.IsDevelopment()) + if (env.IsDevelopment() || allowSwaggerUi) { - app.UseHangfireDashboard(); + app.UseSwagger(); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version); + }); } + }); - app.UseResponseCompression(); - - app.UseForwardedHeaders(); + if (env.IsDevelopment()) + { + app.UseHangfireDashboard(); + } - app.UseRouting(); + app.UseResponseCompression(); - // Ordering is important. Cors, authentication, authorization - if (env.IsDevelopment()) - { - app.UseCors(policy => policy - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials() // For SignalR token query param - .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200") - .WithExposedHeaders("Content-Disposition", "Pagination")); - } + app.UseForwardedHeaders(); - app.UseResponseCaching(); + app.UseRouting(); - app.UseAuthentication(); + // Ordering is important. Cors, authentication, authorization + if (env.IsDevelopment()) + { + app.UseCors(policy => policy + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() // For SignalR token query param + .WithOrigins("http://localhost:4200", $"http://{GetLocalIpAddress()}:4200") + .WithExposedHeaders("Content-Disposition", "Pagination")); + } - app.UseAuthorization(); + app.UseResponseCaching(); - app.UseDefaultFiles(); + app.UseAuthentication(); - app.UseStaticFiles(new StaticFileOptions - { - ContentTypeProvider = new FileExtensionContentTypeProvider(), - HttpsCompression = HttpsCompressionMode.Compress, - OnPrepareResponse = ctx => - { - const int durationInSeconds = 60 * 60 * 24; - ctx.Context.Response.Headers[HeaderNames.CacheControl] = "public,max-age=" + durationInSeconds; - } - }); + app.UseAuthorization(); - app.Use(async (context, next) => - { - // Note: I removed this as I caught Chrome caching api responses when it shouldn't have - // context.Response.GetTypedHeaders().CacheControl = - // new CacheControlHeaderValue() - // { - // Public = false, - // MaxAge = TimeSpan.FromSeconds(10), - // }; - context.Response.Headers[HeaderNames.Vary] = - new[] { "Accept-Encoding" }; - - // Don't let the site be iframed outside the same origin (clickjacking) - context.Response.Headers.XFrameOptions = "SAMEORIGIN"; - - // Setup CSP to ensure we load assets only from these origins - context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'none';"); - - await next(); - }); + app.UseDefaultFiles(); - app.UseEndpoints(endpoints => + app.UseStaticFiles(new StaticFileOptions + { + ContentTypeProvider = new FileExtensionContentTypeProvider(), + HttpsCompression = HttpsCompressionMode.Compress, + OnPrepareResponse = ctx => { - endpoints.MapControllers(); - endpoints.MapHub("hubs/messages"); - endpoints.MapHangfireDashboard(); - endpoints.MapFallbackToController("Index", "Fallback"); - }); + ctx.Context.Response.Headers[HeaderNames.CacheControl] = "public,max-age=" + TimeSpan.FromHours(24); + } + }); - applicationLifetime.ApplicationStopping.Register(OnShutdown); - applicationLifetime.ApplicationStarted.Register(() => - { - try - { - var logger = serviceProvider.GetRequiredService>(); - logger.LogInformation("Kavita - v{Version}", BuildInfo.Version); - } - catch (Exception) - { - /* Swallow Exception */ - } - Console.WriteLine($"Kavita - v{BuildInfo.Version}"); - }); - } + app.UseSerilogRequestLogging(opts + => opts.EnrichDiagnosticContext = LogEnricher.EnrichFromRequest); - private static void OnShutdown() + app.Use(async (context, next) => { - Console.WriteLine("Server is shutting down. Please allow a few seconds to stop any background jobs..."); - TaskScheduler.Client.Dispose(); - System.Threading.Thread.Sleep(1000); - Console.WriteLine("You may now close the application window."); - } - - private static string GetLocalIpAddress() + // Note: I removed this as I caught Chrome caching api responses when it shouldn't have + // context.Response.GetTypedHeaders().CacheControl = + // new CacheControlHeaderValue() + // { + // Public = false, + // MaxAge = TimeSpan.FromSeconds(10), + // }; + context.Response.Headers[HeaderNames.Vary] = + new[] { "Accept-Encoding" }; + + // Don't let the site be iframed outside the same origin (clickjacking) + context.Response.Headers.XFrameOptions = "SAMEORIGIN"; + + // Setup CSP to ensure we load assets only from these origins + context.Response.Headers.Add("Content-Security-Policy", "frame-ancestors 'none';"); + + await next(); + }); + + app.UseEndpoints(endpoints => { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); - socket.Connect("8.8.8.8", 65530); - if (socket.LocalEndPoint is IPEndPoint endPoint) return endPoint.Address.ToString(); - throw new KavitaException("No network adapters with an IPv4 address in the system!"); - } + endpoints.MapControllers(); + endpoints.MapHub("hubs/messages"); + endpoints.MapHub("hubs/logs"); + endpoints.MapHangfireDashboard(); + endpoints.MapFallbackToController("Index", "Fallback"); + }); + + applicationLifetime.ApplicationStopping.Register(OnShutdown); + applicationLifetime.ApplicationStarted.Register(() => + { + try + { + var logger = serviceProvider.GetRequiredService>(); + logger.LogInformation("Kavita - v{Version}", BuildInfo.Version); + } + catch (Exception) + { + /* Swallow Exception */ + } + Console.WriteLine($"Kavita - v{BuildInfo.Version}"); + }); + } + + private static void OnShutdown() + { + Console.WriteLine("Server is shutting down. Please allow a few seconds to stop any background jobs..."); + TaskScheduler.Client.Dispose(); + System.Threading.Thread.Sleep(1000); + Console.WriteLine("You may now close the application window."); + } + private static string GetLocalIpAddress() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); + socket.Connect("8.8.8.8", 65530); + if (socket.LocalEndPoint is IPEndPoint endPoint) return endPoint.Address.ToString(); + throw new KavitaException("No network adapters with an IPv4 address in the system!"); } + } diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index bd19064c4d..2e7b8afc68 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -3,20 +3,5 @@ "DefaultConnection": "Data source=config//kavita.db" }, "TokenKey": "super secret unguessable key", - "Logging": { - "LogLevel": { - "Default": "Debug", - "Microsoft": "Error", - "Microsoft.Hosting.Lifetime": "Error", - "Hangfire": "Error", - "Microsoft.AspNetCore.Hosting.Internal.WebHost": "Error" - }, - "File": { - "Path": "config//logs/kavita.log", - "Append": "True", - "FileSizeLimitBytes": 26214400, - "MaxRollingFiles": 1 - } - }, "Port": 5000 } diff --git a/API/config/appsettings.json b/API/config/appsettings.json index 19637b881c..a2d7fb0538 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -3,20 +3,5 @@ "DefaultConnection": "Data source=config/kavita.db" }, "TokenKey": "super secret unguessable key", - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Error", - "Microsoft.Hosting.Lifetime": "Error", - "Hangfire": "Error", - "Microsoft.AspNetCore.Hosting.Internal.WebHost": "Error" - }, - "File": { - "Path": "config/logs/kavita.log", - "Append": "True", - "FileSizeLimitBytes": 10485760, - "MaxRollingFiles": 1 - } - }, "Port": 5000 } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 55aa99598f..e626f56cc5 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -5,348 +5,238 @@ using Kavita.Common.EnvironmentInfo; using Microsoft.Extensions.Hosting; -namespace Kavita.Common +namespace Kavita.Common; + +public static class Configuration { - public static class Configuration + public static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); + + public static string Branch + { + get => GetBranch(GetAppSettingFilename()); + set => SetBranch(GetAppSettingFilename(), value); + } + + public static int Port + { + get => GetPort(GetAppSettingFilename()); + set => SetPort(GetAppSettingFilename(), value); + } + + public static string JwtToken + { + get => GetJwtToken(GetAppSettingFilename()); + set => SetJwtToken(GetAppSettingFilename(), value); + } + + + public static string DatabasePath { - public static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); + get => GetDatabasePath(GetAppSettingFilename()); + set => SetDatabasePath(GetAppSettingFilename(), value); + } - public static string Branch + private static string GetAppSettingFilename() + { + if (!string.IsNullOrEmpty(AppSettingsFilename)) { - get => GetBranch(GetAppSettingFilename()); - set => SetBranch(GetAppSettingFilename(), value); + return AppSettingsFilename; } - public static int Port + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var isDevelopment = environment == Environments.Development; + return "appsettings" + (isDevelopment ? ".Development" : string.Empty) + ".json"; + } + + #region JWT Token + + private static string GetJwtToken(string filePath) + { + try { - get => GetPort(GetAppSettingFilename()); - set => SetPort(GetAppSettingFilename(), value); - } + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "TokenKey"; - public static string JwtToken + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) + { + return tokenElement.GetString(); + } + + return string.Empty; + } + catch (Exception ex) { - get => GetJwtToken(GetAppSettingFilename()); - set => SetJwtToken(GetAppSettingFilename(), value); + Console.WriteLine("Error reading app settings: " + ex.Message); } - public static string LogLevel + return string.Empty; + } + + private static void SetJwtToken(string filePath, string token) + { + try { - get => GetLogLevel(GetAppSettingFilename()); - set => SetLogLevel(GetAppSettingFilename(), value); + var currentToken = GetJwtToken(filePath); + var json = File.ReadAllText(filePath) + .Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token); + File.WriteAllText(filePath, json); } - - public static string LogPath + catch (Exception) { - get => GetLoggingFile(GetAppSettingFilename()); - set => SetLoggingFile(GetAppSettingFilename(), value); + /* Swallow exception */ } + } - public static string DatabasePath + public static bool CheckIfJwtTokenSet() + { + try { - get => GetDatabasePath(GetAppSettingFilename()); - set => SetDatabasePath(GetAppSettingFilename(), value); + return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key"; } - - private static string GetAppSettingFilename() + catch (Exception ex) { - if (!string.IsNullOrEmpty(AppSettingsFilename)) - { - return AppSettingsFilename; - } - - var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - var isDevelopment = environment == Environments.Development; - return "appsettings" + (isDevelopment ? ".Development" : string.Empty) + ".json"; + Console.WriteLine("Error writing app settings: " + ex.Message); } - #region JWT Token - - private static string GetJwtToken(string filePath) - { - try - { - var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "TokenKey"; + return false; + } - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetString(); - } + #endregion - return string.Empty; - } - catch (Exception ex) - { - Console.WriteLine("Error reading app settings: " + ex.Message); - } + #region Port - return string.Empty; + private static void SetPort(string filePath, int port) + { + if (new OsInfo(Array.Empty()).IsDocker) + { + return; } - private static void SetJwtToken(string filePath, string token) + try { - try - { - var currentToken = GetJwtToken(filePath); - var json = File.ReadAllText(filePath) - .Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token); - File.WriteAllText(filePath, json); - } - catch (Exception) - { - /* Swallow exception */ - } + var currentPort = GetPort(filePath); + var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port); + File.WriteAllText(filePath, json); } - - public static bool CheckIfJwtTokenSet() + catch (Exception) { - try - { - return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key"; - } - catch (Exception ex) - { - Console.WriteLine("Error writing app settings: " + ex.Message); - } - - return false; + /* Swallow Exception */ } + } - #endregion - - #region Port - - private static void SetPort(string filePath, int port) + private static int GetPort(string filePath) + { + const int defaultPort = 5000; + if (new OsInfo(Array.Empty()).IsDocker) { - if (new OsInfo(Array.Empty()).IsDocker) - { - return; - } - - try - { - var currentPort = GetPort(filePath); - var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port); - File.WriteAllText(filePath, json); - } - catch (Exception) - { - /* Swallow Exception */ - } + return defaultPort; } - private static int GetPort(string filePath) + try { - const int defaultPort = 5000; - if (new OsInfo(Array.Empty()).IsDocker) - { - return defaultPort; - } + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "Port"; - try + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) { - var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "Port"; - - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetInt32(); - } - } - catch (Exception ex) - { - Console.WriteLine("Error writing app settings: " + ex.Message); + return tokenElement.GetInt32(); } - - return defaultPort; + } + catch (Exception ex) + { + Console.WriteLine("Error writing app settings: " + ex.Message); } - #endregion + return defaultPort; + } - #region LogLevel + #endregion - private static void SetLogLevel(string filePath, string logLevel) - { - try - { - var currentLevel = GetLogLevel(filePath); - var json = File.ReadAllText(filePath) - .Replace($"\"Default\": \"{currentLevel}\"", $"\"Default\": \"{logLevel}\""); - File.WriteAllText(filePath, json); - } - catch (Exception) - { - /* Swallow Exception */ - } - } + private static string GetBranch(string filePath) + { + const string defaultBranch = "main"; - private static string GetLogLevel(string filePath) + try { - try - { - var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); + const string key = "Branch"; - if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement)) - { - foreach (var property in tokenElement.EnumerateObject()) - { - if (!property.Name.Equals("LogLevel")) continue; - foreach (var logProperty in property.Value.EnumerateObject().Where(logProperty => logProperty.Name.Equals("Default"))) - { - return logProperty.Value.GetString(); - } - } - } - } - catch (Exception ex) + if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) { - Console.WriteLine("Error writing app settings: " + ex.Message); + return tokenElement.GetString(); } - - return "Information"; } - - #endregion - - private static string GetBranch(string filePath) + catch (Exception ex) { - const string defaultBranch = "main"; - - try - { - var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - const string key = "Branch"; + Console.WriteLine("Error reading app settings: " + ex.Message); + } - if (jsonObj.TryGetProperty(key, out JsonElement tokenElement)) - { - return tokenElement.GetString(); - } - } - catch (Exception ex) - { - Console.WriteLine("Error reading app settings: " + ex.Message); - } + return defaultBranch; + } - return defaultBranch; + private static void SetBranch(string filePath, string updatedBranch) + { + try + { + var currentBranch = GetBranch(filePath); + var json = File.ReadAllText(filePath) + .Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch); + File.WriteAllText(filePath, json); } - - private static void SetBranch(string filePath, string updatedBranch) + catch (Exception) { - try - { - var currentBranch = GetBranch(filePath); - var json = File.ReadAllText(filePath) - .Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch); - File.WriteAllText(filePath, json); - } - catch (Exception) - { - /* Swallow Exception */ - } + /* Swallow Exception */ } + } + + + private static string GetDatabasePath(string filePath) + { + const string defaultFile = "config/kavita.db"; - private static string GetLoggingFile(string filePath) + try { - const string defaultFile = "config/logs/kavita.log"; + var json = File.ReadAllText(filePath); + var jsonObj = JsonSerializer.Deserialize(json); - try + if (jsonObj.TryGetProperty("ConnectionStrings", out JsonElement tokenElement)) { - var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - - if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement)) + foreach (var property in tokenElement.EnumerateObject()) { - foreach (var property in tokenElement.EnumerateObject()) - { - if (!property.Name.Equals("File")) continue; - foreach (var logProperty in property.Value.EnumerateObject()) - { - if (logProperty.Name.Equals("Path")) - { - return logProperty.Value.GetString(); - } - } - } + if (!property.Name.Equals("DefaultConnection")) continue; + return property.Value.GetString(); } } - catch (Exception ex) - { - Console.WriteLine("Error writing app settings: " + ex.Message); - } - - return defaultFile; } - - /// - /// This should NEVER be called except by - /// - /// - /// - private static void SetLoggingFile(string filePath, string directory) + catch (Exception ex) { - try - { - var currentFile = GetLoggingFile(filePath); - var json = File.ReadAllText(filePath) - .Replace("\"Path\": \"" + currentFile + "\"", "\"Path\": \"" + directory + "\""); - File.WriteAllText(filePath, json); - } - catch (Exception ex) - { - /* Swallow Exception */ - Console.WriteLine(ex); - } + Console.WriteLine("Error writing app settings: " + ex.Message); } - private static string GetDatabasePath(string filePath) - { - const string defaultFile = "config/kavita.db"; - - try - { - var json = File.ReadAllText(filePath); - var jsonObj = JsonSerializer.Deserialize(json); - - if (jsonObj.TryGetProperty("ConnectionStrings", out JsonElement tokenElement)) - { - foreach (var property in tokenElement.EnumerateObject()) - { - if (!property.Name.Equals("DefaultConnection")) continue; - return property.Value.GetString(); - } - } - } - catch (Exception ex) - { - Console.WriteLine("Error writing app settings: " + ex.Message); - } + return defaultFile; + } - return defaultFile; + /// + /// This should NEVER be called except by MigrateConfigFiles + /// + /// + /// + private static void SetDatabasePath(string filePath, string updatedPath) + { + try + { + var existingString = GetDatabasePath(filePath); + var json = File.ReadAllText(filePath) + .Replace(existingString, + "Data source=" + updatedPath); + File.WriteAllText(filePath, json); } - - /// - /// This should NEVER be called except by MigrateConfigFiles - /// - /// - /// - private static void SetDatabasePath(string filePath, string updatedPath) + catch (Exception) { - try - { - var existingString = GetDatabasePath(filePath); - var json = File.ReadAllText(filePath) - .Replace(existingString, - "Data source=" + updatedPath); - File.WriteAllText(filePath, json); - } - catch (Exception) - { - /* Swallow Exception */ - } + /* Swallow Exception */ } } } diff --git a/Kavita.Common/EnvironmentInfo/IOsInfo.cs b/Kavita.Common/EnvironmentInfo/IOsInfo.cs index cb5a85e09e..5bb9dcf3dd 100644 --- a/Kavita.Common/EnvironmentInfo/IOsInfo.cs +++ b/Kavita.Common/EnvironmentInfo/IOsInfo.cs @@ -4,157 +4,156 @@ using System.IO; using System.Linq; -namespace Kavita.Common.EnvironmentInfo +namespace Kavita.Common.EnvironmentInfo; + +public class OsInfo : IOsInfo { - public class OsInfo : IOsInfo - { - public static Os Os { get; } + public static Os Os { get; } - public static bool IsNotWindows => !IsWindows; - public static bool IsLinux => Os == Os.Linux || Os == Os.LinuxMusl || Os == Os.Bsd; - public static bool IsOsx => Os == Os.Osx; - public static bool IsWindows => Os == Os.Windows; + public static bool IsNotWindows => !IsWindows; + public static bool IsLinux => Os == Os.Linux || Os == Os.LinuxMusl || Os == Os.Bsd; + public static bool IsOsx => Os == Os.Osx; + public static bool IsWindows => Os == Os.Windows; - // this needs to not be static so we can mock it - public bool IsDocker { get; } + // this needs to not be static so we can mock it + public bool IsDocker { get; } - public string Version { get; } - public string Name { get; } - public string FullName { get; } + public string Version { get; } + public string Name { get; } + public string FullName { get; } - static OsInfo() - { - var platform = Environment.OSVersion.Platform; + static OsInfo() + { + var platform = Environment.OSVersion.Platform; - switch (platform) + switch (platform) + { + case PlatformID.Win32NT: { - case PlatformID.Win32NT: - { - Os = Os.Windows; - break; - } - - case PlatformID.MacOSX: - case PlatformID.Unix: - { - Os = GetPosixFlavour(); - break; - } + Os = Os.Windows; + break; } + case PlatformID.MacOSX: + case PlatformID.Unix: + { + Os = GetPosixFlavour(); + break; + } } - public OsInfo(IEnumerable versionAdapters) - { - OsVersionModel osInfo = null; + } - foreach (var osVersionAdapter in versionAdapters.Where(c => c.Enabled)) - { - try - { - osInfo = osVersionAdapter.Read(); - } - catch (Exception e) - { - Console.WriteLine("Couldn't get OS Version info: " + e.Message); - } - - if (osInfo != null) - { - break; - } - } + public OsInfo(IEnumerable versionAdapters) + { + OsVersionModel osInfo = null; - if (osInfo != null) + foreach (var osVersionAdapter in versionAdapters.Where(c => c.Enabled)) + { + try { - Name = osInfo.Name; - Version = osInfo.Version; - FullName = osInfo.FullName; + osInfo = osVersionAdapter.Read(); } - else + catch (Exception e) { - Name = Os.ToString(); - FullName = Name; + Console.WriteLine("Couldn't get OS Version info: " + e.Message); } - if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + if (osInfo != null) { - IsDocker = true; + break; } } - public OsInfo() + if (osInfo != null) + { + Name = osInfo.Name; + Version = osInfo.Version; + FullName = osInfo.FullName; + } + else { Name = Os.ToString(); FullName = Name; + } - if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) - { - IsDocker = true; - } + if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) + { + IsDocker = true; } + } + + public OsInfo() + { + Name = Os.ToString(); + FullName = Name; - private static Os GetPosixFlavour() + if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/")) { - var output = RunAndCapture("uname", "-s"); + IsDocker = true; + } + } - if (output.StartsWith("Darwin")) - { - return Os.Osx; - } - else if (output.Contains("BSD")) - { - return Os.Bsd; - } - else - { + private static Os GetPosixFlavour() + { + var output = RunAndCapture("uname", "-s"); + + if (output.StartsWith("Darwin")) + { + return Os.Osx; + } + else if (output.Contains("BSD")) + { + return Os.Bsd; + } + else + { #if ISMUSL return Os.LinuxMusl; #else - return Os.Linux; + return Os.Linux; #endif - } } + } - private static string RunAndCapture(string filename, string args) + private static string RunAndCapture(string filename, string args) + { + var p = new Process { - var p = new Process + StartInfo = { - StartInfo = - { - FileName = filename, - Arguments = args, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true - } - }; - - p.Start(); - - // To avoid deadlocks, always read the output stream first and then wait. - var output = p.StandardOutput.ReadToEnd(); - p.WaitForExit(1000); - - return output; - } - } + FileName = filename, + Arguments = args, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true + } + }; - public interface IOsInfo - { - string Version { get; } - string Name { get; } - string FullName { get; } + p.Start(); - bool IsDocker { get; } - } + // To avoid deadlocks, always read the output stream first and then wait. + var output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); - public enum Os - { - Windows, - Linux, - Osx, - LinuxMusl, - Bsd + return output; } } + +public interface IOsInfo +{ + string Version { get; } + string Name { get; } + string FullName { get; } + + bool IsDocker { get; } +} + +public enum Os +{ + Windows, + Linux, + Osx, + LinuxMusl, + Bsd +} diff --git a/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs b/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs index fbf4403d3c..827a44ac87 100644 --- a/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs +++ b/Kavita.Common/EnvironmentInfo/IOsVersionAdapter.cs @@ -1,8 +1,7 @@ -namespace Kavita.Common.EnvironmentInfo +namespace Kavita.Common.EnvironmentInfo; + +public interface IOsVersionAdapter { - public interface IOsVersionAdapter - { - bool Enabled { get; } - OsVersionModel Read(); - } -} \ No newline at end of file + bool Enabled { get; } + OsVersionModel Read(); +} diff --git a/Kavita.Common/EnvironmentInfo/OsVersionModel.cs b/Kavita.Common/EnvironmentInfo/OsVersionModel.cs index 9e91daa185..a365526ff4 100644 --- a/Kavita.Common/EnvironmentInfo/OsVersionModel.cs +++ b/Kavita.Common/EnvironmentInfo/OsVersionModel.cs @@ -1,27 +1,26 @@ -namespace Kavita.Common.EnvironmentInfo +namespace Kavita.Common.EnvironmentInfo; + +public class OsVersionModel { - public class OsVersionModel + public OsVersionModel(string name, string version, string fullName = null) { - public OsVersionModel(string name, string version, string fullName = null) - { - Name = Trim(name); - Version = Trim(version); - - if (string.IsNullOrWhiteSpace(fullName)) - { - fullName = $"{Name} {Version}"; - } - - FullName = Trim(fullName); - } + Name = Trim(name); + Version = Trim(version); - private static string Trim(string source) + if (string.IsNullOrWhiteSpace(fullName)) { - return source.Trim().Trim('"', '\''); + fullName = $"{Name} {Version}"; } - public string Name { get; } - public string FullName { get; } - public string Version { get; } + FullName = Trim(fullName); } -} \ No newline at end of file + + private static string Trim(string source) + { + return source.Trim().Trim('"', '\''); + } + + public string Name { get; } + public string FullName { get; } + public string Version { get; } +} diff --git a/Kavita.Common/Extensions/EnumExtensions.cs b/Kavita.Common/Extensions/EnumExtensions.cs index d35aeb805b..e672d80507 100644 --- a/Kavita.Common/Extensions/EnumExtensions.cs +++ b/Kavita.Common/Extensions/EnumExtensions.cs @@ -1,21 +1,20 @@ using System.ComponentModel; -namespace Kavita.Common.Extensions +namespace Kavita.Common.Extensions; + +public static class EnumExtensions { - public static class EnumExtensions - { public static string ToDescription(this TEnum value) where TEnum : struct { - var fi = value.GetType().GetField(value.ToString() ?? string.Empty); + var fi = value.GetType().GetField(value.ToString() ?? string.Empty); - if (fi == null) - { - return value.ToString(); - } + if (fi == null) + { + return value.ToString(); + } - var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false); + var attributes = (DescriptionAttribute[])fi.GetCustomAttributes(typeof(DescriptionAttribute), false); - return attributes is {Length: > 0} ? attributes[0].Description : value.ToString(); + return attributes is {Length: > 0} ? attributes[0].Description : value.ToString(); } - } } diff --git a/Kavita.Common/Extensions/PathExtensions.cs b/Kavita.Common/Extensions/PathExtensions.cs index 5ebb96673b..9045896304 100644 --- a/Kavita.Common/Extensions/PathExtensions.cs +++ b/Kavita.Common/Extensions/PathExtensions.cs @@ -1,12 +1,11 @@ using System.IO; -namespace Kavita.Common.Extensions +namespace Kavita.Common.Extensions; + +public static class PathExtensions { - public static class PathExtensions - { public static string GetParentDirectory(string filePath) { - return Path.GetDirectoryName(filePath); + return Path.GetDirectoryName(filePath); } - } } diff --git a/Kavita.Common/HashUtil.cs b/Kavita.Common/HashUtil.cs index f959f0af47..8b808b9c1f 100644 --- a/Kavita.Common/HashUtil.cs +++ b/Kavita.Common/HashUtil.cs @@ -1,56 +1,55 @@ using System; using System.Text; -namespace Kavita.Common +namespace Kavita.Common; + +public static class HashUtil { - public static class HashUtil + private static string CalculateCrc(string input) { - private static string CalculateCrc(string input) + uint mCrc = 0xffffffff; + byte[] bytes = Encoding.UTF8.GetBytes(input); + foreach (byte myByte in bytes) { - uint mCrc = 0xffffffff; - byte[] bytes = Encoding.UTF8.GetBytes(input); - foreach (byte myByte in bytes) + mCrc ^= (uint)myByte << 24; + for (var i = 0; i < 8; i++) { - mCrc ^= (uint)myByte << 24; - for (var i = 0; i < 8; i++) + if ((Convert.ToUInt32(mCrc) & 0x80000000) == 0x80000000) + { + mCrc = (mCrc << 1) ^ 0x04C11DB7; + } + else { - if ((Convert.ToUInt32(mCrc) & 0x80000000) == 0x80000000) - { - mCrc = (mCrc << 1) ^ 0x04C11DB7; - } - else - { - mCrc <<= 1; - } + mCrc <<= 1; } } - - return $"{mCrc:x8}"; } - /// - /// Calculates a unique, Anonymous Token that will represent this unique Kavita installation. - /// - /// - public static string AnonymousToken() - { - var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Configuration.JwtToken}_{Environment.UserName}"; - return CalculateCrc(seed); - } + return $"{mCrc:x8}"; + } - /// - /// Generates a unique API key to this server instance - /// - /// - public static string ApiKey() - { - var id = Guid.NewGuid(); - if (id.Equals(Guid.Empty)) - { - id = Guid.NewGuid(); - } + /// + /// Calculates a unique, Anonymous Token that will represent this unique Kavita installation. + /// + /// + public static string AnonymousToken() + { + var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Configuration.JwtToken}_{Environment.UserName}"; + return CalculateCrc(seed); + } - return id.ToString(); + /// + /// Generates a unique API key to this server instance + /// + /// + public static string ApiKey() + { + var id = Guid.NewGuid(); + if (id.Equals(Guid.Empty)) + { + id = Guid.NewGuid(); } + + return id.ToString(); } } diff --git a/Kavita.Common/KavitaException.cs b/Kavita.Common/KavitaException.cs index f7942c8b1d..b624e0111b 100644 --- a/Kavita.Common/KavitaException.cs +++ b/Kavita.Common/KavitaException.cs @@ -1,25 +1,24 @@ using System; using System.Runtime.Serialization; -namespace Kavita.Common +namespace Kavita.Common; + +/// +/// These are used for errors to send to the UI that should not be reported to Sentry +/// +[Serializable] +public class KavitaException : Exception { - /// - /// These are used for errors to send to the UI that should not be reported to Sentry - /// - [Serializable] - public class KavitaException : Exception - { - public KavitaException() - { } + public KavitaException() + { } - public KavitaException(string message) : base(message) - { } + public KavitaException(string message) : base(message) + { } - public KavitaException(string message, Exception inner) - : base(message, inner) { } + public KavitaException(string message, Exception inner) + : base(message, inner) { } - protected KavitaException(SerializationInfo info, StreamingContext context) - : base(info, context) - { } - } + protected KavitaException(SerializationInfo info, StreamingContext context) + : base(info, context) + { } } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 961afd6cba..05fa96e946 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -142,6 +142,12 @@ export class MessageHubService { this.onlineUsersSource.next(usernames); }); + this.hubConnection.on("LogObject", resp => { + console.log(resp); + }); + this.hubConnection.on("LogString", resp => { + console.log(resp); + }); this.hubConnection.on(EVENTS.ScanSeries, resp => { this.messagesSource.next({ diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index dd20d02ca8..2bee7e1330 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -23,6 +23,8 @@ import { SidenavModule } from '../sidenav/sidenav.module'; import { ManageMediaSettingsComponent } from './manage-media-settings/manage-media-settings.component'; import { ManageEmailSettingsComponent } from './manage-email-settings/manage-email-settings.component'; import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tasks-settings.component'; +import { ManageLogsComponent } from './manage-logs/manage-logs.component'; +import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; @@ -45,6 +47,7 @@ import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tas ManageMediaSettingsComponent, ManageEmailSettingsComponent, ManageTasksSettingsComponent, + ManageLogsComponent, ], imports: [ CommonModule, @@ -59,6 +62,7 @@ import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tas PipeModule, SidenavModule, UserSettingsModule, // API-key componet + VirtualScrollerModule ], providers: [] }) diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.html b/UI/Web/src/app/admin/dashboard/dashboard.component.html index 6dbcd7cf67..1b8fc85a8f 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.html +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.html @@ -23,6 +23,9 @@

+ + + diff --git a/UI/Web/src/app/admin/dashboard/dashboard.component.ts b/UI/Web/src/app/admin/dashboard/dashboard.component.ts index 097a3674b9..c50c9344b4 100644 --- a/UI/Web/src/app/admin/dashboard/dashboard.component.ts +++ b/UI/Web/src/app/admin/dashboard/dashboard.component.ts @@ -13,7 +13,8 @@ enum TabID { Libraries = 'libraries', System = 'system', Plugins = 'plugins', - Tasks = 'tasks' + Tasks = 'tasks', + Logs = 'logs' } @Component({ @@ -27,6 +28,7 @@ export class DashboardComponent implements OnInit { {title: 'General', fragment: TabID.General}, {title: 'Users', fragment: TabID.Users}, {title: 'Libraries', fragment: TabID.Libraries}, + //{title: 'Logs', fragment: TabID.Logs}, {title: 'Media', fragment: TabID.Media}, {title: 'Email', fragment: TabID.Email}, //{title: 'Plugins', fragment: TabID.Plugins}, diff --git a/UI/Web/src/app/admin/manage-logs/manage-logs.component.html b/UI/Web/src/app/admin/manage-logs/manage-logs.component.html new file mode 100644 index 0000000000..5c022dd00f --- /dev/null +++ b/UI/Web/src/app/admin/manage-logs/manage-logs.component.html @@ -0,0 +1,9 @@ + + +
+
+ {{item.timestamp | date}} [{{item.level}}] {{item.message}} +
+
+
+
\ No newline at end of file diff --git a/UI/Web/src/app/admin/manage-logs/manage-logs.component.scss b/UI/Web/src/app/admin/manage-logs/manage-logs.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts b/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts new file mode 100644 index 0000000000..7dba0d39bd --- /dev/null +++ b/UI/Web/src/app/admin/manage-logs/manage-logs.component.ts @@ -0,0 +1,66 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; +import { BehaviorSubject, ReplaySubject, Subject, take } from 'rxjs'; +import { AccountService } from 'src/app/_services/account.service'; +import { environment } from 'src/environments/environment'; + +interface LogMessage { + timestamp: string; + level: 'Information' | 'Debug' | 'Warning' | 'Error'; + message: string; + exception: string; +} + +@Component({ + selector: 'app-manage-logs', + templateUrl: './manage-logs.component.html', + styleUrls: ['./manage-logs.component.scss'] +}) +export class ManageLogsComponent implements OnInit, OnDestroy { + + hubUrl = environment.hubUrl; + private hubConnection!: HubConnection; + + logsSource = new BehaviorSubject([]); + public logs$ = this.logsSource.asObservable(); + + constructor(private accountService: AccountService) { } + + ngOnInit(): void { + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.hubConnection = new HubConnectionBuilder() + .withUrl(this.hubUrl + 'logs', { + accessTokenFactory: () => user.token + }) + .withAutomaticReconnect() + .build(); + + console.log('Starting log connection'); + + this.hubConnection + .start() + .catch(err => console.error(err)); + + this.hubConnection.on('SendLogAsObject', resp => { + const payload = resp.arguments[0] as LogMessage; + const logMessage = {timestamp: payload.timestamp, level: payload.level, message: payload.message, exception: payload.exception}; + // TODO: It might be better to just have a queue to show this + const values = this.logsSource.getValue(); + values.push(logMessage); + this.logsSource.next(values); + }); + } + }); + + } + + ngOnDestroy(): void { + // unsubscrbe from signalr connection + if (this.hubConnection) { + this.hubConnection.stop().catch(err => console.error(err)); + console.log('Stoping log connection'); + } + } + +} diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index 878885c0c5..b3e0b73968 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -1,6 +1,6 @@
-

Port, Logging Level, and Swagger require a manual restart of Kavita to take effect.

+

Port and Swagger require a manual restart of Kavita to take effect.

  Where the server place temporary files when reading. This will be cleaned up on a regular basis. @@ -48,8 +48,8 @@
  - Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect. - Port the server listens on. Requires restart to take effect. + Use debug to help identify issues. Debug can eat up a lot of disk space. + Port the server listens on.