diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1fe2d72e8..bfd2bab0a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,7 +9,7 @@ assignees: '' **If this is a feature request, request [here](https://feats.kavitareader.com/) instead. Feature requests will be deleted from Github.** - +Please put as much information as possible to help me understand your issue. OS, browser, version are very important! **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index 48a93318a..6673f3f00 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -20,7 +20,8 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.100 + include-prerelease: True + dotnet-version: '6.0' - name: Install dependencies run: dotnet restore @@ -35,67 +36,68 @@ jobs: name: csproj path: Kavita.Common/Kavita.Common.csproj - test: - name: Install Sonar & Test - needs: build - runs-on: windows-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 5.0.100 - - - name: Install dependencies - run: dotnet restore - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 1.11 - - - name: Cache SonarCloud packages - uses: actions/cache@v1 - with: - path: ~\sonar\cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - - name: Cache SonarCloud scanner - id: cache-sonar-scanner - uses: actions/cache@v1 - with: - path: .\.sonar\scanner - key: ${{ runner.os }}-sonar-scanner - restore-keys: ${{ runner.os }}-sonar-scanner - - - name: Install SonarCloud scanner - if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' - shell: powershell - run: | - New-Item -Path .\.sonar\scanner -ItemType Directory - dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner - - - name: Sonar Scan - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - shell: powershell - run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" - dotnet build --configuration Release - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" - - - name: Test - run: dotnet test --no-restore --verbosity normal +# test: +# name: Install Sonar & Test +# needs: build +# runs-on: windows-latest +# steps: +# - name: Checkout Repo +# uses: actions/checkout@v2 +# with: +# fetch-depth: 0 +# +# - name: Setup .NET Core +# uses: actions/setup-dotnet@v1 +# with: +# include-prerelease: True +# dotnet-version: '6.0' +# +# - name: Install dependencies +# run: dotnet restore +# +# - name: Set up JDK 11 +# uses: actions/setup-java@v1 +# with: +# java-version: 1.11 +# +# - name: Cache SonarCloud packages +# uses: actions/cache@v1 +# with: +# path: ~\sonar\cache +# key: ${{ runner.os }}-sonar +# restore-keys: ${{ runner.os }}-sonar +# +# - name: Cache SonarCloud scanner +# id: cache-sonar-scanner +# uses: actions/cache@v1 +# with: +# path: .\.sonar\scanner +# key: ${{ runner.os }}-sonar-scanner +# restore-keys: ${{ runner.os }}-sonar-scanner +# +# - name: Install SonarCloud scanner +# if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' +# shell: powershell +# run: | +# New-Item -Path .\.sonar\scanner -ItemType Directory +# dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner +# +# - name: Sonar Scan +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# shell: powershell +# run: | +# .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" +# dotnet build --configuration Release +# .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" +# +# - name: Test +# run: dotnet test --no-restore --verbosity normal version: name: Bump version on Develop push - needs: [ build, test ] + needs: [ build ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: @@ -106,7 +108,8 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 5.0.100 + include-prerelease: True + dotnet-version: '6.0' - name: Install dependencies run: dotnet restore @@ -122,7 +125,7 @@ jobs: develop: name: Build Nightly Docker if Develop push - needs: [ build, test, version ] + needs: [ build, version ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: @@ -186,7 +189,8 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v1 with: - dotnet-version: '5.0.x' + include-prerelease: True + dotnet-version: '6.0' - run: ./monorepo-build.sh - name: Login to Docker Hub @@ -225,7 +229,7 @@ jobs: stable: name: Build Stable Docker if Main push - needs: [ build, test ] + needs: [ build ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} steps: @@ -299,7 +303,8 @@ jobs: - name: Compile dotnet app uses: actions/setup-dotnet@v1 with: - dotnet-version: '5.0.x' + include-prerelease: True + dotnet-version: '6.0' - run: ./monorepo-build.sh - name: Login to Docker Hub diff --git a/.gitignore b/.gitignore index bc28fac8f..1ee566816 100644 --- a/.gitignore +++ b/.gitignore @@ -508,6 +508,7 @@ UI/Web/dist/ /API/config/cache/ /API/config/temp/ /API/config/stats/ +/API/config/bookmarks/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal @@ -517,5 +518,8 @@ API/config/covers/ API/config/*.db API/config/stats/* API/config/stats/app_stats.json - +API/config/pre-metadata/ +API/config/post-metadata/ +API.Tests/TestResults/ UI/Web/.vscode/settings.json +/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/* diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index d63d24ddc..cbf7d76f9 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -1,7 +1,7 @@ - net5.0 + net6.0 Exe diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index 8681c1261..a180d566f 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -1,6 +1,6 @@ using System.IO; +using System.IO.Abstractions; using API.Entities.Enums; -using API.Interfaces.Services; using API.Parser; using API.Services; using API.Services.Tasks.Scanner; @@ -20,11 +20,15 @@ namespace API.Benchmark private readonly ParseScannedFiles _parseScannedFiles; private readonly ILogger _logger = Substitute.For>(); private readonly ILogger _bookLogger = Substitute.For>(); + private readonly IArchiveService _archiveService = Substitute.For(); public ParseScannedFilesBenchmarks() { - IBookService bookService = new BookService(_bookLogger); - _parseScannedFiles = new ParseScannedFiles(bookService, _logger); + var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); + _parseScannedFiles = new ParseScannedFiles( + Substitute.For(), + directoryService, + new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For>(), directoryService)), Substitute.For(), directoryService)); } // [Benchmark] diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs index a2aabdd8a..618a8b93c 100644 --- a/API.Benchmark/TestBenchmark.cs +++ b/API.Benchmark/TestBenchmark.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using API.Comparators; using API.DTOs; +using API.Extensions; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; @@ -16,9 +17,6 @@ namespace API.Benchmark [RankColumn] public class TestBenchmark { - private readonly NaturalSortComparer _naturalSortComparer = new (); - - private static IEnumerable GenerateVolumes(int max) { var random = new Random(); @@ -50,11 +48,11 @@ namespace API.Benchmark return list; } - private void SortSpecialChapters(IEnumerable volumes) + private static void SortSpecialChapters(IEnumerable volumes) { foreach (var v in volumes.Where(vDto => vDto.Number == 0)) { - v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList(); + v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList(); } } diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 59ecff406..4f268a38a 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -1,15 +1,16 @@ - net5.0 + net6.0 false - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs new file mode 100644 index 000000000..72d6908c6 --- /dev/null +++ b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs @@ -0,0 +1,17 @@ +using System.Linq; +using API.Comparators; +using Xunit; + +namespace API.Tests.Comparers; + +public class ChapterSortComparerZeroFirstTests +{ + [Theory] + [InlineData(new[] {1, 2, 0}, new[] {0, 1, 2,})] + [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})] + [InlineData(new[] {1, 0, 0}, new[] {0, 0, 1})] + public void ChapterSortComparerZeroFirstTest(int[] input, int[] expected) + { + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); + } +} diff --git a/API.Tests/Comparers/NumericComparerTests.cs b/API.Tests/Comparers/NumericComparerTests.cs new file mode 100644 index 000000000..9a66e7666 --- /dev/null +++ b/API.Tests/Comparers/NumericComparerTests.cs @@ -0,0 +1,26 @@ +using System; +using API.Comparators; +using Xunit; + +namespace API.Tests.Comparers; + +public class NumericComparerTests +{ + [Theory] + [InlineData( + new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, + new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} + )] + public void NumericComparerTest(string[] input, string[] expected) + { + var nc = new NumericComparer(); + Array.Sort(input, nc); + + var i = 0; + foreach (var s in input) + { + Assert.Equal(s, expected[i]); + i++; + } + } +} diff --git a/API.Tests/Comparers/StringLogicalComparerTest.cs b/API.Tests/Comparers/StringLogicalComparerTest.cs index ae93b3b46..3d13e43ac 100644 --- a/API.Tests/Comparers/StringLogicalComparerTest.cs +++ b/API.Tests/Comparers/StringLogicalComparerTest.cs @@ -8,13 +8,20 @@ namespace API.Tests.Comparers { [Theory] [InlineData( - new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, - new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} + new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, + new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} )] - public void TestLogicalComparer(string[] input, string[] expected) + [InlineData( + new[] {"a.jpg", "aaa.jpg", "1.jpg", }, + new[] {"1.jpg", "a.jpg", "aaa.jpg"} + )] + [InlineData( + new[] {"a.jpg", "aaa.jpg", "1.jpg", "!cover.png"}, + new[] {"!cover.png", "1.jpg", "a.jpg", "aaa.jpg"} + )] + public void StringComparer(string[] input, string[] expected) { - NumericComparer nc = new NumericComparer(); - Array.Sort(input, nc); + Array.Sort(input, StringLogicalComparer.Compare); var i = 0; foreach (var s in input) @@ -24,4 +31,4 @@ namespace API.Tests.Comparers } } } -} \ No newline at end of file +} diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs index 34efbd59e..813d82426 100644 --- a/API.Tests/Converters/CronConverterTests.cs +++ b/API.Tests/Converters/CronConverterTests.cs @@ -9,9 +9,11 @@ namespace API.Tests.Converters [InlineData("daily", "0 0 * * *")] [InlineData("disabled", "0 0 31 2 *")] [InlineData("weekly", "0 0 * * 1")] + [InlineData("", "0 0 31 2 *")] + [InlineData("sdfgdf", "")] public void ConvertTest(string input, string expected) { Assert.Equal(expected, CronConverter.ConvertToCronNotation(input)); } } -} \ No newline at end of file +} diff --git a/API.Tests/Entities/ComicInfoTests.cs b/API.Tests/Entities/ComicInfoTests.cs new file mode 100644 index 000000000..7b7106eb9 --- /dev/null +++ b/API.Tests/Entities/ComicInfoTests.cs @@ -0,0 +1,38 @@ +using API.Data.Metadata; +using API.Entities.Enums; +using Xunit; + +namespace API.Tests.Entities; + +public class ComicInfoTests +{ + #region ConvertAgeRatingToEnum + + [Theory] + [InlineData("G", AgeRating.G)] + [InlineData("Everyone", AgeRating.Everyone)] + [InlineData("Teen", AgeRating.Teen)] + [InlineData("Adults Only 18+", AgeRating.AdultsOnly)] + [InlineData("Early Childhood", AgeRating.EarlyChildhood)] + [InlineData("Everyone 10+", AgeRating.Everyone10Plus)] + [InlineData("M", AgeRating.Mature)] + [InlineData("MA 15+", AgeRating.Mature15Plus)] + [InlineData("Mature 17+", AgeRating.Mature17Plus)] + [InlineData("Rating Pending", AgeRating.RatingPending)] + [InlineData("X18+", AgeRating.X18Plus)] + [InlineData("Kids to Adults", AgeRating.KidsToAdults)] + [InlineData("NotValid", AgeRating.Unknown)] + [InlineData("PG", AgeRating.PG)] + [InlineData("R18+", AgeRating.R18Plus)] + public void ConvertAgeRatingToEnum_ShouldConvertCorrectly(string input, AgeRating expected) + { + Assert.Equal(expected, ComicInfo.ConvertAgeRatingToEnum(input)); + } + + [Fact] + public void ConvertAgeRatingToEnum_ShouldCompareCaseInsensitive() + { + Assert.Equal(AgeRating.RatingPending, ComicInfo.ConvertAgeRatingToEnum("rating pending")); + } + #endregion +} diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index 2251c660b..845b1387b 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -9,7 +10,7 @@ namespace API.Tests.Extensions { public class ChapterListExtensionsTests { - private Chapter CreateChapter(string range, string number, MangaFile file, bool isSpecial) + private static Chapter CreateChapter(string range, string number, MangaFile file, bool isSpecial) { return new Chapter() { @@ -20,7 +21,7 @@ namespace API.Tests.Extensions }; } - private MangaFile CreateFile(string file, MangaFormat format) + private static MangaFile CreateFile(string file, MangaFormat format) { return new MangaFile() { @@ -28,7 +29,7 @@ namespace API.Tests.Extensions Format = format }; } - + [Fact] public void GetAnyChapterByRange_Test_ShouldBeNull() { @@ -51,11 +52,11 @@ namespace API.Tests.Extensions }; var actualChapter = chapterList.GetChapterByRange(info); - + Assert.NotEqual(chapterList[0], actualChapter); - + } - + [Fact] public void GetAnyChapterByRange_Test_ShouldBeNotNull() { @@ -78,9 +79,39 @@ namespace API.Tests.Extensions }; var actualChapter = chapterList.GetChapterByRange(info); - + Assert.Equal(chapterList[0], actualChapter); - } + + #region GetFirstChapterWithFiles + + [Fact] + public void GetFirstChapterWithFiles_ShouldReturnAllChapters() + { + var chapterList = new List() + { + CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), + CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), + }; + + Assert.Equal(chapterList.First(), chapterList.GetFirstChapterWithFiles()); + } + + [Fact] + public void GetFirstChapterWithFiles_ShouldReturnSecondChapter() + { + var chapterList = new List() + { + CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), + CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), + }; + + chapterList.First().Files = new List(); + + Assert.Equal(chapterList.Last(), chapterList.GetFirstChapterWithFiles()); + } + + + #endregion } -} \ No newline at end of file +} diff --git a/API.Tests/Comparers/NaturalSortComparerTest.cs b/API.Tests/Extensions/EnumerableExtensionsTests.cs similarity index 64% rename from API.Tests/Comparers/NaturalSortComparerTest.cs rename to API.Tests/Extensions/EnumerableExtensionsTests.cs index b624caac8..0f04ac9d7 100644 --- a/API.Tests/Comparers/NaturalSortComparerTest.cs +++ b/API.Tests/Extensions/EnumerableExtensionsTests.cs @@ -1,15 +1,12 @@ -using System; -using System.Linq; -using API.Comparators; +using System.Linq; +using API.Extensions; using Xunit; -namespace API.Tests.Comparers -{ - public class NaturalSortComparerTest - { - private readonly NaturalSortComparer _nc = new NaturalSortComparer(); +namespace API.Tests.Extensions; - [Theory] +public class EnumerableExtensionsTests +{ + [Theory] [InlineData( new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} @@ -46,19 +43,43 @@ namespace API.Tests.Comparers new[] {"Solo Leveling - c000 (v01) - p000 [Cover] [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p001 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p002 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p003 [dig] [Yen Press] [LuCaZ].jpg"}, new[] {"Solo Leveling - c000 (v01) - p000 [Cover] [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p001 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p002 [dig] [Yen Press] [LuCaZ].jpg", "Solo Leveling - c000 (v01) - p003 [dig] [Yen Press] [LuCaZ].jpg"} )] - public void TestNaturalSortComparer(string[] input, string[] expected) + [InlineData( + new[] {"Marvel2In1-7", "Marvel2In1-7-01", "Marvel2In1-7-02"}, + new[] {"Marvel2In1-7", "Marvel2In1-7-01", "Marvel2In1-7-02"} + )] + [InlineData( + new[] {"001", "002", "!001"}, + new[] {"!001", "001", "002"} + )] + [InlineData( + new[] {"001.jpg", "002.jpg", "!001.jpg"}, + new[] {"!001.jpg", "001.jpg", "002.jpg"} + )] + [InlineData( + new[] {"001", "002", "!002"}, + new[] {"!002", "001", "002"} + )] + [InlineData( + new[] {"001", ""}, + new[] {"", "001"} + )] + [InlineData( + new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.020/001.jpg"}, + new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.020/001.jpg"} + )] + [InlineData( + new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"}, + new[] {@"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\001.jpg", @"F:\/Anime_Series_Pelis/MANGA/Mangahere (EN)\Kirara Fantasia\_Ch.001\002.jpg"} + )] + [InlineData( + new[] {"01/001.jpg", "001.jpg"}, + new[] {"001.jpg", "01/001.jpg"} + )] + public void TestNaturalSort(string[] input, string[] expected) { - Array.Sort(input, _nc); - - var i = 0; - foreach (var s in input) - { - Assert.Equal(s, expected[i]); - i++; - } + Assert.Equal(expected, input.OrderByNatural(x => x).ToArray()); } - [Theory] [InlineData( new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, @@ -84,6 +105,10 @@ namespace API.Tests.Comparers new[] {"001.jpg", "10.jpg",}, new[] {"001.jpg", "10.jpg",} )] + [InlineData( + new[] {"001", "002", "!001"}, + new[] {"!001", "001", "002"} + )] [InlineData( new[] {"10/001.jpg", "10.jpg",}, new[] {"10.jpg", "10/001.jpg",} @@ -92,9 +117,13 @@ namespace API.Tests.Comparers new[] {"Batman - Black white vol 1 #04.cbr", "Batman - Black white vol 1 #03.cbr", "Batman - Black white vol 1 #01.cbr", "Batman - Black white vol 1 #02.cbr"}, new[] {"Batman - Black white vol 1 #01.cbr", "Batman - Black white vol 1 #02.cbr", "Batman - Black white vol 1 #03.cbr", "Batman - Black white vol 1 #04.cbr"} )] - public void TestNaturalSortComparerLinq(string[] input, string[] expected) + [InlineData( + new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg"}, + new[] {"Honzuki no Gekokujou_ Part 2/_Ch.019/001.jpg", "Honzuki no Gekokujou_ Part 2/_Ch.019/002.jpg"} + )] + public void TestNaturalSortLinq(string[] input, string[] expected) { - var output = input.OrderBy(c => c, _nc); + var output = input.OrderByNatural(x => x); var i = 0; foreach (var s in output) @@ -103,5 +132,4 @@ namespace API.Tests.Comparers i++; } } - } } diff --git a/API.Tests/Extensions/FilterDtoExtensionsTests.cs b/API.Tests/Extensions/FilterDtoExtensionsTests.cs new file mode 100644 index 000000000..c9985f509 --- /dev/null +++ b/API.Tests/Extensions/FilterDtoExtensionsTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Filtering; +using API.Entities.Enums; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class FilterDtoExtensionsTests +{ + [Fact] + public void GetSqlFilter_ShouldReturnAllFormats() + { + var filter = new FilterDto() + { + Formats = null + }; + + Assert.Equal(Enum.GetValues(), filter.GetSqlFilter()); + } + + [Fact] + public void GetSqlFilter_ShouldReturnAllFormats2() + { + var filter = new FilterDto() + { + Formats = new List() + }; + + Assert.Equal(Enum.GetValues(), filter.GetSqlFilter()); + } + + [Fact] + public void GetSqlFilter_ShouldReturnJust2() + { + var formats = new List() + { + MangaFormat.Archive, MangaFormat.Epub + }; + var filter = new FilterDto() + { + Formats = formats + }; + + Assert.Equal(formats, filter.GetSqlFilter()); + } +} diff --git a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index f6119cc69..e7c8e9994 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -1,15 +1,27 @@ using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; using System.Linq; using API.Entities.Enums; using API.Extensions; using API.Parser; +using API.Services; using API.Tests.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Extensions { public class ParserInfoListExtensions { + private readonly DefaultParser _defaultParser; + public ParserInfoListExtensions() + { + _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) @@ -17,7 +29,7 @@ namespace API.Tests.Extensions 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)] @@ -27,7 +39,7 @@ namespace API.Tests.Extensions var infos = new List(); foreach (var filename in inputInfos) { - infos.Add(API.Parser.Parser.Parse( + infos.Add(_defaultParser.Parse( filename, string.Empty)); } @@ -38,4 +50,4 @@ namespace API.Tests.Extensions Assert.Equal(expectedHasInfo, infos.HasInfo(chapter)); } } -} \ No newline at end of file +} diff --git a/API.Tests/Extensions/PathExtensionsTests.cs b/API.Tests/Extensions/PathExtensionsTests.cs new file mode 100644 index 000000000..bdc752a92 --- /dev/null +++ b/API.Tests/Extensions/PathExtensionsTests.cs @@ -0,0 +1,20 @@ +using System.IO; +using Xunit; +using API.Extensions; + +namespace API.Tests.Extensions; + +public class PathExtensionsTests +{ + #region GetFullPathWithoutExtension + + [Theory] + [InlineData("joe.png", "joe")] + [InlineData("c:/directory/joe.png", "c:/directory/joe")] + public void GetFullPathWithoutExtension_Test(string input, string expected) + { + Assert.Equal(Path.GetFullPath(expected), input.GetFullPathWithoutExtension()); + } + + #endregion +} diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index 35054770b..fc5b5b8ca 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -1,6 +1,11 @@ -using API.Entities; +using System.Linq; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Parser; +using API.Services.Tasks.Scanner; +using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions @@ -31,6 +36,38 @@ namespace API.Tests.Extensions Assert.Equal(expected, series.NameInList(list)); } + [Theory] + [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker than Black"}, MangaFormat.Archive, true)] + [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker_than_Black"}, MangaFormat.Archive, true)] + [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, new [] {"Darker then Black!"}, MangaFormat.Archive, false)] + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"Salem's Lot"}, MangaFormat.Archive, true)] + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salems lot"}, MangaFormat.Archive, true)] + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot"}, new [] {"salem's lot"}, MangaFormat.Archive, true)] + // Different normalizations pass as we check normalization against an on-the-fly calculation so we don't delete series just because we change how normalization works + [InlineData(new [] {"Salem's Lot", "Salem's Lot", "Salem's Lot", "salems lot"}, new [] {"salem's lot"}, MangaFormat.Archive, true)] + [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, new [] {"Kanojo, Okarishimasu"}, MangaFormat.Archive, true)] + public void NameInListParserInfoTest(string[] seriesInput, string[] list, MangaFormat format, bool expected) + { + var series = new Series() + { + Name = seriesInput[0], + LocalizedName = seriesInput[1], + OriginalName = seriesInput[2], + NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Parser.Parser.Normalize(seriesInput[0]), + Metadata = new SeriesMetadata(), + }; + + var parserInfos = list.Select(s => new ParsedSeries() + { + Name = s, + NormalizedName = API.Parser.Parser.Normalize(s), + }).ToList(); + + // This doesn't do any checks against format + Assert.Equal(expected, series.NameInList(parserInfos)); + } + + [Theory] [InlineData(new [] {"Darker than Black", "Darker Than Black", "Darker than Black"}, "Darker than Black", true)] [InlineData(new [] {"Rent-a-Girlfriend", "Rent-a-Girlfriend", "Kanojo, Okarishimasu", "rentagirlfriend"}, "Kanojo, Okarishimasu", true)] diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs new file mode 100644 index 000000000..48d39aa24 --- /dev/null +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Tests.Helpers; +using Xunit; + +namespace API.Tests.Extensions; + +public class VolumeListExtensionsTests +{ + #region FirstWithChapters + + [Fact] + public void FirstWithChapters_ReturnsVolumeWithChapters() + { + var volumes = new List() + { + EntityFactory.CreateVolume("0", new List()), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("2", false), + }), + }; + + Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(false).Number); + Assert.Equal(volumes[1].Number, volumes.FirstWithChapters(true).Number); + } + + [Fact] + public void FirstWithChapters_Book() + { + var volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(true).Number); + } + + [Fact] + public void FirstWithChapters_NonBook() + { + var volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[0].Number, volumes.FirstWithChapters(false).Number); + } + + #endregion + + #region GetCoverImage + + [Fact] + public void GetCoverImage_ArchiveFormat() + { + var volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[0].Number, volumes.GetCoverImage(MangaFormat.Archive).Number); + } + + [Fact] + public void GetCoverImage_EpubFormat() + { + var volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Epub).Name); + } + + [Fact] + public void GetCoverImage_PdfFormat() + { + var volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Pdf).Name); + } + + [Fact] + public void GetCoverImage_ImageFormat() + { + var volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[0].Name, volumes.GetCoverImage(MangaFormat.Image).Name); + } + + [Fact] + public void GetCoverImage_ImageFormat_NoSpecials() + { + var volumes = new List() + { + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("3", false), + EntityFactory.CreateChapter("4", false), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false), + EntityFactory.CreateChapter("0", true), + }), + }; + + Assert.Equal(volumes[1].Name, volumes.GetCoverImage(MangaFormat.Image).Name); + } + + + #endregion +} diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs new file mode 100644 index 000000000..9d1024ff0 --- /dev/null +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using API.Entities; +using API.Helpers; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Helpers; + +public class CacheHelperTests +{ + private const string TestCoverImageDirectory = @"c:\"; + private const string TestCoverImageFile = "thumbnail.jpg"; + private readonly string _testCoverPath = Path.Join(TestCoverImageDirectory, TestCoverImageFile); + private const string TestCoverArchive = @"file in folder.zip"; + private readonly ICacheHelper _cacheHelper; + + public CacheHelperTests() + { + var file = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), file }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), file } + }); + + var fileService = new FileService(fileSystem); + _cacheHelper = new CacheHelper(fileService); + } + + [Theory] + [InlineData("", false)] + [InlineData("C:/", false)] + [InlineData(null, false)] + public void CoverImageExists_DoesFileExist(string coverImage, bool exists) + { + Assert.Equal(exists, _cacheHelper.CoverImageExists(coverImage)); + } + + [Fact] + public void CoverImageExists_FileExists() + { + Assert.True(_cacheHelper.CoverImageExists(TestCoverArchive)); + } + + [Fact] + public void ShouldUpdateCoverImage_OnFirstRun() + { + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.True(_cacheHelper.ShouldUpdateCoverImage(null, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, false)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, false)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked_2() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now, + false, false)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, true)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked_Modified() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, true)); + } + + [Fact] + public void ShouldUpdateCoverImage_CoverImageSetAndReplaced_Modified() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var created = DateTime.Now.Subtract(TimeSpan.FromHours(1)); + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + Assert.True(cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, created, + false, false)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceCreated() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.True(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.True(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified_ForceUpdate() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, true, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)), + LastModified = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)) + }; + + var file = new MangaFile() + { + FilePath = Path.Join(TestCoverImageDirectory, TestCoverArchive), + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan_ButLastModifiedSame() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)), + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = Path.Join(TestCoverImageDirectory, TestCoverArchive), + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + +} diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index 456cd1b52..7b55df108 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -1,6 +1,8 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; namespace API.Tests.Helpers { @@ -27,6 +29,7 @@ namespace API.Tests.Helpers return new Volume() { Name = volumeNumber, + Number = int.Parse(volumeNumber), Pages = 0, Chapters = chapters ?? new List() }; @@ -75,4 +78,4 @@ namespace API.Tests.Helpers }; } } -} \ No newline at end of file +} diff --git a/API.Tests/Helpers/GenreHelperTests.cs b/API.Tests/Helpers/GenreHelperTests.cs new file mode 100644 index 000000000..0d0aebe0c --- /dev/null +++ b/API.Tests/Helpers/GenreHelperTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using API.Data; +using API.Entities; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class GenreHelperTests +{ + [Fact] + public void UpdateGenre_ShouldAddNewGenre() + { + var allGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + }; + var genreAdded = new List(); + + GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, false, genre => + { + genreAdded.Add(genre); + }); + + Assert.Equal(2, genreAdded.Count); + Assert.Equal(4, allGenres.Count); + } + + [Fact] + public void UpdateGenre_ShouldNotAddDuplicateGenre() + { + var allGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + + }; + var genreAdded = new List(); + + GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, false, genre => + { + genreAdded.Add(genre); + }); + + Assert.Equal(3, allGenres.Count); + } + + [Fact] + public void AddGenre_ShouldAddOnlyNonExistingGenre() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + }; + + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", false)); + Assert.Equal(3, existingGenres.Count); + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("action", false)); + Assert.Equal(3, existingGenres.Count); + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Shonen", false)); + Assert.Equal(4, existingGenres.Count); + } + + [Fact] + public void AddGenre_ShouldNotAddSameNameAndExternal() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + }; + + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", true)); + Assert.Equal(3, existingGenres.Count); + } + + [Fact] + public void KeepOnlySamePeopleBetweenLists() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("Sci-fi", false), + }; + + var peopleFromChapters = new List + { + DbFactory.Genre("Action", false), + }; + + var genreRemoved = new List(); + GenreHelper.KeepOnlySameGenreBetweenLists(existingGenres, + peopleFromChapters, genre => + { + genreRemoved.Add(genre); + }); + + Assert.Equal(1, genreRemoved.Count); + } +} diff --git a/API.Tests/Helpers/ParserInfoFactory.cs b/API.Tests/Helpers/ParserInfoFactory.cs index 7dcf564e1..2dc2f2869 100644 --- a/API.Tests/Helpers/ParserInfoFactory.cs +++ b/API.Tests/Helpers/ParserInfoFactory.cs @@ -1,6 +1,10 @@ -using System.IO; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; using API.Entities.Enums; using API.Parser; +using API.Services.Tasks.Scanner; namespace API.Tests.Helpers { @@ -21,5 +25,49 @@ namespace API.Tests.Helpers Volumes = volumes }; } + + public static void AddToParsedInfo(IDictionary> collectedSeries, ParserInfo info) + { + var existingKey = collectedSeries.Keys.FirstOrDefault(ps => + ps.Format == info.Format && ps.NormalizedName == API.Parser.Parser.Normalize(info.Series)); + existingKey ??= new ParsedSeries() + { + Format = info.Format, + Name = info.Series, + NormalizedName = API.Parser.Parser.Normalize(info.Series) + }; + if (collectedSeries.GetType() == typeof(ConcurrentDictionary<,>)) + { + ((ConcurrentDictionary>) collectedSeries).AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => + { + oldValue ??= new List(); + if (!oldValue.Contains(info)) + { + oldValue.Add(info); + } + + return oldValue; + }); + } + else + { + if (!collectedSeries.ContainsKey(existingKey)) + { + collectedSeries.Add(existingKey, new List() {info}); + } + else + { + var list = collectedSeries[existingKey]; + if (!list.Contains(info)) + { + list.Add(info); + } + + collectedSeries[existingKey] = list; + } + + } + + } } -} \ No newline at end of file +} diff --git a/API.Tests/Helpers/ParserInfoHelperTests.cs b/API.Tests/Helpers/ParserInfoHelperTests.cs new file mode 100644 index 000000000..d3b58d96b --- /dev/null +++ b/API.Tests/Helpers/ParserInfoHelperTests.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Parser; +using API.Services.Tasks.Scanner; +using Xunit; + +namespace API.Tests.Helpers; + +public class ParserInfoHelperTests +{ + #region SeriesHasMatchingParserInfoFormat + + [Fact] + public void SeriesHasMatchingParserInfoFormat_ShouldBeFalse() + { + 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}); + + var series = new Series() + { + Name = "Darker Than Black", + LocalizedName = "Darker Than Black", + OriginalName = "Darker Than Black", + Volumes = new List() + { + new Volume() + { + Number = 1, + Name = "1" + } + }, + NormalizedName = API.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata(), + Format = MangaFormat.Epub + }; + + Assert.False(ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(series, infos)); + } + + [Fact] + public void SeriesHasMatchingParserInfoFormat_ShouldBeTrue() + { + var infos = new Dictionary>(); + + ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Archive}); + ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Epub}); + + var series = new Series() + { + Name = "Darker Than Black", + LocalizedName = "Darker Than Black", + OriginalName = "Darker Than Black", + Volumes = new List() + { + new Volume() + { + Number = 1, + Name = "1" + } + }, + NormalizedName = API.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata(), + Format = MangaFormat.Epub + }; + + Assert.True(ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(series, infos)); + } + + #endregion +} diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs new file mode 100644 index 000000000..f5b5551ae --- /dev/null +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class PersonHelperTests +{ + [Fact] + public void UpdatePeople_ShouldAddNewPeople() + { + var allPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer) + }; + var peopleAdded = new List(); + + PersonHelper.UpdatePeople(allPeople, new[] {"Joseph Shmo", "Sally Ann"}, PersonRole.Writer, person => + { + peopleAdded.Add(person); + }); + + Assert.Equal(2, peopleAdded.Count); + Assert.Equal(4, allPeople.Count); + } + + [Fact] + public void UpdatePeople_ShouldNotAddDuplicatePeople() + { + var allPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Sally Ann", PersonRole.CoverArtist), + + }; + var peopleAdded = new List(); + + PersonHelper.UpdatePeople(allPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.CoverArtist, person => + { + peopleAdded.Add(person); + }); + + Assert.Equal(3, allPeople.Count); + } + + [Fact] + public void RemovePeople_ShouldRemovePeopleOfSameRole() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer) + }; + var peopleRemoved = new List(); + PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => + { + peopleRemoved.Add(person); + }); + + Assert.NotEqual(existingPeople, peopleRemoved); + Assert.Equal(1, peopleRemoved.Count); + } + + [Fact] + public void RemovePeople_ShouldRemovePeopleFromBothRoles() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer) + }; + var peopleRemoved = new List(); + PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => + { + peopleRemoved.Add(person); + }); + + Assert.NotEqual(existingPeople, peopleRemoved); + Assert.Equal(1, peopleRemoved.Count); + + PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo"}, PersonRole.CoverArtist, person => + { + peopleRemoved.Add(person); + }); + + Assert.Equal(0, existingPeople.Count); + Assert.Equal(2, peopleRemoved.Count); + } + + [Fact] + public void KeepOnlySamePeopleBetweenLists() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Sally", PersonRole.Writer), + }; + + var peopleFromChapters = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + }; + + var peopleRemoved = new List(); + PersonHelper.KeepOnlySamePeopleBetweenLists(existingPeople, + peopleFromChapters, person => + { + peopleRemoved.Add(person); + }); + + Assert.Equal(2, peopleRemoved.Count); + } + + [Fact] + public void AddPeople_ShouldAddOnlyNonExistingPeople() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Sally", PersonRole.Writer), + }; + + + PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo", PersonRole.CoverArtist)); + Assert.Equal(3, existingPeople.Count); + + PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo", PersonRole.Writer)); + Assert.Equal(3, existingPeople.Count); + + PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo Two", PersonRole.CoverArtist)); + Assert.Equal(4, existingPeople.Count); + } +} diff --git a/API.Tests/Helpers/SeriesHelperTests.cs b/API.Tests/Helpers/SeriesHelperTests.cs new file mode 100644 index 000000000..8acd4bb85 --- /dev/null +++ b/API.Tests/Helpers/SeriesHelperTests.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Services.Tasks.Scanner; +using Xunit; + +namespace API.Tests.Helpers; + +public class SeriesHelperTests +{ + #region FindSeries + [Fact] + public void FindSeries_ShouldFind_SameFormat() + { + var series = DbFactory.Series("Darker than Black"); + series.OriginalName = "Something Random"; + series.Format = MangaFormat.Archive; + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Darker than Black", + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Darker than Black".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Darker than Black".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + } + + [Fact] + public void FindSeries_ShouldNotFind_WrongFormat() + { + var series = DbFactory.Series("Darker than Black"); + series.OriginalName = "Something Random"; + series.Format = MangaFormat.Archive; + Assert.False(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Darker than Black", + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.False(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Darker than Black".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.False(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Darker than Black".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + } + + [Fact] + public void FindSeries_ShouldFind_UsingOriginalName() + { + var series = DbFactory.Series("Darker than Black"); + series.OriginalName = "Something Random"; + series.Format = MangaFormat.Image; + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random", + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "SomethingRandom".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("SomethingRandom") + })); + } + + [Fact] + public void FindSeries_ShouldFind_UsingLocalizedName() + { + var series = DbFactory.Series("Darker than Black"); + series.LocalizedName = "Something Random"; + series.Format = MangaFormat.Image; + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random", + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "SomethingRandom".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("SomethingRandom") + })); + } + #endregion + + [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 = SeriesHelper.RemoveMissingSeries(existingSeries, missingSeries, out var removeCount).ToList(); + + Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); + Assert.Equal(missingSeries.Count, removeCount); + } +} diff --git a/API.Tests/Helpers/TagHelperTests.cs b/API.Tests/Helpers/TagHelperTests.cs new file mode 100644 index 000000000..5370d9971 --- /dev/null +++ b/API.Tests/Helpers/TagHelperTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using API.Data; +using API.Entities; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class TagHelperTests +{ + [Fact] + public void UpdateTag_ShouldAddNewTag() + { + var allTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + }; + var tagAdded = new List(); + + TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, false, (tag, added) => + { + if (added) + { + tagAdded.Add(tag); + } + + }); + + Assert.Equal(1, tagAdded.Count); + Assert.Equal(4, allTags.Count); + } + + [Fact] + public void UpdateTag_ShouldNotAddDuplicateTag() + { + var allTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + + }; + var tagAdded = new List(); + + TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, false, (tag, added) => + { + if (added) + { + tagAdded.Add(tag); + } + TagHelper.AddTagIfNotExists(allTags, tag); + }); + + Assert.Equal(3, allTags.Count); + Assert.Empty(tagAdded); + } + + [Fact] + public void AddTag_ShouldAddOnlyNonExistingTag() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + }; + + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", false)); + Assert.Equal(3, existingTags.Count); + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action", false)); + Assert.Equal(3, existingTags.Count); + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen", false)); + Assert.Equal(4, existingTags.Count); + } + + [Fact] + public void AddTag_ShouldNotAddSameNameAndExternal() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("action", false), + DbFactory.Tag("Sci-fi", false), + }; + + + TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", true)); + Assert.Equal(3, existingTags.Count); + } + + [Fact] + public void KeepOnlySamePeopleBetweenLists() + { + var existingTags = new List + { + DbFactory.Tag("Action", false), + DbFactory.Tag("Sci-fi", false), + }; + + var peopleFromChapters = new List + { + DbFactory.Tag("Action", false), + }; + + var tagRemoved = new List(); + TagHelper.KeepOnlySameTagBetweenLists(existingTags, + peopleFromChapters, tag => + { + tagRemoved.Add(tag); + }); + + Assert.Equal(1, tagRemoved.Count); + } +} diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 043c0e027..fe0cd0961 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -1,7 +1,10 @@ -using System; using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; using API.Entities.Enums; using API.Parser; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; using Xunit.Abstractions; @@ -10,10 +13,14 @@ namespace API.Tests.Parser public class ComicParserTests { private readonly ITestOutputHelper _testOutputHelper; + private readonly DefaultParser _defaultParser; public ComicParserTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; + _defaultParser = + new DefaultParser(new DirectoryService(Substitute.For>(), + new MockFileSystem())); } [Theory] @@ -61,8 +68,17 @@ namespace API.Tests.Parser [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("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.Parser.Parser.ParseComicSeries(filename)); @@ -101,6 +117,15 @@ namespace API.Tests.Parser [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.Parser.Parser.ParseComicVolume(filename)); @@ -144,6 +169,8 @@ namespace API.Tests.Parser [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.Parser.Parser.ParseComicChapter(filename)); @@ -155,76 +182,13 @@ namespace API.Tests.Parser [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)] public void ParseComicSpecialTest(string input, bool expected) { Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseComicSpecial(input))); } - - [Fact] - public void ParseInfoTest() - { - const string rootPath = @"E:/Comics/"; - var expected = new Dictionary(); - var filepath = @"E:/Comics/Teen Titans/Teen Titans v1 Annual 01 (1967) SP01.cbr"; - expected.Add(filepath, new ParserInfo - { - Series = "Teen Titans", Volumes = "0", - Chapters = "0", Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - // Fallback test with bad naming - filepath = @"E:\Comics\Comics\Babe\Babe Vol.1 #1-4\Babe 01.cbr"; - expected.Add(filepath, new ParserInfo - { - Series = "Babe", Volumes = "0", Edition = "", - Chapters = "1", Filename = "Babe 01.cbr", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - filepath = @"E:\Comics\Comics\Publisher\Batman the Detective (2021)\Batman the Detective - v6 - 11 - (2021).cbr"; - expected.Add(filepath, new ParserInfo - { - Series = "Batman the Detective", Volumes = "6", Edition = "", - Chapters = "11", Filename = "Batman the Detective - v6 - 11 - (2021).cbr", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - filepath = @"E:\Comics\Comics\Batman - The Man Who Laughs #1 (2005)\Batman - The Man Who Laughs #1 (2005).cbr"; - expected.Add(filepath, new ParserInfo - { - Series = "Batman - The Man Who Laughs", Volumes = "0", Edition = "", - Chapters = "1", Filename = "Batman - The Man Who Laughs #1 (2005).cbr", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - foreach (var file in expected.Keys) - { - var expectedInfo = expected[file]; - var actual = API.Parser.Parser.Parse(file, rootPath, LibraryType.Comic); - if (expectedInfo == null) - { - Assert.Null(actual); - return; - } - Assert.NotNull(actual); - _testOutputHelper.WriteLine($"Validating {file}"); - Assert.Equal(expectedInfo.Format, actual.Format); - _testOutputHelper.WriteLine("Format ✓"); - Assert.Equal(expectedInfo.Series, actual.Series); - _testOutputHelper.WriteLine("Series ✓"); - Assert.Equal(expectedInfo.Chapters, actual.Chapters); - _testOutputHelper.WriteLine("Chapters ✓"); - Assert.Equal(expectedInfo.Volumes, actual.Volumes); - _testOutputHelper.WriteLine("Volumes ✓"); - Assert.Equal(expectedInfo.Edition, actual.Edition); - _testOutputHelper.WriteLine("Edition ✓"); - Assert.Equal(expectedInfo.Filename, actual.Filename); - _testOutputHelper.WriteLine("Filename ✓"); - Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); - _testOutputHelper.WriteLine("FullFilePath ✓"); - } - } - } } diff --git a/API.Tests/Parser/DefaultParserTests.cs b/API.Tests/Parser/DefaultParserTests.cs new file mode 100644 index 000000000..a9c593de9 --- /dev/null +++ b/API.Tests/Parser/DefaultParserTests.cs @@ -0,0 +1,312 @@ +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using API.Entities.Enums; +using API.Parser; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Parser; + +public class DefaultParserTests +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly DefaultParser _defaultParser; + + public DefaultParserTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + var directoryService = new DirectoryService(Substitute.For>(), new MockFileSystem()); + _defaultParser = new DefaultParser(directoryService); + } + + + #region ParseFromFallbackFolders + [Theory] + [InlineData("C:/", "C:/Love Hina/Love Hina - Special.cbz", "Love Hina")] + [InlineData("C:/", "C:/Love Hina/Specials/Ani-Hina Art Collection.cbz", "Love Hina")] + [InlineData("C:/", "C:/Mujaki no Rakuen Something/Mujaki no Rakuen Vol12 ch76.cbz", "Mujaki no Rakuen")] + [InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")] + public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries) + { + var actual = _defaultParser.Parse(inputPath, rootDir); + if (actual == null) + { + Assert.NotNull(actual); + return; + } + + Assert.Equal(expectedSeries, actual.Series); + } + + [Theory] + [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")] + [InlineData("/manga/Btooom!/Vol.1 Chapter 2/1.cbz", "Btooom!~1~2")] + [InlineData("/manga/Monster #8/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", "Monster #8~0~1")] + public void ParseFromFallbackFolders_ShouldParseSeriesVolumeAndChapter(string inputFile, string expectedParseInfo) + { + const string rootDirectory = "/manga/"; + var tokens = expectedParseInfo.Split("~"); + var actual = new ParserInfo {Chapters = "0", Volumes = "0"}; + _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); + Assert.Equal(tokens[0], actual.Series); + Assert.Equal(tokens[1], actual.Volumes); + Assert.Equal(tokens[2], actual.Chapters); + } + + #endregion + + + #region Parse + + [Fact] + public void Parse_ParseInfo_Manga() + { + const string rootPath = @"E:/Manga/"; + var expected = new Dictionary(); + var filepath = @"E:/Manga/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Mujaki no Rakuen", Volumes = "12", + Chapters = "76", Filename = "Mujaki no Rakuen Vol12 ch76.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:/Manga/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", Volumes = "1", + Chapters = "0", Filename = "Vol 1.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip"; + expected.Add(filepath, new ParserInfo + { + Series = "Beelzebub", Volumes = "0", + Chapters = "1", Filename = "Beelzebub_01_[Noodles].zip", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip"; + expected.Add(filepath, new ParserInfo + { + Series = "Ichinensei ni Nacchattara", Volumes = "1", + Chapters = "1", Filename = "Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Tenjo Tenge", Volumes = "1", Edition = "Full Contact Edition", + Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)\Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "", + Chapters = "0", Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Dorohedoro\Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Dorohedoro", Volumes = "1", Edition = "", + Chapters = "0", Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\APOSIMZ\APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "APOSIMZ", Volumes = "0", Edition = "", + Chapters = "40", Filename = "APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Corpse Party Musume\Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Kedouin Makoto - Corpse Party Musume", Volumes = "0", Edition = "", + Chapters = "9", Filename = "Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Goblin Slayer\Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Goblin Slayer - Brand New Day", Volumes = "0", Edition = "", + Chapters = "6.5", Filename = "Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Summer Time Rendering\Specials\Record 014 (between chapter 083 and ch084) SP11.cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Summer Time Rendering", Volumes = "0", Edition = "", + Chapters = "0", Filename = "Record 014 (between chapter 083 and ch084) SP11.cbr", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = true + }); + + filepath = @"E:\Manga\Seraph of the End\Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Seraph of the End - Vampire Reign", Volumes = "0", Edition = "", + Chapters = "93", Filename = "Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Manga\Kono Subarashii Sekai ni Bakuen wo!\Vol. 00 Ch. 000.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Kono Subarashii Sekai ni Bakuen wo!", Volumes = "0", Edition = "", + Chapters = "0", Filename = "Vol. 00 Ch. 000.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Manga\Toukyou Akazukin\Vol. 01 Ch. 001.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Toukyou Akazukin", Volumes = "1", Edition = "", + Chapters = "1", Filename = "Vol. 01 Ch. 001.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Manga\Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub"; + expected.Add(filepath, new ParserInfo + { + Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "", + Chapters = "0", Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, + FullFilePath = filepath, IsSpecial = false + }); + + // If an image is cover exclusively, ignore it + filepath = @"E:\Manga\Seraph of the End\cover.png"; + expected.Add(filepath, null); + + filepath = @"E:\Manga\The Beginning After the End\Chapter 001.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "The Beginning After the End", Volumes = "0", Edition = "", + Chapters = "1", Filename = "Chapter 001.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Manga\Monster #8\Ch. 001-016 [MangaPlus] [Digital] [amit34521]\Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]\13.jpg"; + expected.Add(filepath, new ParserInfo + { + Series = "Monster #8", Volumes = "0", Edition = "", + Chapters = "1", Filename = "13.jpg", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Manga\Air Gear\Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Air Gear", Volumes = "1", Edition = "Omnibus", + Chapters = "0", Filename = "Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + + foreach (var file in expected.Keys) + { + var expectedInfo = expected[file]; + var actual = _defaultParser.Parse(file, rootPath); + if (expectedInfo == null) + { + Assert.Null(actual); + return; + } + Assert.NotNull(actual); + _testOutputHelper.WriteLine($"Validating {file}"); + Assert.Equal(expectedInfo.Format, actual.Format); + _testOutputHelper.WriteLine("Format ✓"); + Assert.Equal(expectedInfo.Series, actual.Series); + _testOutputHelper.WriteLine("Series ✓"); + Assert.Equal(expectedInfo.Chapters, actual.Chapters); + _testOutputHelper.WriteLine("Chapters ✓"); + Assert.Equal(expectedInfo.Volumes, actual.Volumes); + _testOutputHelper.WriteLine("Volumes ✓"); + Assert.Equal(expectedInfo.Edition, actual.Edition); + _testOutputHelper.WriteLine("Edition ✓"); + Assert.Equal(expectedInfo.Filename, actual.Filename); + _testOutputHelper.WriteLine("Filename ✓"); + Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); + _testOutputHelper.WriteLine("FullFilePath ✓"); + } + } + + [Fact] + public void Parse_ParseInfo_Comic() + { + const string rootPath = @"E:/Comics/"; + var expected = new Dictionary(); + var filepath = @"E:/Comics/Teen Titans/Teen Titans v1 Annual 01 (1967) SP01.cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Teen Titans", Volumes = "0", + Chapters = "0", Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + // Fallback test with bad naming + filepath = @"E:\Comics\Comics\Babe\Babe Vol.1 #1-4\Babe 01.cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Babe", Volumes = "0", Edition = "", + Chapters = "1", Filename = "Babe 01.cbr", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Comics\Comics\Publisher\Batman the Detective (2021)\Batman the Detective - v6 - 11 - (2021).cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Batman the Detective", Volumes = "6", Edition = "", + Chapters = "11", Filename = "Batman the Detective - v6 - 11 - (2021).cbr", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + filepath = @"E:\Comics\Comics\Batman - The Man Who Laughs #1 (2005)\Batman - The Man Who Laughs #1 (2005).cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Batman - The Man Who Laughs", Volumes = "0", Edition = "", + Chapters = "1", Filename = "Batman - The Man Who Laughs #1 (2005).cbr", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + foreach (var file in expected.Keys) + { + var expectedInfo = expected[file]; + var actual = _defaultParser.Parse(file, rootPath, LibraryType.Comic); + if (expectedInfo == null) + { + Assert.Null(actual); + return; + } + Assert.NotNull(actual); + _testOutputHelper.WriteLine($"Validating {file}"); + Assert.Equal(expectedInfo.Format, actual.Format); + _testOutputHelper.WriteLine("Format ✓"); + Assert.Equal(expectedInfo.Series, actual.Series); + _testOutputHelper.WriteLine("Series ✓"); + Assert.Equal(expectedInfo.Chapters, actual.Chapters); + _testOutputHelper.WriteLine("Chapters ✓"); + Assert.Equal(expectedInfo.Volumes, actual.Volumes); + _testOutputHelper.WriteLine("Volumes ✓"); + Assert.Equal(expectedInfo.Edition, actual.Edition); + _testOutputHelper.WriteLine("Edition ✓"); + Assert.Equal(expectedInfo.Filename, actual.Filename); + _testOutputHelper.WriteLine("Filename ✓"); + Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); + _testOutputHelper.WriteLine("FullFilePath ✓"); + } + } + #endregion +} diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 1576cbb07..fe4dd5e42 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -167,6 +167,9 @@ namespace API.Tests.Parser [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")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); @@ -240,6 +243,8 @@ namespace API.Tests.Parser [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")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename)); @@ -255,6 +260,7 @@ namespace API.Tests.Parser [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", "Full Color")] + [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")] public void ParseEditionTest(string input, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseEdition(input)); @@ -272,6 +278,8 @@ namespace API.Tests.Parser [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.Parser.Parser.ParseMangaSpecial(input))); @@ -294,194 +302,6 @@ namespace API.Tests.Parser } - [Theory] - [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")] - [InlineData("/manga/Btooom!/Vol.1 Chapter 2/1.cbz", "Btooom!~1~2")] - [InlineData("/manga/Monster #8/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", "Monster #8~0~1")] - public void ParseFromFallbackFoldersTest(string inputFile, string expectedParseInfo) - { - const string rootDirectory = "/manga/"; - var tokens = expectedParseInfo.Split("~"); - var actual = new ParserInfo {Chapters = "0", Volumes = "0"}; - API.Parser.Parser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); - Assert.Equal(tokens[0], actual.Series); - Assert.Equal(tokens[1], actual.Volumes); - Assert.Equal(tokens[2], actual.Chapters); - } - - [Fact] - public void ParseInfoTest() - { - const string rootPath = @"E:/Manga/"; - var expected = new Dictionary(); - var filepath = @"E:/Manga/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Mujaki no Rakuen", Volumes = "12", - Chapters = "76", Filename = "Mujaki no Rakuen Vol12 ch76.cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:/Manga/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", Volumes = "1", - Chapters = "0", Filename = "Vol 1.cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip"; - expected.Add(filepath, new ParserInfo - { - Series = "Beelzebub", Volumes = "0", - Chapters = "1", Filename = "Beelzebub_01_[Noodles].zip", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip"; - expected.Add(filepath, new ParserInfo - { - Series = "Ichinensei ni Nacchattara", Volumes = "1", - Chapters = "1", Filename = "Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Tenjo Tenge", Volumes = "1", Edition = "Full Contact Edition", - Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)\Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "", - Chapters = "0", Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Dorohedoro\Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Dorohedoro", Volumes = "1", Edition = "", - Chapters = "0", Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\APOSIMZ\APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "APOSIMZ", Volumes = "0", Edition = "", - Chapters = "40", Filename = "APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Corpse Party Musume\Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Kedouin Makoto - Corpse Party Musume", Volumes = "0", Edition = "", - Chapters = "9", Filename = "Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Goblin Slayer\Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Goblin Slayer - Brand New Day", Volumes = "0", Edition = "", - Chapters = "6.5", Filename = "Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, - FullFilePath = filepath - }); - - filepath = @"E:\Manga\Summer Time Rendering\Specials\Record 014 (between chapter 083 and ch084) SP11.cbr"; - expected.Add(filepath, new ParserInfo - { - Series = "Summer Time Rendering", Volumes = "0", Edition = "", - Chapters = "0", Filename = "Record 014 (between chapter 083 and ch084) SP11.cbr", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = true - }); - - filepath = @"E:\Manga\Seraph of the End\Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Seraph of the End - Vampire Reign", Volumes = "0", Edition = "", - Chapters = "93", Filename = "Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - filepath = @"E:\Manga\Kono Subarashii Sekai ni Bakuen wo!\Vol. 00 Ch. 000.cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Kono Subarashii Sekai ni Bakuen wo!", Volumes = "0", Edition = "", - Chapters = "0", Filename = "Vol. 00 Ch. 000.cbz", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - filepath = @"E:\Manga\Toukyou Akazukin\Vol. 01 Ch. 001.cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "Toukyou Akazukin", Volumes = "1", Edition = "", - Chapters = "1", Filename = "Vol. 01 Ch. 001.cbz", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - filepath = @"E:\Manga\Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub"; - expected.Add(filepath, new ParserInfo - { - Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "", - Chapters = "0", Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, - FullFilePath = filepath, IsSpecial = false - }); - - // If an image is cover exclusively, ignore it - filepath = @"E:\Manga\Seraph of the End\cover.png"; - expected.Add(filepath, null); - - filepath = @"E:\Manga\The Beginning After the End\Chapter 001.cbz"; - expected.Add(filepath, new ParserInfo - { - Series = "The Beginning After the End", Volumes = "0", Edition = "", - Chapters = "1", Filename = "Chapter 001.cbz", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - filepath = @"E:\Manga\Monster #8\Ch. 001-016 [MangaPlus] [Digital] [amit34521]\Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]\13.jpg"; - expected.Add(filepath, new ParserInfo - { - Series = "Monster #8", Volumes = "0", Edition = "", - Chapters = "1", Filename = "13.jpg", Format = MangaFormat.Archive, - FullFilePath = filepath, IsSpecial = false - }); - - - foreach (var file in expected.Keys) - { - var expectedInfo = expected[file]; - var actual = API.Parser.Parser.Parse(file, rootPath); - if (expectedInfo == null) - { - Assert.Null(actual); - return; - } - Assert.NotNull(actual); - _testOutputHelper.WriteLine($"Validating {file}"); - Assert.Equal(expectedInfo.Format, actual.Format); - _testOutputHelper.WriteLine("Format ✓"); - Assert.Equal(expectedInfo.Series, actual.Series); - _testOutputHelper.WriteLine("Series ✓"); - Assert.Equal(expectedInfo.Chapters, actual.Chapters); - _testOutputHelper.WriteLine("Chapters ✓"); - Assert.Equal(expectedInfo.Volumes, actual.Volumes); - _testOutputHelper.WriteLine("Volumes ✓"); - Assert.Equal(expectedInfo.Edition, actual.Edition); - _testOutputHelper.WriteLine("Edition ✓"); - Assert.Equal(expectedInfo.Filename, actual.Filename); - _testOutputHelper.WriteLine("Filename ✓"); - Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); - _testOutputHelper.WriteLine("FullFilePath ✓"); - } - } } } diff --git a/API.Tests/Parser/ParserInfoTests.cs b/API.Tests/Parser/ParserInfoTests.cs index 78b879de7..16906cf55 100644 --- a/API.Tests/Parser/ParserInfoTests.cs +++ b/API.Tests/Parser/ParserInfoTests.cs @@ -20,7 +20,7 @@ namespace API.Tests.Parser Title = "darker than black", Volumes = "0" }; - + var p2 = new ParserInfo() { Chapters = "1", @@ -32,7 +32,7 @@ namespace API.Tests.Parser Title = "Darker Than Black", Volumes = "0" }; - + var expected = new ParserInfo() { Chapters = "1", @@ -45,11 +45,11 @@ namespace API.Tests.Parser Volumes = "0" }; p1.Merge(p2); - + AssertSame(expected, p1); } - + [Fact] public void MergeFromTest2() { @@ -64,7 +64,7 @@ namespace API.Tests.Parser Title = "darker than black", Volumes = "0" }; - + var p2 = new ParserInfo() { Chapters = "0", @@ -76,7 +76,7 @@ namespace API.Tests.Parser Title = "Darker Than Black", Volumes = "1" }; - + var expected = new ParserInfo() { Chapters = "1", @@ -93,9 +93,9 @@ namespace API.Tests.Parser AssertSame(expected, p1); } - - private void AssertSame(ParserInfo expected, ParserInfo actual) + + private static void AssertSame(ParserInfo expected, ParserInfo actual) { Assert.Equal(expected.Chapters, actual.Chapters); Assert.Equal(expected.Volumes, actual.Volumes); @@ -107,4 +107,4 @@ namespace API.Tests.Parser Assert.Equal(expected.FullFilePath, actual.FullFilePath); } } -} \ No newline at end of file +} diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 8fdf0509d..5068b39b3 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -1,3 +1,5 @@ +using System.Linq; +using API.Entities.Enums; using Xunit; using static API.Parser.Parser; @@ -5,6 +7,14 @@ namespace API.Tests.Parser { 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("Beastars - SP01", true)] @@ -40,6 +50,8 @@ namespace API.Tests.Parser [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")] public void CleanTitleTest(string input, bool isComic, string expected) @@ -47,20 +59,28 @@ namespace API.Tests.Parser 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("@font-face{font-family:\"PaytoneOne\";src:url(\"..\\/Fonts\\/PaytoneOne.ttf\")}", "@font-face{font-family:\"PaytoneOne\";src:url(\"PaytoneOne.ttf\")}")] - // [InlineData("@font-face{font-family:\"PaytoneOne\";src:url(\"..\\/Fonts\\/PaytoneOne.ttf\")}", "..\\/Fonts\\/PaytoneOne.ttf")] - // //[InlineData("@font-face{font-family:'PaytoneOne';src:url('..\\/Fonts\\/PaytoneOne.ttf')}", "@font-face{font-family:'PaytoneOne';src:url('PaytoneOne.ttf')}")] - // //[InlineData("@font-face{\r\nfont-family:'PaytoneOne';\r\nsrc:url('..\\/Fonts\\/PaytoneOne.ttf')\r\n}", "@font-face{font-family:'PaytoneOne';src:url('PaytoneOne.ttf')}")] - // public void ReplaceStyleUrlTest(string input, string expected) - // { - // var replacementStr = "PaytoneOne.ttf"; - // // Use Match to validate since replace is weird - // //Assert.Equal(expected, FontSrcUrlRegex.Replace(input, "$1" + replacementStr + "$2" + "$3")); - // var match = FontSrcUrlRegex.Match(input); - // Assert.Equal(!string.IsNullOrEmpty(expected), FontSrcUrlRegex.Match(input).Success); - // } + [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] @@ -132,28 +152,14 @@ namespace API.Tests.Parser [InlineData("test.jpeg", true)] [InlineData("test.png", true)] [InlineData(".test.jpg", false)] - [InlineData("!test.jpg", false)] + [InlineData("!test.jpg", true)] [InlineData("test.webp", true)] public void IsImageTest(string filename, bool expected) { Assert.Equal(expected, IsImage(filename)); } - [Theory] - [InlineData("C:/", "C:/Love Hina/Love Hina - Special.cbz", "Love Hina")] - [InlineData("C:/", "C:/Love Hina/Specials/Ani-Hina Art Collection.cbz", "Love Hina")] - [InlineData("C:/", "C:/Mujaki no Rakuen Something/Mujaki no Rakuen Vol12 ch76.cbz", "Mujaki no Rakuen")] - public void FallbackTest(string rootDir, string inputPath, string expectedSeries) - { - var actual = Parse(inputPath, rootDir); - if (actual == null) - { - Assert.NotNull(actual); - return; - } - Assert.Equal(expectedSeries, actual.Series); - } [Theory] [InlineData("Love Hina - Special.jpg", false)] @@ -164,6 +170,8 @@ namespace API.Tests.Parser [InlineData("cover.jpg", true)] [InlineData("cover.png", true)] [InlineData("ch1/cover.png", true)] + [InlineData("ch1/backcover.png", false)] + [InlineData("backcover.png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); @@ -174,9 +182,23 @@ namespace API.Tests.Parser [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)] 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 7bdc18f1d..526abde3e 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -1,11 +1,14 @@ using System.Diagnostics; using System.IO; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.IO.Compression; +using System.Linq; using API.Archive; using API.Data.Metadata; -using API.Interfaces.Services; using API.Services; using Microsoft.Extensions.Logging; +using NetVips; using NSubstitute; using NSubstitute.Extensions; using Xunit; @@ -19,12 +22,12 @@ namespace API.Tests.Services private readonly ArchiveService _archiveService; private readonly ILogger _logger = Substitute.For>(); private readonly ILogger _directoryServiceLogger = Substitute.For>(); - private readonly IDirectoryService _directoryService = new DirectoryService(Substitute.For>()); + private readonly IDirectoryService _directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); public ArchiveServiceTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; - _archiveService = new ArchiveService(_logger, _directoryService); + _archiveService = new ArchiveService(_logger, _directoryService, new ImageService(Substitute.For>(), _directoryService)); } [Theory] @@ -107,15 +110,15 @@ namespace API.Tests.Services var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); var extractDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives/Extraction"); - DirectoryService.ClearAndDeleteDirectory(extractDirectory); + _directoryService.ClearAndDeleteDirectory(extractDirectory); - Stopwatch sw = Stopwatch.StartNew(); + var sw = Stopwatch.StartNew(); _archiveService.ExtractArchive(Path.Join(testDirectory, archivePath), extractDirectory); var di1 = new DirectoryInfo(extractDirectory); - Assert.Equal(expectedFileCount, di1.Exists ? di1.GetFiles().Length : 0); + Assert.Equal(expectedFileCount, di1.Exists ? _directoryService.GetFiles(extractDirectory, searchOption:SearchOption.AllDirectories).Count() : 0); _testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); - DirectoryService.ClearAndDeleteDirectory(extractDirectory); + _directoryService.ClearAndDeleteDirectory(extractDirectory); } @@ -128,7 +131,7 @@ namespace API.Tests.Services [InlineData(new [] {"Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg", "Akame ga KILL! ZERO - c055 (v10) - p000 [Digital] [LuCaZ].jpg", "Akame ga KILL! ZERO - c060 (v10) - p200 [Digital] [LuCaZ].jpg", "folder.jpg"}, "folder.jpg")] public void FindFolderEntry(string[] files, string expected) { - var foundFile = _archiveService.FindFolderEntry(files); + var foundFile = ArchiveService.FindFolderEntry(files); Assert.Equal(expected, string.IsNullOrEmpty(foundFile) ? "" : foundFile); } @@ -141,6 +144,7 @@ namespace API.Tests.Services [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); @@ -157,27 +161,29 @@ namespace API.Tests.Services [InlineData("macos_native.zip", "macos_native.jpg")] [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.jpg")] [InlineData("sorting.zip", "sorting.expected.jpg")] + [InlineData("test.zip", "test.expected.jpg")] // https://github.com/kleisauke/net-vips/issues/155 public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) { - var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger)); - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); - var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, 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 sw = Stopwatch.StartNew(); var outputDir = Path.Join(testDirectory, "output"); - DirectoryService.ClearAndDeleteDirectory(outputDir); - DirectoryService.ExistOrCreate(outputDir); - + _directoryService.ClearDirectory(outputDir); + _directoryService.ExistOrCreate(outputDir); var coverImagePath = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), - Path.GetFileNameWithoutExtension(inputFile) + "_output"); - var actual = File.ReadAllBytes(coverImagePath); + Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir); + var actual = File.ReadAllBytes(Path.Join(outputDir, coverImagePath)); Assert.Equal(expectedBytes, actual); - _testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); - DirectoryService.ClearAndDeleteDirectory(outputDir); + //_directoryService.ClearAndDeleteDirectory(outputDir); } @@ -191,35 +197,69 @@ namespace API.Tests.Services [InlineData("sorting.zip", "sorting.expected.jpg")] public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) { - var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger)); + var imageService = new ImageService(Substitute.For>(), _directoryService); + var archiveService = Substitute.For(_logger, + new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); + var outputDir = Path.Join(testDirectory, "output"); + _directoryService.ClearDirectory(outputDir); + _directoryService.ExistOrCreate(outputDir); + archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress); - Stopwatch sw = Stopwatch.StartNew(); - Assert.Equal(expectedBytes, File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), Path.GetFileNameWithoutExtension(inputFile) + "_output"))); - _testOutputHelper.WriteLine($"Processed in {sw.ElapsedMilliseconds} ms"); + var actualBytes = File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), + Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir)); + Assert.Equal(expectedBytes, actualBytes); + + _directoryService.ClearAndDeleteDirectory(outputDir); } - // TODO: This is broken on GA due to DirectoryService.CoverImageDirectory - //[Theory] + [Theory] [InlineData("Archives/macos_native.zip")] [InlineData("Formats/One File with DB_Supported.zip")] public void CanParseCoverImage(string inputFile) { + var imageService = Substitute.For(); + imageService.WriteCoverThumbnail(Arg.Any(), Arg.Any(), Arg.Any()).Returns(x => "cover.jpg"); + var archiveService = new ArchiveService(_logger, _directoryService, imageService); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/"); - Assert.NotEmpty(File.ReadAllBytes(_archiveService.GetCoverImage(Path.Join(testDirectory, inputFile), Path.GetFileNameWithoutExtension(inputFile) + "_output"))); + var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile)); + var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output"); + new DirectoryInfo(outputPath).Create(); + var expectedImage = archiveService.GetCoverImage(inputPath, inputFile, outputPath); + Assert.Equal("cover.jpg", expectedImage); + new DirectoryInfo(outputPath).Delete(); } - [Fact] - public void ShouldHaveComicInfo() - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); - var archive = Path.Join(testDirectory, "file in folder.zip"); - var summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; + #region ShouldHaveComicInfo - Assert.Equal(summaryInfo, _archiveService.GetComicInfo(archive).Summary); - } + [Fact] + public void ShouldHaveComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo.zip"); + const string summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; + + 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); + } + + #endregion + + #region CanParseComicInfo [Fact] public void CanParseComicInfo() @@ -243,5 +283,38 @@ namespace API.Tests.Services Assert.NotStrictEqual(expected, actual); } + + #endregion + + #region FindCoverImageFilename + + [Theory] + [InlineData(new string[] {}, "", null)] + [InlineData(new [] {"001.jpg", "002.jpg"}, "Test.zip", "001.jpg")] + [InlineData(new [] {"001.jpg", "!002.jpg"}, "Test.zip", "!002.jpg")] + [InlineData(new [] {"001.jpg", "!001.jpg"}, "Test.zip", "!001.jpg")] + [InlineData(new [] {"001.jpg", "cover.jpg"}, "Test.zip", "cover.jpg")] + [InlineData(new [] {"001.jpg", "Chapter 20/cover.jpg", "Chapter 21/0001.jpg"}, "Test.zip", "Chapter 20/cover.jpg")] + [InlineData(new [] {"._/001.jpg", "._/cover.jpg", "010.jpg"}, "Test.zip", "010.jpg")] + [InlineData(new [] {"001.txt", "002.txt", "a.jpg"}, "Test.zip", "a.jpg")] + public void FindCoverImageFilename(string[] filenames, string archiveName, string expected) + { + Assert.Equal(expected, _archiveService.FindCoverImageFilename(archiveName, filenames)); + } + + + #endregion + + #region CreateZipForDownload + + //[Fact] + public void CreateZipForDownloadTest() + { + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + //_archiveService.CreateZipForDownload(new []{}, outputDirectory) + } + + #endregion } } diff --git a/API.Tests/Services/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs new file mode 100644 index 000000000..1af01632c --- /dev/null +++ b/API.Tests/Services/BackupServiceTests.cs @@ -0,0 +1,143 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using AutoMapper; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class BackupServiceTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub = Substitute.For>(); + private readonly IConfiguration _config; + + 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 LogDirectory = "C:/kavita/config/logs/"; + + public BackupServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + _config = Substitute.For(); + + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + public void Dispose() => _connection.Dispose(); + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(LogDirectory); + fileSystem.AddDirectory("C:/data/"); + + return fileSystem; + } + + #endregion + + + + #region GetLogFiles + + public void GetLogFiles_ExpectAllFiles_NoRollingFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{LogDirectory}kavita.log", new MockFileData("")); + filesystem.AddFile($"{LogDirectory}kavita1.log", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), filesystem); + // You can't mock _config extensions because they are static + _config.GetMaxRollingFiles().Returns(1); + _config.GetLoggingFileName().Returns(ds.FileSystem.Path.Join(LogDirectory, "kavita.log")); + + var backupService = new BackupService(_logger, _unitOfWork, ds, _config, _messageHub); + + Assert.Single(backupService.GetLogFiles(1, LogDirectory)); + } + + + #endregion + +} diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index d0cc29040..07fa2936d 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -1,5 +1,5 @@ using System.IO; -using API.Interfaces.Services; +using System.IO.Abstractions; using API.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -14,7 +14,8 @@ namespace API.Tests.Services public BookServiceTests() { - _bookService = new BookService(_logger); + var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); + _bookService = new BookService(_logger, directoryService, new ImageService(Substitute.For>(), directoryService)); } [Theory] @@ -27,5 +28,40 @@ namespace API.Tests.Services Assert.Equal(expectedPages, _bookService.GetNumberOfPages(Path.Join(testDirectory, filePath))); } + [Fact] + public void ShouldHaveComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService/EPUB"); + 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/EPUB"); + 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); + } + + + #region BookEscaping + + [Fact] + public void EscapeCSSImportReferencesTest() + { + + } + + #endregion + } } diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index 410c43ade..7a2cbada8 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -1,115 +1,508 @@ -namespace API.Tests.Services +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Parser; +using API.Services; +using API.SignalR; +using AutoMapper; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services { + internal class MockReadingItemServiceForCacheService : IReadingItemService + { + private readonly DirectoryService _directoryService; + + public MockReadingItemServiceForCacheService(DirectoryService directoryService) + { + _directoryService = directoryService; + } + + public ComicInfo GetComicInfo(string filePath) + { + return null; + } + + public int GetNumberOfPages(string filePath, MangaFormat format) + { + return 1; + } + + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format) + { + return string.Empty; + } + + public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1) + { + throw new System.NotImplementedException(); + } + + public ParserInfo Parse(string path, string rootPath, LibraryType type) + { + throw new System.NotImplementedException(); + } + } public class CacheServiceTests { - // private readonly CacheService _cacheService; - // private readonly ILogger _logger = Substitute.For>(); - // private readonly IUnitOfWork _unitOfWork = Substitute.For(); - // private readonly IArchiveService _archiveService = Substitute.For(); - // private readonly IDirectoryService _directoryService = Substitute.For(); - // - // public CacheServiceTests() - // { - // _cacheService = new CacheService(_logger, _unitOfWork, _archiveService, _directoryService); - // } - + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub = Substitute.For>(); + + private readonly DbConnection _connection; + private readonly DataContext _context; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string DataDirectory = "C:/data/"; + + public CacheServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + public void Dispose() => _connection.Dispose(); + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(DataDirectory); + + return fileSystem; + } + + #endregion + + #region Ensure + + [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)); + + 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)); + } + // [Fact] - // public async void Ensure_ShouldExtractArchive(int chapterId) + // public async Task Ensure_DirectoryAlreadyExists_ExtractsImages() // { - // - // // CacheDirectory needs to be customized. - // _unitOfWork.VolumeRepository.GetChapterAsync(chapterId).Returns(new Chapter + // // 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() // { - // Id = 1, + // Number = "1", // Files = new List() // { // new MangaFile() // { - // FilePath = "" + // Format = MangaFormat.Archive, + // FilePath = $"{DataDirectory}Test v1.zip", // } // } - // }); - // - // await _cacheService.Ensure(1); - // - // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/CacheService/Archives"); + // }; + // 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)); // } - - //string GetCachedPagePath(Volume volume, int page) + + + #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)); + + cleanupService.CleanupChapters(new []{1, 3}); + Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories)); + } + + + #endregion + + #region GetCachedEpubFile + + [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)); + + var c = new Chapter() + { + Files = new List() + { + new MangaFile() + { + FilePath = $"{DataDirectory}1.epub" + }, + new MangaFile() + { + FilePath = $"{DataDirectory}2.epub" + } + } + }; + cs.GetCachedEpubFile(1, c); + Assert.Same($"{DataDirectory}1.epub", cs.GetCachedEpubFile(1, c)); + } + + #endregion + + #region GetCachedPagePath + + [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() + { + Id = 1, + Files = new List() + }; + + 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++; + } + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + + // Flatten to prepare for how GetFullPath expects + ds.Flatten($"{CacheDirectory}1/"); + + 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("")); + + var c = new Chapter() + { + Id = 1, + Files = new List() + { + new MangaFile() + { + Id = 1, + FilePath = $"{DataDirectory}1.zip", + Pages = 10 + + }, + new MangaFile() + { + Id = 2, + FilePath = $"{DataDirectory}2.zip", + Pages = 5 + } + } + }; + + 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("")); + } + + fileIndex++; + } + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + + // 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() + { + Id = 1, + Files = new List() + { + new MangaFile() + { + Id = 1, + FilePath = $"{DataDirectory}1.zip", + Pages = 10 + + } + } + }; + c.Pages = c.Files.Sum(f => f.Pages); + + 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("")); + } + + fileIndex++; + } + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + + // 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)); + } + + [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() + { + Id = 1, + Files = new List() + { + new MangaFile() + { + Id = 1, + FilePath = $"{DataDirectory}1.zip", + Pages = 10 + + }, + new MangaFile() + { + Id = 2, + FilePath = $"{DataDirectory}2.zip", + Pages = 5 + } + } + }; + + 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("")); + } + + fileIndex++; + } + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cs = new CacheService(_logger, _unitOfWork, ds, + new ReadingItemService(Substitute.For(), Substitute.For(), Substitute.For(), ds)); + + // Flatten to prepare for how GetFullPath expects + ds.Flatten($"{CacheDirectory}1/"); + + // 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] - // //[InlineData("", 0, "")] - // public void GetCachedPagePathTest_Should() + // public void ExtractChapterFiles_ShouldExtractOnlyImages() // { - // - // // string archivePath = "flat file.zip"; - // // int pageNum = 0; - // // string expected = "cache/1/pexels-photo-6551949.jpg"; - // // - // // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); - // // var file = Path.Join(testDirectory, archivePath); - // // var volume = new Volume - // // { - // // Id = 1, - // // Files = new List() - // // { - // // new() - // // { - // // Id = 1, - // // Chapter = 0, - // // FilePath = archivePath, - // // Format = MangaFormat.Archive, - // // Pages = 1, - // // } - // // }, - // // Name = "1", - // // Number = 1 - // // }; - // // - // // var cacheService = Substitute.ForPartsOf(); - // // cacheService.Configure().CacheDirectoryIsAccessible().Returns(true); - // // cacheService.Configure().GetVolumeCachePath(1, volume.Files.ElementAt(0)).Returns("cache/1/"); - // // _directoryService.Configure().GetFilesWithExtension("cache/1/").Returns(new string[] {"pexels-photo-6551949.jpg"}); - // // Assert.Equal(expected, _cacheService.GetCachedPagePath(volume, pageNum)); - // //Assert.True(true); + // const string testDirectory = "/manga/"; + // var fileSystem = new MockFileSystem(); + // for (var i = 0; i < 10; i++) + // { + // fileSystem.AddFile($"{testDirectory}file_{i}.zip", new MockFileData("")); // } // - // [Fact] - // public void GetOrderedChaptersTest() - // { - // // var files = new List() - // // { - // // new() - // // { - // // Number = "1" - // // }, - // // new() - // // { - // // Chapter = 2 - // // }, - // // new() - // // { - // // Chapter = 0 - // // }, - // // }; - // // var expected = new List() - // // { - // // new() - // // { - // // Chapter = 1 - // // }, - // // new() - // // { - // // Chapter = 2 - // // }, - // // new() - // // { - // // Chapter = 0 - // // }, - // // }; - // // Assert.NotStrictEqual(expected, _cacheService.GetOrderedChapters(files)); - // } + // 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 } -} \ No newline at end of file +} diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs new file mode 100644 index 000000000..419dd4126 --- /dev/null +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -0,0 +1,433 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using AutoMapper; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class CleanupServiceTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub = Substitute.For>(); + + private readonly DbConnection _connection; + private readonly DataContext _context; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; + + + public CleanupServiceTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + public void Dispose() => _connection.Dispose(); + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = "C:/data/" + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + _context.Users.RemoveRange(_context.Users.ToList()); + _context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory("C:/data/"); + + return fileSystem; + } + + #endregion + + + #region DeleteSeriesCoverImages + + [Fact] + public async Task DeleteSeriesCoverImages_ShouldDeleteAll() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CoverImageDirectory}series_01.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}series_03.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}series_1000.jpg", new MockFileData("")); + + // Delete all Series to reset state + await ResetDB(); + + var s = DbFactory.Series("Test 1"); + s.CoverImage = "series_01.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + s = DbFactory.Series("Test 2"); + s.CoverImage = "series_03.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + s = DbFactory.Series("Test 3"); + s.CoverImage = "series_1000.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + + await cleanupService.DeleteSeriesCoverImages(); + + Assert.Empty(ds.GetFiles(CoverImageDirectory)); + } + + [Fact] + public async Task DeleteSeriesCoverImages_ShouldNotDeleteLinkedFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CoverImageDirectory}series_01.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}series_03.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}series_1000.jpg", new MockFileData("")); + + // Delete all Series to reset state + await ResetDB(); + + // Add 2 series with cover images + var s = DbFactory.Series("Test 1"); + s.CoverImage = "series_01.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + s = DbFactory.Series("Test 2"); + s.CoverImage = "series_03.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + + + await _context.SaveChangesAsync(); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + + await cleanupService.DeleteSeriesCoverImages(); + + Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); + } + #endregion + + #region DeleteChapterCoverImages + [Fact] + public async Task DeleteChapterCoverImages_ShouldNotDeleteLinkedFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CoverImageDirectory}v01_c01.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}v01_c03.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}v01_c1000.jpg", new MockFileData("")); + + // Delete all Series to reset state + await ResetDB(); + + // Add 2 series with cover images + var s = DbFactory.Series("Test 1"); + var v = DbFactory.Volume("1"); + v.Chapters.Add(new Chapter() + { + CoverImage = "v01_c01.jpg" + }); + v.CoverImage = "v01_c01.jpg"; + s.Volumes.Add(v); + s.CoverImage = "series_01.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + + s = DbFactory.Series("Test 2"); + v = DbFactory.Volume("1"); + v.Chapters.Add(new Chapter() + { + CoverImage = "v01_c03.jpg" + }); + v.CoverImage = "v01_c03jpg"; + s.Volumes.Add(v); + s.CoverImage = "series_03.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + + + await _context.SaveChangesAsync(); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + + await cleanupService.DeleteChapterCoverImages(); + + Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); + } + #endregion + + #region DeleteTagCoverImages + + [Fact] + public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CoverImageDirectory}tag_01.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}tag_02.jpg", new MockFileData("")); + filesystem.AddFile($"{CoverImageDirectory}tag_1000.jpg", new MockFileData("")); + + // Delete all Series to reset state + await ResetDB(); + + // Add 2 series with cover images + var s = DbFactory.Series("Test 1"); + s.Metadata.CollectionTags = new List(); + s.Metadata.CollectionTags.Add(new CollectionTag() + { + Title = "Something", + CoverImage ="tag_01.jpg" + }); + s.CoverImage = "series_01.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + + s = DbFactory.Series("Test 2"); + s.Metadata.CollectionTags = new List(); + s.Metadata.CollectionTags.Add(new CollectionTag() + { + Title = "Something 2", + CoverImage ="tag_02.jpg" + }); + s.CoverImage = "series_03.jpg"; + s.LibraryId = 1; + _context.Series.Add(s); + + + await _context.SaveChangesAsync(); + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + + await cleanupService.DeleteTagCoverImages(); + + Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count()); + } + + #endregion + + #region CleanupCacheDirectory + + [Fact] + public void CleanupCacheDirectory_ClearAllFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CacheDirectory}01.jpg", new MockFileData("")); + filesystem.AddFile($"{CacheDirectory}02.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + cleanupService.CleanupCacheDirectory(); + Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); + } + + [Fact] + public void CleanupCacheDirectory_ClearAllFilesInSubDirectory() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{CacheDirectory}01.jpg", new MockFileData("")); + filesystem.AddFile($"{CacheDirectory}subdir/02.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + cleanupService.CleanupCacheDirectory(); + Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); + } + + #endregion + + #region CleanupBackups + + [Fact] + public void CleanupBackups_LeaveOneFile_SinceAllAreExpired() + { + var filesystem = CreateFileSystem(); + var filesystemFile = new MockFileData("") + { + CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(31)) + }; + filesystem.AddFile($"{BackupDirectory}kavita_backup_11_29_2021_12_00_13 AM.zip", filesystemFile); + filesystem.AddFile($"{BackupDirectory}kavita_backup_12_3_2021_9_27_58 AM.zip", filesystemFile); + filesystem.AddFile($"{BackupDirectory}randomfile.zip", filesystemFile); + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + cleanupService.CleanupBackups(); + Assert.Single(ds.GetFiles(BackupDirectory, searchOption: SearchOption.AllDirectories)); + } + + [Fact] + public void CleanupBackups_LeaveLestExpired() + { + var filesystem = CreateFileSystem(); + var filesystemFile = new MockFileData("") + { + CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(31)) + }; + filesystem.AddFile($"{BackupDirectory}kavita_backup_11_29_2021_12_00_13 AM.zip", filesystemFile); + filesystem.AddFile($"{BackupDirectory}kavita_backup_12_3_2021_9_27_58 AM.zip", filesystemFile); + filesystem.AddFile($"{BackupDirectory}randomfile.zip", new MockFileData("") + { + CreationTime = DateTimeOffset.Now.Subtract(TimeSpan.FromDays(14)) + }); + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + cleanupService.CleanupBackups(); + Assert.True(filesystem.File.Exists($"{BackupDirectory}randomfile.zip")); + } + + #endregion + + #region CleanupBookmarks + + [Fact] + public async Task CleanupBookmarks_LeaveAllFiles() + { + var filesystem = CreateFileSystem(); + filesystem.AddFile($"{BookmarkDirectory}1/1/1/0001.jpg", new MockFileData("")); + filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData("")); + + // Delete all Series to reset state + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + + } + } + } + } + }); + + await _context.SaveChangesAsync(); + + _context.AppUser.Add(new AppUser() + { + Bookmarks = new List() + { + new AppUserBookmark() + { + AppUserId = 1, + ChapterId = 1, + Page = 1, + FileName = "1/1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1 + } + } + }); + + await _context.SaveChangesAsync(); + + + var ds = new DirectoryService(Substitute.For>(), filesystem); + var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, + ds); + + await cleanupService.CleanupBookmarks(); + + Assert.Equal(1, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); + + } + + #endregion +} diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index d64df0d82..bdbb7a238 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,7 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.Linq; +using System.Text; +using System.Threading.Tasks; using API.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -12,92 +16,658 @@ namespace API.Tests.Services public class DirectoryServiceTests { - private readonly DirectoryService _directoryService; private readonly ILogger _logger = Substitute.For>(); - public DirectoryServiceTests() - { - _directoryService = new DirectoryService(_logger); - } - + #region TraverseTreeParallelForEach [Fact] - public void GetFilesTest_Should_Be28() + public void TraverseTreeParallelForEach_JustArchives_ShouldBe28() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga"); - // ReSharper disable once CollectionNeverQueried.Local + 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 = DirectoryService.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), + var fileCount = ds.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), API.Parser.Parser.ArchiveFileExtensions, _logger); Assert.Equal(28, fileCount); + Assert.Equal(28, files.Count); } [Fact] - public void GetFiles_WithCustomRegex_ShouldPass_Test() + public void TraverseTreeParallelForEach_LongDirectory_ShouldBe1() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/regex"); - var files = DirectoryService.GetFiles(testDirectory, @"file\d*.txt"); - Assert.Equal(2, files.Count()); + 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.Parser.Parser.ImageFileExtensions, _logger); + Assert.Equal(1, fileCount); + } + catch (Exception ex) + { + Assert.False(true); + } + + + Assert.Equal(1, files.Count); + } + + + + [Fact] + public void TraverseTreeParallelForEach_DontCountExcludedDirectories_ShouldBe28() + { + 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.Parser.Parser.ArchiveFileExtensions, _logger); + + Assert.Equal(28, fileCount); + Assert.Equal(28, files.Count); + } + #endregion + + #region GetFilesWithCertainExtensions + [Fact] + public void GetFilesWithCertainExtensions_ShouldBe10() + { + 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_{29}.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFilesWithExtension(testDirectory, API.Parser.Parser.ArchiveFileExtensions); + + Assert.Equal(10, files.Length); + Assert.All(files, s => fileSystem.Path.GetExtension(s).Equals(".zip")); } [Fact] - public void GetFiles_TopLevel_ShouldBeEmpty_Test() + public void GetFilesWithCertainExtensions_OnlyArchives() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService"); - var files = DirectoryService.GetFiles(testDirectory); - Assert.Empty(files); + 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_{29}.rar", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFilesWithExtension(testDirectory, ".zip|.rar"); + + Assert.Equal(11, files.Length); + } + #endregion + + #region GetFiles + [Fact] + public void GetFiles_ArchiveOnly_ShouldBe10() + { + 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_{29}.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory, API.Parser.Parser.ArchiveFileExtensions).ToList(); + + Assert.Equal(10, files.Count()); + Assert.All(files, s => fileSystem.Path.GetExtension(s).Equals(".zip")); } [Fact] - public void GetFilesWithExtensions_ShouldBeEmpty_Test() + public void GetFiles_All_ShouldBe11() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/extensions"); - var files = DirectoryService.GetFiles(testDirectory, "*.txt"); - Assert.Empty(files); + 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_{29}.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); + + Assert.Equal(11, files.Count()); } [Fact] - public void GetFilesWithExtensions_Test() + public void GetFiles_All_MixedPathSeparators() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/extension"); - var files = DirectoryService.GetFiles(testDirectory, ".cbz|.rar"); - Assert.Equal(3, files.Count()); + 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($"/manga\\file_{29}.jpg", new MockFileData("")); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var files = ds.GetFiles(testDirectory).ToList(); + + Assert.Equal(11, files.Count()); } [Fact] - public void GetFilesWithExtensions_BadDirectory_ShouldBeEmpty_Test() + public void GetFiles_All_TopDirectoryOnly_ShouldBe10() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/doesntexist"); - var files = DirectoryService.GetFiles(testDirectory, ".cbz|.rar"); - Assert.Empty(files); + 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}/SubDir/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 ListDirectory_SubDirectory_Test() + public void GetFiles_WithSubDirectories_ShouldCountOnlyTopLevel() { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/DirectoryService/"); - var dirs = _directoryService.ListDirectory(testDirectory); - Assert.Contains(dirs, s => s.Contains("regex")); + 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}/SubDir/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 ListDirectory_NoSubDirectory_Test() + public void GetFiles_ShouldNotReturnFilesThatAreExcluded() { - var dirs = _directoryService.ListDirectory(""); - Assert.DoesNotContain(dirs, s => s.Contains("regex")); + 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_{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++) + { + 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 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 + + #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 + + #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() + { + 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); + } + #endregion + + #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("")); + + 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/"); + } + + [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/"); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + ds.CopyDirectoryToDirectory($"{testDirectory}empty/", "/manga/output/"); + Assert.Empty(fileSystem.DirectoryInfo.FromDirectoryName("/manga/output/").GetFiles()); + } + + [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/"); + + 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++) + { + 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); + } + #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++) + { + 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() + { + 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); + } + #endregion + + #region CopyFilesToDirectory + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFiles() + { + 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()); + } + + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFiles_InclFilesInNestedFolders() + { + 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()); + } + + [Fact] + public void CopyFilesToDirectory_ShouldMoveAllFiles_WithPrepend() + { + 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_")); + } + + [Fact] + public void CopyFilesToDirectory_ShouldMoveOnlyFilesThatExist() + { + 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()); + } + + #endregion + + #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); + Assert.Empty(ds.ListDirectory("/comics/")); + } + + [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()); + } + + [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()); + } + + #endregion + + #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 + + #region FindHighestDirectoriesFromFiles + [Theory] [InlineData(new [] {"C:/Manga/"}, new [] {"C:/Manga/Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] - public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string[] folders, string expectedDirectory) + [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"}, "")] + public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string[] files, string expectedDirectory) { - var actual = DirectoryService.FindHighestDirectoriesFromFiles(rootDirectories, folders); - var expected = new Dictionary {{expectedDirectory, ""}}; + 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); } + #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")] @@ -114,12 +684,110 @@ namespace API.Tests.Services [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)) { - expected = Array.Empty(); + expected = Array.Empty(); } - Assert.Equal(expected, DirectoryService.GetFoldersTillRoot(rootPath, fullpath)); + Assert.Equal(expected, ds.GetFoldersTillRoot(rootPath, fullpath)); } + + #endregion + + #region RemoveNonImages + + [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")); + + 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()); + } + + #endregion + + #region Flatten + + [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 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/")); + } + + #endregion + + #region CheckWriteAccess + + [Fact] + public async Task CheckWriteAccess_ShouldHaveAccess() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var hasAccess = await ds.CheckWriteAccess(ds.FileSystem.Path.Join(testDirectory, "bookmarks")); + Assert.True(hasAccess); + + 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"))); + } + + + #endregion + + #region GetHumanReadableBytes + + [Theory] + [InlineData(1200, "1.17 KB")] + [InlineData(1, "1 B")] + [InlineData(10000000, "9.54 MB")] + [InlineData(10000000000, "9.31 GB")] + public void GetHumanReadableBytesTest(long bytes, string expected) + { + Assert.Equal(expected, DirectoryService.GetHumanReadableBytes(bytes)); + } + #endregion } } diff --git a/API.Tests/Services/FileSystemTests.cs b/API.Tests/Services/FileSystemTests.cs new file mode 100644 index 000000000..97250ea45 --- /dev/null +++ b/API.Tests/Services/FileSystemTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class FileSystemTests +{ + [Fact] + public void FileHasNotBeenModifiedSinceCreation() + { + var file = new MockFileData("Testing is meh.") + { + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { @"c:\myfile.txt", file } + }); + + var fileService = new FileService(fileSystem); + + Assert.False(fileService.HasFileBeenModifiedSince(@"c:\myfile.txt", DateTime.Now)); + } + + [Fact] + public void FileHasBeenModifiedSinceCreation() + { + var file = new MockFileData("Testing is meh.") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { @"c:\myfile.txt", file } + }); + + var fileService = new FileService(fileSystem); + + Assert.True(fileService.HasFileBeenModifiedSince(@"c:\myfile.txt", DateTime.Now.Subtract(TimeSpan.FromMinutes(1)))); + } +} diff --git a/API.Tests/Services/MetadataServiceTests.cs b/API.Tests/Services/MetadataServiceTests.cs index 5d61ee249..60a1bd0bd 100644 --- a/API.Tests/Services/MetadataServiceTests.cs +++ b/API.Tests/Services/MetadataServiceTests.cs @@ -1,8 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; -using API.Entities; +using System.IO.Abstractions.TestingHelpers; +using API.Helpers; using API.Services; -using Xunit; namespace API.Tests.Services { @@ -10,6 +11,7 @@ namespace API.Tests.Services { 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(); @@ -18,116 +20,23 @@ namespace API.Tests.Services // private readonly IArchiveService _archiveService = Substitute.For(); // private readonly ILogger _logger = Substitute.For>(); // private readonly IHubContext _messageHub = Substitute.For>(); + private readonly ICacheHelper _cacheHelper; + public MetadataServiceTests() { //_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService, _messageHub); - } - - [Fact] - public void ShouldUpdateCoverImage_OnFirstRun() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() + var file = new MockFileData("") { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = DateTime.Now - }, false, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnFirstRunSeries() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null,null, false, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnFirstRun_FileModified() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime.Subtract(TimeSpan.FromDays(1)) - }, false, false)); - } + { TestCoverArchive, file } + }); - [Fact] - public void ShouldUpdateCoverImage_OnFirstRun_CoverImageLocked() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, false, true)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_ForceUpdate() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, true, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_NoFileChangeButNoCoverImage() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, false, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_FileChangeButNoCoverImage() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime + TimeSpan.FromDays(1) - }, false, false)); - } - - [Fact] - public void ShouldNotUpdateCoverImage_OnSecondRun_CoverImageSet() - { - // Represents first run - Assert.False(MetadataService.ShouldUpdateCoverImage(TestCoverImageFile, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, false, false, _testCoverImageDirectory)); - } - - [Fact] - public void ShouldNotUpdateCoverImage_OnSecondRun_HasCoverImage_NoForceUpdate_NoLock() - { - - Assert.False(MetadataService.ShouldUpdateCoverImage(TestCoverImageFile, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = DateTime.Now - }, false, false, _testCoverImageDirectory)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_HasCoverImage_NoForceUpdate_HasLock_CoverImageDoesntExist() - { - - Assert.True(MetadataService.ShouldUpdateCoverImage(@"doesn't_exist.jpg", new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = DateTime.Now - }, false, true, _testCoverImageDirectory)); + var fileService = new FileService(fileSystem); + _cacheHelper = new CacheHelper(fileService); } } } diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs new file mode 100644 index 000000000..fd55143a1 --- /dev/null +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -0,0 +1,314 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Parser; +using API.Services; +using API.Services.Tasks.Scanner; +using API.SignalR; +using API.Tests.Helpers; +using AutoMapper; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +internal class MockReadingItemService : IReadingItemService +{ + private readonly DefaultParser _defaultParser; + + public MockReadingItemService(DefaultParser defaultParser) + { + _defaultParser = defaultParser; + } + + public ComicInfo GetComicInfo(string filePath) + { + return null; + } + + public int GetNumberOfPages(string filePath, MangaFormat format) + { + return 1; + } + + public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format) + { + return string.Empty; + } + + public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1) + { + throw new System.NotImplementedException(); + } + + public ParserInfo Parse(string path, string rootPath, LibraryType type) + { + return _defaultParser.Parse(path, rootPath, type); + } +} + +public class ParseScannedFilesTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; + + private readonly DbConnection _connection; + private readonly DataContext _context; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string DataDirectory = "C:/data/"; + + public ParseScannedFilesTests() + { + var contextOptions = new DbContextOptionsBuilder() + .UseSqlite(CreateInMemoryDatabase()) + .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + + // Since ProcessFile relies on _readingItemService, we can implement our own versions of _readingItemService so we have control over how the calls work + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + public void Dispose() => _connection.Dispose(); + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", + Folders = new List() + { + new FolderPath() + { + Path = DataDirectory + } + } + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(DataDirectory); + + return fileSystem; + } + + #endregion + + #region GetInfosByName + + [Fact] + public void GetInfosByName_ShouldReturnGivenMatchingSeriesName() + { + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(new DefaultParser(ds))); + + var infos = new List() + { + ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.cbz", false), + ParserInfoFactory.CreateParsedInfo("Accel World", "2", "0", "Accel World v2.cbz", false) + }; + var parsedSeries = new Dictionary> + { + { + new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Accel World", + NormalizedName = API.Parser.Parser.Normalize("Accel World") + }, + infos + }, + { + new ParsedSeries() + { + Format = MangaFormat.Pdf, + Name = "Accel World", + NormalizedName = API.Parser.Parser.Normalize("Accel World") + }, + new List() + } + }; + + var series = DbFactory.Series("Accel World"); + series.Format = MangaFormat.Pdf; + + Assert.Empty(ParseScannedFiles.GetInfosByName(parsedSeries, series)); + + series.Format = MangaFormat.Archive; + Assert.Equal(2, ParseScannedFiles.GetInfosByName(parsedSeries, series).Count()); + + } + + [Fact] + public void GetInfosByName_ShouldReturnGivenMatchingNormalizedSeriesName() + { + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(new DefaultParser(ds))); + + var infos = new List() + { + ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.cbz", false), + ParserInfoFactory.CreateParsedInfo("Accel World", "2", "0", "Accel World v2.cbz", false) + }; + var parsedSeries = new Dictionary> + { + { + new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Accel World", + NormalizedName = API.Parser.Parser.Normalize("Accel World") + }, + infos + }, + { + new ParsedSeries() + { + Format = MangaFormat.Pdf, + Name = "Accel World", + NormalizedName = API.Parser.Parser.Normalize("Accel World") + }, + new List() + } + }; + + var series = DbFactory.Series("accel world"); + series.Format = MangaFormat.Archive; + Assert.Equal(2, ParseScannedFiles.GetInfosByName(parsedSeries, series).Count()); + + } + + #endregion + + #region MergeName + + [Fact] + public void MergeName_ShouldMergeMatchingFormatAndName() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty)); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(new DefaultParser(ds))); + + + psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _); + + Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.cbz", false))); + Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.cbz", false))); + Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accelworld", "1", "0", "Accel World v1.cbz", false))); + } + + [Fact] + public void MergeName_ShouldMerge_MismatchedFormatSameName() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty)); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(new DefaultParser(ds))); + + + psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _); + + Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.epub", false))); + Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.epub", false))); + } + + #endregion + + #region ScanLibrariesForSeries + + [Fact] + public void ScanLibrariesForSeries_ShouldFindFiles() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Data/"); + fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty)); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(new DefaultParser(ds))); + + + var parsedSeries = psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _); + + Assert.Equal(3, parsedSeries.Values.Count); + Assert.NotEmpty(parsedSeries.Keys.Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World"))); + + } + + + #endregion +} diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs new file mode 100644 index 000000000..940bc2ebe --- /dev/null +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -0,0 +1,814 @@ +using System.Collections.Generic; +using System.Data.Common; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Services; +using API.SignalR; +using API.Tests.Helpers; +using AutoMapper; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class ReaderServiceTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub = Substitute.For>(); + + private readonly DbConnection _connection; + private readonly DataContext _context; + + private const string CacheDirectory = "C:/kavita/config/cache/"; + private const string CoverImageDirectory = "C:/kavita/config/covers/"; + private const string BackupDirectory = "C:/kavita/config/backups/"; + private const string DataDirectory = "C:/data/"; + + public ReaderServiceTests() + { + var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + + _context = new DataContext(contextOptions); + Task.Run(SeedDb).GetAwaiter().GetResult(); + + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); + } + + #region Setup + + private static DbConnection CreateInMemoryDatabase() + { + var connection = new SqliteConnection("Filename=:memory:"); + + connection.Open(); + + return connection; + } + + public void Dispose() => _connection.Dispose(); + + private async Task SeedDb() + { + await _context.Database.MigrateAsync(); + var filesystem = CreateFileSystem(); + + await Seed.SeedSettings(_context, + new DirectoryService(Substitute.For>(), filesystem)); + + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; + + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; + + _context.ServerSetting.Update(setting); + + _context.Library.Add(new Library() + { + Name = "Manga", Folders = new List() {new FolderPath() {Path = "C:/data/"}} + }); + return await _context.SaveChangesAsync() > 0; + } + + private async Task ResetDB() + { + _context.Series.RemoveRange(_context.Series.ToList()); + + await _context.SaveChangesAsync(); + } + + private static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); + fileSystem.AddDirectory("C:/kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(DataDirectory); + + return fileSystem; + } + + #endregion + + #region FormatBookmarkFolderPath + + [Theory] + [InlineData("/manga/", 1, 1, 1, "/manga/1/1/1")] + [InlineData("C:/manga/", 1, 1, 10001, "C:/manga/1/1/10001")] + public void FormatBookmarkFolderPathTest(string baseDir, int userId, int seriesId, int chapterId, string expected) + { + Assert.Equal(expected, ReaderService.FormatBookmarkFolderPath(baseDir, userId, seriesId, chapterId)); + } + + #endregion + + #region CapPageToChapter + + [Fact] + public async Task CapPageToChapterTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + } + } + } + } + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + Assert.Equal(0, await readerService.CapPageToChapter(1, -1)); + Assert.Equal(1, await readerService.CapPageToChapter(1, 10)); + } + + #endregion + + #region SaveReadingProgress + + [Fact] + public async Task SaveReadingProgress_ShouldCreateNewEntity() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var successful = await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = 1, + PageNum = 1, + SeriesId = 1, + VolumeId = 1, + BookScrollId = null + }, 1); + + Assert.True(successful); + Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); + } + + [Fact] + public async Task SaveReadingProgress_ShouldUpdateExisting() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var successful = await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = 1, + PageNum = 1, + SeriesId = 1, + VolumeId = 1, + BookScrollId = null + }, 1); + + Assert.True(successful); + Assert.NotNull(await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = 1, + PageNum = 1, + SeriesId = 1, + VolumeId = 1, + BookScrollId = "/h1/" + }, 1)); + + Assert.Equal("/h1/", (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).BookScrollId); + + } + + + #endregion + + #region MarkChaptersAsRead + + [Fact] + public async Task MarkChaptersAsReadTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); + readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await _context.SaveChangesAsync(); + + Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + } + #endregion + + #region MarkChapterAsUnread + + [Fact] + public async Task MarkChapterAsUnreadTest() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + new Volume() + { + Chapters = new List() + { + new Chapter() + { + Pages = 1 + }, + new Chapter() + { + Pages = 2 + } + } + } + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); + readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + + await _context.SaveChangesAsync(); + Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count); + + readerService.MarkChaptersAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); + await _context.SaveChangesAsync(); + + var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses; + Assert.Equal(0, progresses.Max(p => p.PagesRead)); + Assert.Equal(2, progresses.Count); + } + + #endregion + + #region GetNextChapterIdAsync + + [Fact] + public async Task GetNextChapterIdAsync_ShouldGetNextVolume() + { + // V1 -> V2 + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 1, 1); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("2", actualChapter.Range); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldRollIntoNextVolume() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("21", actualChapter.Range); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldNotMoveFromVolumeToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); + Assert.Equal(-1, nextChapter); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + + var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); + Assert.NotEqual(-1, nextChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.Equal("B.cbz", actualChapter.Range); + } + + #endregion + + #region GetPrevChapterIdAsync + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldGetPrevVolume() + { + // V1 -> V2 + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 2, 1); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.Equal("1", actualChapter.Range); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldRollIntoPrevVolume() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List()), + EntityFactory.CreateChapter("22", false, new List()), + }), + EntityFactory.CreateVolume("3", new List() + { + EntityFactory.CreateChapter("31", false, new List()), + EntityFactory.CreateChapter("32", false, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.Equal("2", actualChapter.Range); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldMoveFromVolumeToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + Assert.NotEqual(-1, prevChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.Equal("B.cbz", actualChapter.Range); + } + + [Fact] + public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToSpecial() + { + await ResetDB(); + + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List()), + EntityFactory.CreateChapter("2", false, new List()), + }), + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("A.cbz", true, new List()), + EntityFactory.CreateChapter("B.cbz", true, new List()), + }), + } + }); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var fileSystem = new MockFileSystem(); + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + + var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1); + Assert.NotEqual(-1, prevChapter); + var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.Equal("A.cbz", actualChapter.Range); + } + + #endregion + + // #region GetNumberOfPages + // + // [Fact] + // public void GetNumberOfPages_EPUB() + // { + // const string testDirectory = "/manga/"; + // var fileSystem = new MockFileSystem(); + // + // var actualFile = Path.Join(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService/EPUB"), "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub") + // fileSystem.File.WriteAllBytes("${testDirectory}test.epub", File.ReadAllBytes(actualFile)); + // + // fileSystem.AddDirectory(CacheDirectory); + // + // var ds = new DirectoryService(Substitute.For>(), fileSystem); + // var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); + // var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + // + // + // } + // + // + // #endregion + +} diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 0253ccef6..b78c6be35 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -3,13 +3,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Data.Common; using System.IO; +using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Entities; using API.Entities.Enums; -using API.Interfaces; -using API.Interfaces.Services; +using API.Entities.Metadata; +using API.Helpers; using API.Parser; using API.Services; using API.Services.Tasks; @@ -27,55 +28,38 @@ using Xunit; namespace API.Tests.Services { - public class ScannerServiceTests : IDisposable + public class ScannerServiceTests { - private readonly ScannerService _scannerService; - private readonly ILogger _logger = Substitute.For>(); - private readonly IArchiveService _archiveService = Substitute.For(); - private readonly IBookService _bookService = Substitute.For(); - private readonly IImageService _imageService = Substitute.For(); - private readonly ILogger _metadataLogger = Substitute.For>(); - private readonly ICacheService _cacheService = Substitute.For(); - private readonly IHubContext _messageHub = Substitute.For>(); - - private readonly DbConnection _connection; - private readonly DataContext _context; - - - public ScannerServiceTests() + [Fact] + public void FindSeriesNotOnDisk_Should_Remove1() { - var contextOptions = new DbContextOptionsBuilder() - .UseSqlite(CreateInMemoryDatabase()) - .Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; + var infos = new Dictionary>(); - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); + 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}); - IUnitOfWork unitOfWork = new UnitOfWork(_context, Substitute.For(), null); - - - IMetadataService metadataService = Substitute.For(unitOfWork, _metadataLogger, _archiveService, _bookService, _imageService, _messageHub); - _scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService, _cacheService, _messageHub); - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - await Seed.SeedSettings(_context); - - _context.Library.Add(new Library() + var existingSeries = new List { - Name = "Manga", - Folders = new List() + new Series() { - new FolderPath() + Name = "Darker Than Black", + LocalizedName = "Darker Than Black", + OriginalName = "Darker Than Black", + Volumes = new List() { - Path = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga") - } + new Volume() + { + Number = 1, + Name = "1" + } + }, + NormalizedName = API.Parser.Parser.Normalize("Darker Than Black"), + Metadata = new SeriesMetadata(), + Format = MangaFormat.Epub } - }); - return await _context.SaveChangesAsync() > 0; + }; + + Assert.Equal(1, ScannerService.FindSeriesNotOnDisk(existingSeries, infos).Count()); } [Fact] @@ -83,9 +67,9 @@ namespace API.Tests.Services { var infos = new Dictionary>(); - AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Format = MangaFormat.Archive}); - AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "1", Format = MangaFormat.Archive}); - AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "10", Format = MangaFormat.Archive}); + 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 { @@ -138,78 +122,25 @@ namespace API.Tests.Services // 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(); + // [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); + // } - Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); - Assert.Equal(missingSeries.Count, removeCount); - } - private void AddToParsedInfo(IDictionary> collectedSeries, ParserInfo info) - { - var existingKey = collectedSeries.Keys.FirstOrDefault(ps => - ps.Format == info.Format && ps.NormalizedName == API.Parser.Parser.Normalize(info.Series)); - existingKey ??= new ParsedSeries() - { - Format = info.Format, - Name = info.Series, - NormalizedName = API.Parser.Parser.Normalize(info.Series) - }; - if (collectedSeries.GetType() == typeof(ConcurrentDictionary<,>)) - { - ((ConcurrentDictionary>) collectedSeries).AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => - { - oldValue ??= new List(); - if (!oldValue.Contains(info)) - { - oldValue.Add(info); - } - - return oldValue; - }); - } - else - { - if (!collectedSeries.ContainsKey(existingKey)) - { - collectedSeries.Add(existingKey, new List() {info}); - } - else - { - var list = collectedSeries[existingKey]; - if (!list.Contains(info)) - { - list.Add(info); - } - - collectedSeries[existingKey] = list; - } - - } - - } - - private static DbConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - - connection.Open(); - - return connection; - } - - public void Dispose() => _connection.Dispose(); } } diff --git a/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_authors.zip b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_authors.zip new file mode 100644 index 000000000..e44b8aa5a Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_authors.zip differ diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/test2_output.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/test2_output.png new file mode 100644 index 000000000..faa1b5d21 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/test2_output.png differ diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/test.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/test.expected.jpg new file mode 100644 index 000000000..e3f368eb3 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/test.expected.jpg differ diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/test.zip b/API.Tests/Services/Test Data/ArchiveService/CoverImages/test.zip new file mode 100644 index 000000000..3d318ac6c Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/test.zip differ diff --git a/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub b/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub index 7388bc85e..99d4e3bbf 100644 Binary files a/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub and b/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub differ diff --git a/API.Tests/Services/Test Data/BookService/EPUB/content.opf b/API.Tests/Services/Test Data/BookService/EPUB/content.opf new file mode 100644 index 000000000..74e62677a --- /dev/null +++ b/API.Tests/Services/Test Data/BookService/EPUB/content.opf @@ -0,0 +1,81 @@ + + + + + Public domain in the USA. + http://www.gutenberg.org/64999 + Roger Starbuck + The Golden Harpoon / Lost Among the Floes + en + 2021-04-05 + 2021-04-05T23:00:07.039989+00:00 + https://www.gutenberg.org/files/64999/64999-h/64999-h.htm + Book Description + Genre1, Genre2 + Junya Inoue + Inoue, Junya + aut + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/API.Tests/generate_test_data.py b/API.Tests/generate_test_data.py deleted file mode 100644 index 69652969a..000000000 --- a/API.Tests/generate_test_data.py +++ /dev/null @@ -1,80 +0,0 @@ -""" This script should be run on a directory which will generate a test case file - that can be loaded into the renametest.py""" -import os -from pathlib import Path -import shutil - -verbose = False - -def print_log(val): - if verbose: - print(val) - - -def create_test_base(file, root_dir): - """ Creates and returns a new base directory for data creation for a given testcase.""" - base_dir = os.path.split(file.split('-testcase.txt')[0])[-1] - print_log('base_dir: {0}'.format(base_dir)) - new_dir = os.path.join(root_dir, base_dir) - print_log('new dir: {0}'.format(new_dir)) - p = Path(new_dir) - if not p.exists(): - os.mkdir(new_dir) - - return new_dir - - - -def generate_data(file, root_dir): - ''' Generates directories and fake files for testing against ''' - - base_dir = '' - if file.endswith('-testcase.txt'): - base_dir = create_test_base(file, root_dir) - - files_to_create = [] - with open(file, 'r') as in_file: - files_to_create = in_file.read().splitlines() - - for filepath in files_to_create: - for part in os.path.split(filepath): - part_path = os.path.join(base_dir, part) - print_log('Checking if {0} exists '.format(part_path)) - p = Path(part_path) - - if not p.exists(): - print_log('Creating: {0}'.format(part)) - - if p.suffix != '': - with open(os.path.join(root_dir, base_dir + '/' + filepath), 'w+') as f: - f.write('') - else: - os.mkdir(part_path) - -def clean_up_generated_data(root_dir): - for root, dirs, files in os.walk(root_dir): - for dir in dirs: - shutil.rmtree(os.path.join(root, dir)) - for file in files: - if not file.endswith('-testcase.txt'): - print_log('Removing {0}'.format(os.path.join(root, file))) - os.remove(os.path.join(root, file)) - - -def generate_test_file(): - root_dir = os.path.abspath('.') - current_folder = os.path.split(root_dir)[-1] - out_files = [] - for root, _, files in os.walk(root_dir): - for file in files: - if not file.endswith('-testcase.txt'): - filename = os.path.join(root.replace(root_dir, ''), file) # root_dir or root_dir + '//'? - out_files.append(filename) - - with open(os.path.join(root_dir, current_folder + '-testcase.txt'), 'w+') as f: - for filename in out_files: - f.write(filename + '\n') - -if __name__ == '__main__': - verbose = True - generate_test_file() \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index d6f059830..fbf15067e 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -2,7 +2,7 @@ Default - net5.0 + net6.0 true Linux @@ -36,39 +36,41 @@ - - + + - - + + - + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + diff --git a/API/API.csproj.DotSettings b/API/API.csproj.DotSettings index 80aad93c5..c7410bba2 100644 --- a/API/API.csproj.DotSettings +++ b/API/API.csproj.DotSettings @@ -1,2 +1,3 @@  - True \ No newline at end of file + True + True \ No newline at end of file diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs index 3791e05ff..189919a33 100644 --- a/API/Comparators/ChapterSortComparer.cs +++ b/API/Comparators/ChapterSortComparer.cs @@ -8,7 +8,7 @@ namespace API.Comparators public class ChapterSortComparer : IComparer { /// - /// Normal sort for 2 doubles. 0 always comes before anything else + /// Normal sort for 2 doubles. 0 always comes last /// /// /// diff --git a/API/Comparators/NaturalSortComparer.cs b/API/Comparators/NaturalSortComparer.cs deleted file mode 100644 index 8fb0a74a5..000000000 --- a/API/Comparators/NaturalSortComparer.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; -using static System.GC; -using static System.String; - -namespace API.Comparators -{ - /// - /// Attempts to emulate Windows explorer sorting - /// - /// This is not thread-safe - public sealed class NaturalSortComparer : IComparer, IDisposable - { - private readonly bool _isAscending; - private Dictionary _table = new(); - - private bool _disposed; - - - public NaturalSortComparer(bool inAscendingOrder = true) - { - _isAscending = inAscendingOrder; - } - - int IComparer.Compare(string x, string y) - { - if (x == y) return 0; - - if (!_table.TryGetValue(x ?? Empty, out var x1)) - { - x1 = Regex.Split(x ?? Empty, "([0-9]+)"); - _table.Add(x ?? Empty, x1); - } - - if (!_table.TryGetValue(y ?? Empty, out var y1)) - { - y1 = Regex.Split(y ?? Empty, "([0-9]+)"); - _table.Add(y ?? Empty, y1); - } - - int returnVal; - - for (var i = 0; i < x1.Length && i < y1.Length; i++) - { - if (x1[i] == y1[i]) continue; - returnVal = PartCompare(x1[i], y1[i]); - return _isAscending ? returnVal : -returnVal; - } - - if (y1.Length > x1.Length) - { - returnVal = 1; - } - else if (x1.Length > y1.Length) - { - returnVal = -1; - } - else - { - returnVal = 0; - } - - - return _isAscending ? returnVal : -returnVal; - } - - private static int PartCompare(string left, string right) - { - if (!int.TryParse(left, out var x)) - return Compare(left, right, StringComparison.Ordinal); - - if (!int.TryParse(right, out var y)) - return Compare(left, right, StringComparison.Ordinal); - - return x.CompareTo(y); - } - - private void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - // called via myClass.Dispose(). - _table.Clear(); - _table = null; - } - // Release unmanaged resources. - // Set large fields to null. - _disposed = true; - } - } - - public void Dispose() - { - Dispose(true); - SuppressFinalize(this); - } - - ~NaturalSortComparer() // the finalizer - { - Dispose(false); - } - } -} diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 415b51f59..3d3a93f85 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -4,12 +4,11 @@ using System.Linq; using System.Reflection; using System.Threading.Tasks; using API.Constants; +using API.Data; using API.DTOs; using API.DTOs.Account; using API.Entities; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; using AutoMapper; using Kavita.Common; @@ -92,7 +91,7 @@ namespace API.Controllers if (registerDto.IsAdmin) { var firstTimeFlow = !(await _userManager.GetUsersInRoleAsync("Admin")).Any(); - if (!firstTimeFlow && !await _unitOfWork.UserRepository.IsUserAdmin( + if (!firstTimeFlow && !await _unitOfWork.UserRepository.IsUserAdminAsync( await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()))) { return BadRequest("You are not permitted to create an admin account"); @@ -167,7 +166,7 @@ namespace API.Controllers if (user == null) return Unauthorized("Invalid username"); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); if (!settings.EnableAuthentication && !isAdmin) { diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index cf5e66e22..473640df7 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -1,15 +1,16 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.DTOs; using API.DTOs.Reader; using API.Entities.Enums; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; using HtmlAgilityPack; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VersOne.Epub; @@ -21,10 +22,11 @@ namespace API.Controllers private readonly IBookService _bookService; private readonly IUnitOfWork _unitOfWork; private readonly ICacheService _cacheService; - private static readonly string BookApiUrl = "book-resources?file="; + private const string BookApiUrl = "book-resources?file="; - public BookController(ILogger logger, IBookService bookService, IUnitOfWork unitOfWork, ICacheService cacheService) + public BookController(ILogger logger, IBookService bookService, + IUnitOfWork unitOfWork, ICacheService cacheService) { _logger = logger; _bookService = bookService; @@ -110,32 +112,12 @@ namespace API.Controllers } } - if (navigationItem.Link == null) - { - var item = new BookChapterItem() - { - Title = navigationItem.Title, - Children = nestedChapters - }; - if (nestedChapters.Count > 0) - { - item.Page = nestedChapters[0].Page; - } - chaptersList.Add(item); - } - else - { - var groupKey = BookService.CleanContentKeys(navigationItem.Link.ContentFileName); - if (mappings.ContainsKey(groupKey)) - { - chaptersList.Add(new BookChapterItem() - { - Title = navigationItem.Title, - Page = mappings[groupKey], - Children = nestedChapters - }); - } - } + CreateToCChapter(navigationItem, nestedChapters, chaptersList, mappings); + } + + if (navigationItem.NestedItems.Count == 0) + { + CreateToCChapter(navigationItem, Array.Empty(), chaptersList, mappings); } } @@ -188,6 +170,38 @@ namespace API.Controllers return Ok(chaptersList); } + private static void CreateToCChapter(EpubNavigationItemRef navigationItem, IList nestedChapters, IList chaptersList, + IReadOnlyDictionary mappings) + { + if (navigationItem.Link == null) + { + var item = new BookChapterItem() + { + Title = navigationItem.Title, + Children = nestedChapters + }; + if (nestedChapters.Count > 0) + { + item.Page = nestedChapters[0].Page; + } + + chaptersList.Add(item); + } + else + { + var groupKey = BookService.CleanContentKeys(navigationItem.Link.ContentFileName); + if (mappings.ContainsKey(groupKey)) + { + chaptersList.Add(new BookChapterItem() + { + Title = navigationItem.Title, + Page = mappings[groupKey], + Children = nestedChapters + }); + } + } + } + [HttpGet("{chapterId}/book-page")] public async Task> GetBookPage(int chapterId, [FromQuery] int page) { @@ -200,146 +214,40 @@ namespace API.Controllers var counter = 0; var doc = new HtmlDocument {OptionFixNestedTags = true}; - var baseUrl = Request.Scheme + "://" + Request.Host + Request.PathBase + "/api/"; + + var baseUrl = "//" + Request.Host + Request.PathBase + "/api/"; var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; var bookPages = await book.GetReadingOrderAsync(); foreach (var contentFileRef in bookPages) { - if (page == counter) + if (page != counter) { - var content = await contentFileRef.ReadContentAsync(); - if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(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"); - - if (body == null) - { - if (doc.ParseErrors.Any()) - { - LogBookErrors(book, contentFileRef, doc); - return BadRequest("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"); - } - - var inlineStyles = doc.DocumentNode.SelectNodes("//style"); - if (inlineStyles != null) - { - foreach (var inlineStyle in inlineStyles) - { - var styleContent = await _bookService.ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); - body.PrependChild(HtmlNode.CreateNode($"")); - } - } - - var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); - if (styleNodes != null) - { - foreach (var styleLinks in styleNodes) - { - var key = BookService.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 styleContent = await _bookService.ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase, book.Content.Css[key].FileName, book); - if (styleContent != null) - { - body.PrependChild(HtmlNode.CreateNode($"")); - } - } - } - - var anchors = doc.DocumentNode.SelectNodes("//a"); - if (anchors != null) - { - foreach (var anchor in anchors) - { - BookService.UpdateLinks(anchor, mappings, page); - } - } - - var images = doc.DocumentNode.SelectNodes("//img"); - if (images != null) - { - foreach (var image in images) - { - if (image.Name != "img") continue; - - // Need to do for xlink:href - if (image.Attributes["src"] != null) - { - var imageFile = image.Attributes["src"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - image.Attributes.Remove("src"); - image.Attributes.Add("src", $"{apiBase}" + imageFile); - } - } - } - - images = doc.DocumentNode.SelectNodes("//image"); - if (images != null) - { - foreach (var image in images) - { - if (image.Name != "image") continue; - - if (image.Attributes["xlink:href"] != null) - { - var imageFile = image.Attributes["xlink:href"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - image.Attributes.Remove("xlink:href"); - image.Attributes.Add("xlink:href", $"{apiBase}" + imageFile); - } - } - } - - // 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")) - { - 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 Ok($"
{body.InnerHtml}
"); - } - - - return Ok(body.InnerHtml); + counter++; + continue; } - counter++; + var content = await contentFileRef.ReadContentAsync(); + if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(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"); + + if (body == null) + { + if (doc.ParseErrors.Any()) + { + LogBookErrors(book, contentFileRef, doc); + return BadRequest("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"); + } + + return Ok(await _bookService.ScopePage(doc, book, apiBase, body, mappings, page)); } return BadRequest("Could not find the appropriate html for that page"); diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 681a962d0..9f297273f 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -3,11 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; -using API.DTOs; using API.DTOs.CollectionTags; -using API.Entities; +using API.Entities.Metadata; using API.Extensions; -using API.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -34,7 +32,7 @@ namespace API.Controllers public async Task> GetAllTags() { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (isAdmin) { return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index d1ea4e8fb..c253fb9ee 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -4,16 +4,17 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data; using API.DTOs.Downloads; using API.Entities; using API.Entities.Enums; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; +using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; namespace API.Controllers { @@ -25,16 +26,19 @@ namespace API.Controllers private readonly IDirectoryService _directoryService; private readonly ICacheService _cacheService; private readonly IDownloadService _downloadService; + private readonly IHubContext _messageHub; private readonly NumericComparer _numericComparer; private const string DefaultContentType = "application/octet-stream"; - public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService, IDownloadService downloadService) + public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, + ICacheService cacheService, IDownloadService downloadService, IHubContext messageHub) { _unitOfWork = unitOfWork; _archiveService = archiveService; _directoryService = directoryService; _cacheService = cacheService; _downloadService = downloadService; + _messageHub = messageHub; _numericComparer = new NumericComparer(); } @@ -42,21 +46,21 @@ namespace API.Controllers public async Task> GetVolumeSize(int volumeId) { var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); - return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath))); + return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); } [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))); + return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); } [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))); + return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath))); } [HttpGet("volume")] @@ -67,13 +71,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { - if (files.Count == 1) - { - return await GetFirstFileDownload(files); - } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - $"download_{User.GetUsername()}_v{volumeId}"); - return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_v{volumeId}", $"{series.Name} - Volume {volume.Number}.zip"); } catch (KavitaException ex) { @@ -96,13 +94,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { - if (files.Count == 1) - { - return await GetFirstFileDownload(files); - } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - $"download_{User.GetUsername()}_c{chapterId}"); - return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series.Name} - Chapter {chapter.Number}.zip"); } catch (KavitaException ex) { @@ -110,6 +102,21 @@ namespace API.Controllers } } + private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 0F)); + if (files.Count == 1) + { + return await GetFirstFileDownload(files); + } + var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), + tempFolder); + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F)); + return File(fileBytes, DefaultContentType, downloadName); + } + [HttpGet("series")] public async Task DownloadSeries(int seriesId) { @@ -117,13 +124,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); try { - if (files.Count == 1) - { - return await GetFirstFileDownload(files); - } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - $"download_{User.GetUsername()}_s{seriesId}"); - return File(fileBytes, DefaultContentType, $"{series.Name}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_s{seriesId}", $"{series.Name}.zip"); } catch (KavitaException ex) { @@ -135,57 +136,20 @@ namespace API.Controllers public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) { // 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 totalFilePaths = new List(); - var tempFolder = $"download_{series.Id}_bookmarks"; - var fullExtractPath = Path.Join(DirectoryService.TempDirectory, tempFolder); - if (new DirectoryInfo(fullExtractPath).Exists) - { - return BadRequest( - "Server is currently processing this exact download. Please try again in a few minutes."); - } - DirectoryService.ExistOrCreate(fullExtractPath); + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var files = (await _unitOfWork.UserRepository.GetAllBookmarksByIds(downloadBookmarkDto.Bookmarks + .Select(b => b.Id) + .ToList())) + .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, b.FileName))); - var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList(); - - foreach (var chapterId in uniqueChapterIds) - { - var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}"); - var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId) - .Select(b => b.Page).ToList(); - var mangaFiles = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - switch (series.Format) - { - case MangaFormat.Image: - DirectoryService.ExistOrCreate(chapterExtractPath); - _directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_"); - break; - case MangaFormat.Archive: - case MangaFormat.Pdf: - _cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList()); - var originalFiles = DirectoryService.GetFilesWithExtension(chapterExtractPath, - Parser.Parser.ImageFileExtensions); - _directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_"); - DirectoryService.DeleteFiles(originalFiles); - break; - case MangaFormat.Epub: - return BadRequest("Series is not in a valid format."); - default: - return BadRequest("Series is not in a valid format. Please rescan series and try again."); - } - - var files = DirectoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions); - // Filter out images that aren't in bookmarks - Array.Sort(files, _numericComparer); - totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i))); - } - - - var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths, - tempFolder); - DirectoryService.ClearAndDeleteDirectory(fullExtractPath); + var (fileBytes, _) = await _archiveService.CreateZipForDownload(files, + $"download_{user.Id}_{series.Id}_bookmarks"); return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip"); } + } } diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs index ecd0315e2..ae8bad21f 100644 --- a/API/Controllers/FallbackController.cs +++ b/API/Controllers/FallbackController.cs @@ -1,5 +1,5 @@ using System.IO; -using API.Interfaces; +using API.Services; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -12,7 +12,7 @@ namespace API.Controllers public FallbackController(ITaskScheduler taskScheduler) { - // This is used to load TaskScheduler on startup without having to navigate to a Controller that uses. + // This is used to load TaskScheduler on startup without having to navigate to a Controller that uses. _taskScheduler = taskScheduler; } @@ -21,4 +21,4 @@ namespace API.Controllers return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML"); } } -} \ No newline at end of file +} diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 88bafcff7..a875be14e 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -1,7 +1,8 @@ using System.IO; using System.Threading.Tasks; +using API.Data; +using API.Entities.Enums; using API.Extensions; -using API.Interfaces; using API.Services; using Microsoft.AspNetCore.Mvc; @@ -13,11 +14,13 @@ namespace API.Controllers public class ImageController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; /// - public ImageController(IUnitOfWork unitOfWork) + public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService) { _unitOfWork = unitOfWork; + _directoryService = directoryService; } /// @@ -28,12 +31,12 @@ namespace API.Controllers [HttpGet("chapter-cover")] public async Task GetChapterCoverImage(int chapterId) { - var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId)); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image"); - var format = Path.GetExtension(path).Replace(".", ""); + 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(".", ""); Response.AddCacheHeader(path); - return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } /// @@ -44,12 +47,12 @@ namespace API.Controllers [HttpGet("volume-cover")] public async Task GetVolumeCoverImage(int volumeId) { - var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId)); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image"); - var format = Path.GetExtension(path).Replace(".", ""); + 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(".", ""); Response.AddCacheHeader(path); - return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } /// @@ -60,12 +63,12 @@ namespace API.Controllers [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId) { - var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId)); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image"); - var format = Path.GetExtension(path).Replace(".", ""); + 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, Path.GetFileName(path)); + return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } /// @@ -76,12 +79,34 @@ namespace API.Controllers [HttpGet("collection-cover")] public async Task GetCollectionCoverImage(int collectionTagId) { - var path = Path.Join(DirectoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No cover image"); - var format = Path.GetExtension(path).Replace(".", ""); + 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(".", ""); Response.AddCacheHeader(path); - return PhysicalFile(path, "image/" + format, Path.GetFileName(path)); + 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")] + 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)); } } } diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 15a8d1166..9cdd06158 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -3,13 +3,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.Data.Repositories; using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; +using API.Services; using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs new file mode 100644 index 000000000..4aa13691f --- /dev/null +++ b/API/Controllers/MetadataController.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.DTOs.Filtering; +using API.DTOs.Metadata; +using API.Entities.Enums; +using Kavita.Common.Extensions; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + + +public class MetadataController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public MetadataController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + /// + /// Fetches genres from the instance + /// + /// String separated libraryIds or null for all genres + /// + [HttpGet("genres")] + public async Task>> GetAllGenres(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids)); + } + + return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync()); + } + + /// + /// Fetches people from the instance + /// + /// String separated libraryIds or null for all people + /// + [HttpGet("people")] + public async Task>> GetAllPeople(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids)); + } + return Ok(await _unitOfWork.PersonRepository.GetAllPeople()); + } + + /// + /// Fetches all tags from the instance + /// + /// String separated libraryIds or null for all tags + /// + [HttpGet("tags")] + public async Task>> GetAllTags(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids)); + } + return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync()); + } + + /// + /// Fetches all age ratings from the instance + /// + /// String separated libraryIds or null for all ratings + /// + [HttpGet("age-ratings")] + public async Task>> GetAllAgeRatings(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids)); + } + + return Ok(Enum.GetValues().Select(t => new AgeRatingDto() + { + Title = t.ToDescription(), + Value = t + })); + } + + /// + /// Fetches all publication status' from the instance + /// + /// String separated libraryIds or null for all publication status + /// + [HttpGet("publication-status")] + public async Task>> GetAllPublicationStatus(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids)); + } + + return Ok(Enum.GetValues().Select(t => new PublicationStatusDto() + { + Title = t.ToDescription(), + Value = t + })); + } + + /// + /// Fetches all age ratings from the instance + /// + /// String separated libraryIds or null for all ratings + /// + [HttpGet("languages")] + public async Task>> GetAllLanguages(string? libraryIds) + { + var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); + if (ids != null && ids.Count > 0) + { + return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids)); + } + + return Ok(new List() + { + new () + { + Title = CultureInfo.GetCultureInfo("en").DisplayName, + IsoCode = "en" + } + }); + } +} diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 49a70d90d..9b7e87d62 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -5,789 +5,800 @@ using System.Linq; using System.Threading.Tasks; using System.Xml.Serialization; using API.Comparators; +using API.Data; using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.OPDS; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Helpers; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -namespace API.Controllers +namespace API.Controllers; + +public class OpdsController : BaseApiController { - public class OpdsController : BaseApiController + private readonly IUnitOfWork _unitOfWork; + private readonly IDownloadService _downloadService; + private readonly IDirectoryService _directoryService; + private readonly ICacheService _cacheService; + private readonly IReaderService _readerService; + + + private readonly XmlSerializer _xmlSerializer; + private readonly XmlSerializer _xmlOpenSearchSerializer; + private const string Prefix = "/api/opds/"; + private readonly FilterDto _filterDto = new FilterDto() { - private readonly IUnitOfWork _unitOfWork; - private readonly IDownloadService _downloadService; - private readonly IDirectoryService _directoryService; - private readonly ICacheService _cacheService; - private readonly IReaderService _readerService; + Formats = new List(), + Character = new List(), + Colorist = new List(), + Editor = new List(), + Genres = new List(), + Inker = new List(), + Languages = new List(), + Letterer = new List(), + Penciller = new List(), + Libraries = new List(), + Publisher = new List(), + Rating = 0, + Tags = new List(), + Translators = new List(), + Writers = new List(), + AgeRating = new List(), + CollectionTags = new List(), + CoverArtist = new List(), + ReadStatus = new ReadStatus(), + SortOptions = null, + PublicationStatus = new List() + }; + private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); + public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, + IDirectoryService directoryService, ICacheService cacheService, + IReaderService readerService) + { + _unitOfWork = unitOfWork; + _downloadService = downloadService; + _directoryService = directoryService; + _cacheService = cacheService; + _readerService = readerService; - private readonly XmlSerializer _xmlSerializer; - private readonly XmlSerializer _xmlOpenSearchSerializer; - private const string Prefix = "/api/opds/"; - private readonly FilterDto _filterDto = new FilterDto() + _xmlSerializer = new XmlSerializer(typeof(Feed)); + _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); + + } + + [HttpPost("{apiKey}")] + [HttpGet("{apiKey}")] + [Produces("application/xml")] + public async Task Get(string apiKey) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var feed = CreateFeed("Kavita", string.Empty, apiKey); + SetFeedId(feed, "root"); + feed.Entries.Add(new FeedEntry() { - MangaFormat = null + Id = "onDeck", + Title = "On Deck", + Content = new FeedEntryContent() + { + Text = "Browse by On Deck" + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/on-deck"), + } + }); + feed.Entries.Add(new FeedEntry() + { + Id = "recentlyAdded", + Title = "Recently Added", + Content = new FeedEntryContent() + { + Text = "Browse by Recently Added" + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/recently-added"), + } + }); + feed.Entries.Add(new FeedEntry() + { + Id = "readingList", + Title = "Reading Lists", + Content = new FeedEntryContent() + { + Text = "Browse by Reading Lists" + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list"), + } + }); + feed.Entries.Add(new FeedEntry() + { + Id = "allLibraries", + Title = "All Libraries", + Content = new FeedEntryContent() + { + Text = "Browse by Libraries" + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries"), + } + }); + feed.Entries.Add(new FeedEntry() + { + Id = "allCollections", + Title = "All Collections", + Content = new FeedEntryContent() + { + Text = "Browse by Collections" + }, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections"), + } + }); + return CreateXmlResult(SerializeXml(feed)); + } + + + [HttpGet("{apiKey}/libraries")] + [Produces("application/xml")] + public async Task GetLibraries(string apiKey) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); + var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey); + SetFeedId(feed, "libraries"); + foreach (var library in libraries) + { + feed.Entries.Add(new FeedEntry() + { + Id = library.Id.ToString(), + Title = library.Name, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries/{library.Id}"), + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/collections")] + [Produces("application/xml")] + public async Task GetCollections(string apiKey) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + + IList tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList() + : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList(); + + + var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey); + SetFeedId(feed, "collections"); + foreach (var tag in tags) + { + feed.Entries.Add(new FeedEntry() + { + Id = tag.Id.ToString(), + Title = tag.Title, + Summary = tag.Summary, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections/{tag.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}") + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + + [HttpGet("{apiKey}/collections/{collectionId}")] + [Produces("application/xml")] + public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + + IEnumerable tags; + if (isAdmin) + { + tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + } + else + { + tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + } + + var tag = tags.SingleOrDefault(t => t.Id == collectionId); + if (tag == null) + { + return BadRequest("Collection does not exist or you don't have access"); + } + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, new UserParams() + { + PageNumber = pageNumber, + PageSize = 20 + }); + + var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey); + SetFeedId(feed, $"collections-{collectionId}"); + AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}"); + + foreach (var seriesDto in series) + { + feed.Entries.Add(CreateSeries(seriesDto, apiKey)); + } + + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/reading-list")] + [Produces("application/xml")] + public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, true, new UserParams() + { + PageNumber = pageNumber + }); + + + var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey); + SetFeedId(feed, "reading-list"); + foreach (var readingListDto in readingLists) + { + feed.Entries.Add(new FeedEntry() + { + Id = readingListDto.Id.ToString(), + Title = readingListDto.Title, + Summary = readingListDto.Summary, + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"), + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/reading-list/{readingListId}")] + [Produces("application/xml")] + public async Task GetReadingListItems(int readingListId, string apiKey) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + + var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName); + var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); + if (readingList == null) + { + return BadRequest("Reading list does not exist or you don't have access"); + } + + var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); + SetFeedId(feed, $"reading-list-{readingListId}"); + + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); + foreach (var item in items) + { + feed.Entries.Add(new FeedEntry() + { + Id = item.ChapterId.ToString(), + Title = $"{item.SeriesName} Chapter {item.ChapterNumber}", + Links = new List() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}") + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/libraries/{libraryId}")] + [Produces("application/xml")] + public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var library = + (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l => + l.Id == libraryId); + if (library == null) + { + return BadRequest("User does not have access to this library"); + } + + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, new UserParams() + { + PageNumber = pageNumber, + PageSize = 20 + }, _filterDto); + + var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey); + SetFeedId(feed, $"library-{library.Name}"); + AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}"); + + foreach (var seriesDto in series) + { + feed.Entries.Add(CreateSeries(seriesDto, apiKey)); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/recently-added")] + [Produces("application/xml")] + public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, new UserParams() + { + PageNumber = pageNumber, + PageSize = 20 + }, _filterDto); + + var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey); + SetFeedId(feed, "recently-added"); + AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added"); + + foreach (var seriesDto in recentlyAdded) + { + feed.Entries.Add(CreateSeries(seriesDto, apiKey)); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/on-deck")] + [Produces("application/xml")] + public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var userParams = new UserParams() + { + PageNumber = pageNumber, + PageSize = 20 }; - private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); + var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); + var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) + .Take(userParams.PageSize).ToList(); + var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); - public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, - IDirectoryService directoryService, ICacheService cacheService, - IReaderService readerService) + Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); + + var feed = CreateFeed("On Deck", $"{apiKey}/on-deck", apiKey); + SetFeedId(feed, "on-deck"); + AddPagination(feed, pagedList, $"{Prefix}{apiKey}/on-deck"); + + foreach (var seriesDto in pagedList) { - _unitOfWork = unitOfWork; - _downloadService = downloadService; - _directoryService = directoryService; - _cacheService = cacheService; - _readerService = readerService; - - _xmlSerializer = new XmlSerializer(typeof(Feed)); - _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); - + feed.Entries.Add(CreateSeries(seriesDto, apiKey)); } - [HttpPost("{apiKey}")] - [HttpGet("{apiKey}")] - [Produces("application/xml")] - public async Task Get(string apiKey) + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/series")] + [Produces("application/xml")] + public async Task SearchSeries(string apiKey, [FromQuery] string query) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + if (string.IsNullOrEmpty(query)) + { + return BadRequest("You must pass a query parameter"); + } + query = query.Replace(@"%", ""); + // Get libraries user has access to + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); + + if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); + + var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query); + + var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey); + SetFeedId(feed, "search-series"); + foreach (var seriesDto in series) + { + feed.Entries.Add(CreateSeries(seriesDto, apiKey)); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + private static void SetFeedId(Feed feed, string id) + { + feed.Id = id; + } + + [HttpGet("{apiKey}/search")] + [Produces("application/xml")] + public async Task GetSearchDescriptor(string apiKey) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var feed = new OpenSearchDescription() + { + ShortName = "Search", + Description = "Search for Series", + Url = new SearchLink() + { + Type = FeedLinkType.AtomAcquisition, + Template = $"{Prefix}{apiKey}/series?query=" + "{searchTerms}" + } + }; + + await using var sm = new StringWriter(); + _xmlOpenSearchSerializer.Serialize(sm, feed); + + return CreateXmlResult(sm.ToString().Replace("utf-16", "utf-8")); + } + + [HttpGet("{apiKey}/series/{seriesId}")] + [Produces("application/xml")] + public async Task GetSeries(string apiKey, int seriesId) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); + var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); + SetFeedId(feed, $"series-{series.Id}"); + feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); + foreach (var volumeDto in volumes) + { + feed.Entries.Add(CreateVolume(volumeDto, seriesId, apiKey)); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")] + [Produces("application/xml")] + public async Task GetVolume(string apiKey, int seriesId, int volumeId) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); + var chapters = + (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), + _chapterSortComparer); + + var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); + SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters"); + foreach (var chapter in chapters) { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var feed = CreateFeed("Kavita", string.Empty, apiKey); - SetFeedId(feed, "root"); feed.Entries.Add(new FeedEntry() { - Id = "onDeck", - Title = "On Deck", - Content = new FeedEntryContent() - { - Text = "Browse by On Deck" - }, + Id = chapter.Id.ToString(), + Title = "Chapter " + chapter.Number, Links = new List() { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/on-deck"), + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapter.Id}") } }); - feed.Entries.Add(new FeedEntry() - { - Id = "recentlyAdded", - Title = "Recently Added", - Content = new FeedEntryContent() - { - Text = "Browse by Recently Added" - }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/recently-added"), - } - }); - feed.Entries.Add(new FeedEntry() - { - Id = "readingList", - Title = "Reading Lists", - Content = new FeedEntryContent() - { - Text = "Browse by Reading Lists" - }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list"), - } - }); - feed.Entries.Add(new FeedEntry() - { - Id = "allLibraries", - Title = "All Libraries", - Content = new FeedEntryContent() - { - Text = "Browse by Libraries" - }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries"), - } - }); - feed.Entries.Add(new FeedEntry() - { - Id = "allCollections", - Title = "All Collections", - Content = new FeedEntryContent() - { - Text = "Browse by Collections" - }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections"), - } - }); - return CreateXmlResult(SerializeXml(feed)); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")] + [Produces("application/xml")] + public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var userId = await GetUser(apiKey); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + + var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey); + SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files"); + foreach (var mangaFile in files) + { + feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey)); + } + + return CreateXmlResult(SerializeXml(feed)); + } + + /// + /// Downloads a file + /// + /// + /// + /// + /// Not used. Only for Chunky to allow download links + /// + [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")] + public async Task DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename) + { + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + return BadRequest("OPDS is not enabled on this server"); + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files); + return File(bytes, contentType, fileDownloadName); + } + + private static ContentResult CreateXmlResult(string xml) + { + return new ContentResult + { + ContentType = "application/xml", + Content = xml, + StatusCode = 200 + }; + } + + private static void AddPagination(Feed feed, PagedList list, string href) + { + var url = href; + if (href.Contains("?")) + { + url += "&"; + } + else + { + url += "?"; + } + + var pageNumber = Math.Max(list.CurrentPage, 1); + + if (pageNumber > 1) + { + feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); + } + + if (pageNumber + 1 <= list.TotalPages) + { + feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); + } + + // Update self to point to current page + var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self); + if (selfLink != null) + { + selfLink.Href = url + "pageNumber=" + pageNumber; } - [HttpGet("{apiKey}/libraries")] - [Produces("application/xml")] - public async Task GetLibraries(string apiKey) + feed.Total = list.TotalCount; + feed.ItemsPerPage = list.PageSize; + feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1; + } + + private static FeedEntry CreateSeries(SeriesDto seriesDto, string apiKey) + { + return new FeedEntry() { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId); - var feed = CreateFeed("All Libraries", $"{apiKey}/libraries", apiKey); - SetFeedId(feed, "libraries"); - foreach (var library in libraries) + Id = seriesDto.Id.ToString(), + Title = $"{seriesDto.Name} ({seriesDto.Format})", + Summary = seriesDto.Summary, + Links = new List() { - feed.Entries.Add(new FeedEntry() - { - Id = library.Id.ToString(), - Title = library.Name, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/libraries/{library.Id}"), - } - }); + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}") } + }; + } - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/collections")] - [Produces("application/xml")] - public async Task GetCollections(string apiKey) + private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey) + { + return new FeedEntry() { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); - - IList tags; - if (isAdmin) + Id = searchResultDto.SeriesId.ToString(), + Title = $"{searchResultDto.Name} ({searchResultDto.Format})", + Links = new List() { - tags = (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList(); - } - else - { - tags = (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList(); + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{searchResultDto.SeriesId}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}") } + }; + } - - var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey); - SetFeedId(feed, "collections"); - foreach (var tag in tags) - { - feed.Entries.Add(new FeedEntry() - { - Id = tag.Id.ToString(), - Title = tag.Title, - Summary = tag.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/collections/{tag.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/collection-cover?collectionId={tag.Id}") - } - }); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - - [HttpGet("{apiKey}/collections/{collectionId}")] - [Produces("application/xml")] - public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0) + private static FeedEntry CreateVolume(VolumeDto volumeDto, int seriesId, string apiKey) + { + return new FeedEntry() { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); - - IEnumerable tags; - if (isAdmin) + Id = volumeDto.Id.ToString(), + Title = "Volume " + volumeDto.Name, + Links = new List() { - tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); - } - else - { - tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}") } + }; + } - var tag = tags.SingleOrDefault(t => t.Id == collectionId); - if (tag == null) - { - return BadRequest("Collection does not exist or you don't have access"); - } - - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, new UserParams() - { - PageNumber = pageNumber, - PageSize = 20 - }); - - var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey); - SetFeedId(feed, $"collections-{collectionId}"); - AddPagination(feed, series, $"{Prefix}{apiKey}/collections/{collectionId}"); - - foreach (var seriesDto in series) - { - feed.Entries.Add(CreateSeries(seriesDto, apiKey)); - } - - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/reading-list")] - [Produces("application/xml")] - public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0) + private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey) + { + var fileSize = + DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List() + {mangaFile.FilePath})); + var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); + var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty); + return new FeedEntry() { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - - var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId, true, new UserParams() + Id = mangaFile.Id.ToString(), + Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}", + Extent = fileSize, + Summary = $"{fileType.Split("/")[1]} - {fileSize}", + Format = mangaFile.Format.ToString(), + Links = new List() { - PageNumber = pageNumber - }); - - - var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey); - SetFeedId(feed, "reading-list"); - foreach (var readingListDto in readingLists) + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"), + // Chunky requires a file at the end. Our API ignores this + CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"), + CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey) + }, + Content = new FeedEntryContent() { - feed.Entries.Add(new FeedEntry() - { - Id = readingListDto.Id.ToString(), - Title = readingListDto.Title, - Summary = readingListDto.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"), - } - }); + Text = fileType, + Type = "text" } + }; + } - return CreateXmlResult(SerializeXml(feed)); - } + [HttpGet("{apiKey}/image")] + public async Task GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) + { + if (pageNumber < 0) return BadRequest("Page cannot be less than 0"); + var chapter = await _cacheService.Ensure(chapterId); + if (chapter == null) return BadRequest("There was an issue finding image file for reading"); - [HttpGet("{apiKey}/reading-list/{readingListId}")] - [Produces("application/xml")] - public async Task GetReadingListItems(int readingListId, string apiKey) + try { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var path = _cacheService.GetCachedPagePath(chapter, pageNumber); + if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {pageNumber}"); - var userWithLists = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(user.UserName); - var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); - if (readingList == null) - { - return BadRequest("Reading list does not exist or you don't have access"); - } - - var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); - SetFeedId(feed, $"reading-list-{readingListId}"); - - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); - foreach (var item in items) - { - feed.Entries.Add(new FeedEntry() - { - Id = item.ChapterId.ToString(), - Title = $"{item.SeriesName} Chapter {item.ChapterNumber}", - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}") - } - }); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/libraries/{libraryId}")] - [Produces("application/xml")] - public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var library = - (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l => - l.Id == libraryId); - if (library == null) - { - return BadRequest("User does not have access to this library"); - } - - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, new UserParams() - { - PageNumber = pageNumber, - PageSize = 20 - }, _filterDto); - - var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey); - SetFeedId(feed, $"library-{library.Name}"); - AddPagination(feed, series, $"{Prefix}{apiKey}/libraries/{libraryId}"); - - foreach (var seriesDto in series) - { - feed.Entries.Add(CreateSeries(seriesDto, apiKey)); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/recently-added")] - [Produces("application/xml")] - public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAdded(0, userId, new UserParams() - { - PageNumber = pageNumber, - PageSize = 20 - }, _filterDto); - - var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey); - SetFeedId(feed, "recently-added"); - AddPagination(feed, recentlyAdded, $"{Prefix}{apiKey}/recently-added"); - - foreach (var seriesDto in recentlyAdded) - { - feed.Entries.Add(CreateSeries(seriesDto, apiKey)); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/on-deck")] - [Produces("application/xml")] - public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var userParams = new UserParams() - { - PageNumber = pageNumber, - PageSize = 20 - }; - var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto); - var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize) - .Take(userParams.PageSize).ToList(); - var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize); - - Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - - var feed = CreateFeed("On Deck", $"{apiKey}/on-deck", apiKey); - SetFeedId(feed, "on-deck"); - AddPagination(feed, pagedList, $"{Prefix}{apiKey}/on-deck"); - - foreach (var seriesDto in pagedList) - { - feed.Entries.Add(CreateSeries(seriesDto, apiKey)); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/series")] - [Produces("application/xml")] - public async Task SearchSeries(string apiKey, [FromQuery] string query) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - if (string.IsNullOrEmpty(query)) - { - return BadRequest("You must pass a query parameter"); - } - query = query.Replace(@"%", ""); - // Get libraries user has access to - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); - - if (!libraries.Any()) return BadRequest("User does not have access to any libraries"); - - var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query); - - var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey); - SetFeedId(feed, "search-series"); - foreach (var seriesDto in series) - { - feed.Entries.Add(CreateSeries(seriesDto, apiKey)); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - private static void SetFeedId(Feed feed, string id) - { - feed.Id = id; - } - - [HttpGet("{apiKey}/search")] - [Produces("application/xml")] - public async Task GetSearchDescriptor(string apiKey) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var feed = new OpenSearchDescription() - { - ShortName = "Search", - Description = "Search for Series", - Url = new SearchLink() - { - Type = FeedLinkType.AtomAcquisition, - Template = $"{Prefix}{apiKey}/series?query=" + "{searchTerms}" - } - }; - - await using var sm = new StringWriter(); - _xmlOpenSearchSerializer.Serialize(sm, feed); - - return CreateXmlResult(sm.ToString().Replace("utf-16", "utf-8")); - } - - [HttpGet("{apiKey}/series/{seriesId}")] - [Produces("application/xml")] - public async Task GetSeries(string apiKey, int seriesId) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); - var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); - SetFeedId(feed, $"series-{series.Id}"); - feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); - foreach (var volumeDto in volumes) - { - feed.Entries.Add(CreateVolume(volumeDto, seriesId, apiKey)); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")] - [Produces("application/xml")] - public async Task GetVolume(string apiKey, int seriesId, int volumeId) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - var chapters = - (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), - _chapterSortComparer); - - var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey); - SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters"); - foreach (var chapter in chapters) - { - feed.Entries.Add(new FeedEntry() - { - Id = chapter.Id.ToString(), - Title = "Chapter " + chapter.Number, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapter.Id}") - } - }); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")] - [Produces("application/xml")] - public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var userId = await GetUser(apiKey); - var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - - var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey); - SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files"); - foreach (var mangaFile in files) - { - feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey)); - } - - return CreateXmlResult(SerializeXml(feed)); - } - - /// - /// Downloads a file - /// - /// - /// - /// - /// Not used. Only for Chunky to allow download links - /// - [HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}")] - public async Task DownloadFile(string apiKey, int seriesId, int volumeId, int chapterId, string filename) - { - if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) - return BadRequest("OPDS is not enabled on this server"); - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files); - return File(bytes, contentType, fileDownloadName); - } - - private static ContentResult CreateXmlResult(string xml) - { - return new ContentResult - { - ContentType = "application/xml", - Content = xml, - StatusCode = 200 - }; - } - - private static void AddPagination(Feed feed, PagedList list, string href) - { - var url = href; - if (href.Contains("?")) - { - url += "&"; - } - else - { - url += "?"; - } - - var pageNumber = Math.Max(list.CurrentPage, 1); - - if (pageNumber > 1) - { - feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); - } - - if (pageNumber + 1 < list.TotalPages) - { - feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); - } - - // Update self to point to current page - var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self); - if (selfLink != null) - { - selfLink.Href = url + "pageNumber=" + pageNumber; - } - - - feed.Total = list.TotalPages * list.PageSize; - feed.ItemsPerPage = list.PageSize; - feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1; - } - - private static FeedEntry CreateSeries(SeriesDto seriesDto, string apiKey) - { - return new FeedEntry() - { - Id = seriesDto.Id.ToString(), - Title = $"{seriesDto.Name} ({seriesDto.Format})", - Summary = seriesDto.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesDto.Id}") - } - }; - } - - private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey) - { - return new FeedEntry() - { - Id = searchResultDto.SeriesId.ToString(), - Title = $"{searchResultDto.Name} ({searchResultDto.Format})", - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{searchResultDto.SeriesId}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/series-cover?seriesId={searchResultDto.SeriesId}") - } - }; - } - - private static FeedEntry CreateVolume(VolumeDto volumeDto, int seriesId, string apiKey) - { - return new FeedEntry() - { - Id = volumeDto.Id.ToString(), - Title = "Volume " + volumeDto.Name, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}") - } - }; - } - - private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey) - { - var fileSize = - DirectoryService.GetHumanReadableBytes(DirectoryService.GetTotalSize(new List() - {mangaFile.FilePath})); - var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); - var filename = Uri.EscapeUriString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty); - return new FeedEntry() - { - Id = mangaFile.Id.ToString(), - Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}", - Extent = fileSize, - Summary = $"{fileType.Split("/")[1]} - {fileSize}", - Format = mangaFile.Format.ToString(), - Links = new List() - { - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"), - // Chunky requires a file at the end. Our API ignores this - CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"), - CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey) - }, - Content = new FeedEntryContent() - { - Text = fileType, - Type = "text" - } - }; - } - - [HttpGet("{apiKey}/image")] - public async Task GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber) - { - if (pageNumber < 0) return BadRequest("Page cannot be less than 0"); - var chapter = await _cacheService.Ensure(chapterId); - if (chapter == null) return BadRequest("There was an issue finding image file for reading"); - - try - { - var (path, _) = await _cacheService.GetCachedPagePath(chapter, pageNumber); - if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {pageNumber}"); - - var content = await _directoryService.ReadFileAsync(path); - var format = Path.GetExtension(path).Replace(".", ""); - - // Calculates SHA1 Hash for byte[] - Response.AddCacheHeader(content); - - // Save progress for the user - await _readerService.SaveReadingProgress(new ProgressDto() - { - ChapterId = chapterId, - PageNum = pageNumber, - SeriesId = seriesId, - VolumeId = volumeId - }, await GetUser(apiKey)); - - return File(content, "image/" + format); - } - catch (Exception) - { - _cacheService.CleanupChapters(new []{ chapterId }); - throw; - } - } - - [HttpGet("{apiKey}/favicon")] - public async Task GetFavicon(string apiKey) - { - var files = DirectoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico"); - if (files.Length == 0) return BadRequest("Cannot find icon"); - var path = files[0]; var content = await _directoryService.ReadFileAsync(path); var format = Path.GetExtension(path).Replace(".", ""); // Calculates SHA1 Hash for byte[] Response.AddCacheHeader(content); + // Save progress for the user + await _readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = chapterId, + PageNum = pageNumber, + SeriesId = seriesId, + VolumeId = volumeId + }, await GetUser(apiKey)); + return File(content, "image/" + format); } - - /// - /// Gets the user from the API key - /// - /// - private async Task GetUser(string apiKey) + catch (Exception) { - try - { - var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - return user; - } - catch - { - /* Do nothing */ - } - throw new KavitaException("User does not exist"); - } - - private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) - { - var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); - link.TotalPages = mangaFile.Pages; - return link; - } - - private static FeedLink CreateLink(string rel, string type, string href) - { - return new FeedLink() - { - Rel = rel, - Href = href, - Type = type - }; - } - - private static Feed CreateFeed(string title, string href, string apiKey) - { - var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ? - FeedLinkType.AtomNavigation : - FeedLinkType.AtomAcquisition, Prefix + href); - - return new Feed() - { - Title = title, - Icon = Prefix + $"{apiKey}/favicon", - Links = new List() - { - link, - CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, Prefix + apiKey), - CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, Prefix + $"{apiKey}/search") - }, - }; - } - - private string SerializeXml(Feed feed) - { - if (feed == null) return string.Empty; - using var sm = new StringWriter(); - _xmlSerializer.Serialize(sm, feed); - return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + _cacheService.CleanupChapters(new []{ chapterId }); + throw; } } + + [HttpGet("{apiKey}/favicon")] + public async Task GetFavicon(string apiKey) + { + var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico"); + if (files.Length == 0) return BadRequest("Cannot find icon"); + var path = files[0]; + var content = await _directoryService.ReadFileAsync(path); + var format = Path.GetExtension(path).Replace(".", ""); + + // Calculates SHA1 Hash for byte[] + Response.AddCacheHeader(content); + + return File(content, "image/" + format); + } + + /// + /// Gets the user from the API key + /// + /// + private async Task GetUser(string apiKey) + { + try + { + var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + return user; + } + catch + { + /* Do nothing */ + } + throw new KavitaException("User does not exist"); + } + + private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey) + { + var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); + link.TotalPages = mangaFile.Pages; + return link; + } + + private static FeedLink CreateLink(string rel, string type, string href) + { + return new FeedLink() + { + Rel = rel, + Href = href, + Type = type + }; + } + + private static Feed CreateFeed(string title, string href, string apiKey) + { + var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ? + FeedLinkType.AtomNavigation : + FeedLinkType.AtomAcquisition, Prefix + href); + + return new Feed() + { + Title = title, + Icon = Prefix + $"{apiKey}/favicon", + Links = new List() + { + link, + CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, Prefix + apiKey), + CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, Prefix + $"{apiKey}/search") + }, + }; + } + + private string SerializeXml(Feed feed) + { + if (feed == null) return string.Empty; + using var sm = new StringWriter(); + _xmlSerializer.Serialize(sm, feed); + return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + } } diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index b176c0628..5f2d99ba3 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; +using API.Data; using API.DTOs; -using API.Interfaces; -using API.Interfaces.Services; +using API.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 5b18cfb98..3028d1fee 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -3,13 +3,15 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Reader; using API.Entities; +using API.Entities.Enums; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; +using API.Services; +using API.Services.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -24,15 +26,21 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IReaderService _readerService; + private readonly IDirectoryService _directoryService; + private readonly ICleanupService _cleanupService; /// public ReaderController(ICacheService cacheService, - IUnitOfWork unitOfWork, ILogger logger, IReaderService readerService) + IUnitOfWork unitOfWork, ILogger logger, + IReaderService readerService, IDirectoryService directoryService, + ICleanupService cleanupService) { _cacheService = cacheService; _unitOfWork = unitOfWork; _logger = logger; _readerService = readerService; + _directoryService = directoryService; + _cleanupService = cleanupService; } /// @@ -50,7 +58,7 @@ namespace API.Controllers try { - var (path, _) = await _cacheService.GetCachedPagePath(chapter, page); + var path = _cacheService.GetCachedPagePath(chapter, page); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}"); var format = Path.GetExtension(path).Replace(".", ""); @@ -75,6 +83,7 @@ namespace API.Controllers 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(); return Ok(new ChapterInfoDto() @@ -89,6 +98,7 @@ namespace API.Controllers LibraryId = dto.LibraryId, IsSpecial = dto.IsSpecial, Pages = dto.Pages, + ChapterTitle = dto.ChapterTitle ?? string.Empty }); } @@ -129,15 +139,7 @@ namespace API.Controllers user.Progresses ??= new List(); foreach (var volume in volumes) { - foreach (var chapter in volume.Chapters) - { - var userProgress = ReaderService.GetUserProgressForChapter(user, chapter); - - if (userProgress == null) continue; - userProgress.PagesRead = 0; - userProgress.SeriesId = markReadDto.SeriesId; - userProgress.VolumeId = volume.Id; - } + _readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters); } _unitOfWork.UserRepository.Update(user); @@ -396,6 +398,14 @@ namespace API.Controllers if (await _unitOfWork.CommitAsync()) { + try + { + await _cleanupService.CleanupBookmarks(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue cleaning up old bookmarks"); + } return Ok(); } } @@ -453,6 +463,18 @@ namespace API.Controllers var userBookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id); + // We need to get the image + var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); + if (chapter == null) return BadRequest("There was an issue finding image file for reading"); + var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); + var fileInfo = new FileInfo(path); + + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + _directoryService.CopyFileToDirectory(path, Path.Join(bookmarkDirectory, + $"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}")); + + if (userBookmark == null) { user.Bookmarks ??= new List(); @@ -462,22 +484,21 @@ namespace API.Controllers VolumeId = bookmarkDto.VolumeId, SeriesId = bookmarkDto.SeriesId, ChapterId = bookmarkDto.ChapterId, + FileName = Path.Join($"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}", fileInfo.Name) + }); _unitOfWork.UserRepository.Update(user); } - - if (await _unitOfWork.CommitAsync()) - { - return Ok(); - } + await _unitOfWork.CommitAsync(); } catch (Exception) { await _unitOfWork.RollbackAsync(); + return BadRequest("Could not save bookmark"); } - return BadRequest("Could not save bookmark"); + return Ok(); } /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 19e4a4b49..9391105cb 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data; using API.DTOs.ReadingLists; using API.Entities; using API.Extensions; using API.Helpers; -using API.Interfaces; using Microsoft.AspNetCore.Mvc; namespace API.Controllers diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index ba0571ec3..ad2faeb0f 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -3,15 +3,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Metadata; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Helpers; -using API.Interfaces; +using API.Services; using API.SignalR; using Kavita.Common; +using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; @@ -80,6 +83,8 @@ namespace API.Controllers var username = User.GetUsername(); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId})); var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId); @@ -89,6 +94,8 @@ namespace API.Controllers await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); await _unitOfWork.CommitAsync(); _taskScheduler.CleanupChapters(chapterIds); + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(seriesId, series.Name, series.LibraryId)); } return Ok(result); } @@ -151,7 +158,7 @@ namespace API.Controllers public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings); - var userRating = await _unitOfWork.UserRepository.GetUserRating(updateSeriesRatingDto.SeriesId, user.Id) ?? + var userRating = await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ?? new AppUserRating(); userRating.Rating = updateSeriesRatingDto.UserRating; @@ -187,7 +194,7 @@ namespace API.Controllers series.Name = updateSeries.Name.Trim(); series.LocalizedName = updateSeries.LocalizedName.Trim(); series.SortName = updateSeries.SortName?.Trim(); - series.Summary = updateSeries.Summary?.Trim(); + series.Metadata.Summary = updateSeries.Summary?.Trim(); var needsRefreshMetadata = false; // This is when you hit Reset @@ -230,6 +237,23 @@ namespace API.Controllers return Ok(series); } + [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"); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + /// /// Fetches series that are on deck aka have progress on them. /// @@ -294,6 +318,7 @@ namespace API.Controllers else { series.Metadata.CollectionTags ??= new List(); + // TODO: Move this merging logic into a reusable code as it can be used for any Tag var newTags = new List(); // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different @@ -313,7 +338,7 @@ namespace API.Controllers var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title); if (existingTag != null) { - if (!series.Metadata.CollectionTags.Any(t => t.Title == tag.Title)) + if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title)) { newTags.Add(existingTag); } @@ -392,6 +417,12 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); } + [HttpGet("age-rating")] + public ActionResult GetAgeRating(int ageRating) + { + var val = (AgeRating) ageRating; + return Ok(val.ToDescription()); + } } } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 04ffa3428..45fb22ce5 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -5,7 +5,7 @@ using System.Threading.Tasks; using API.DTOs.Stats; using API.DTOs.Update; using API.Extensions; -using API.Interfaces.Services; +using API.Services; using API.Services.Tasks; using Kavita.Common; using Microsoft.AspNetCore.Authorization; @@ -27,10 +27,11 @@ namespace API.Controllers private readonly ICacheService _cacheService; private readonly IVersionUpdaterService _versionUpdaterService; private readonly IStatsService _statsService; + private readonly ICleanupService _cleanupService; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config, IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, - IVersionUpdaterService versionUpdaterService, IStatsService statsService) + IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService) { _applicationLifetime = applicationLifetime; _logger = logger; @@ -40,6 +41,7 @@ namespace API.Controllers _cacheService = cacheService; _versionUpdaterService = versionUpdaterService; _statsService = statsService; + _cleanupService = cleanupService; } /// @@ -63,7 +65,7 @@ namespace API.Controllers public ActionResult ClearCache() { _logger.LogInformation("{UserName} is clearing cache of server from admin dashboard", User.GetUsername()); - _cacheService.Cleanup(); + _cleanupService.CleanupCacheDirectory(); return Ok(); } @@ -94,7 +96,7 @@ namespace API.Controllers [HttpGet("logs")] public async Task GetLogs() { - var files = _backupService.LogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName()); + var files = _backupService.GetLogFiles(_config.GetMaxRollingFiles(), _config.GetLoggingFileName()); try { var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files, "logs"); diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index c8b3248ba..76b30acf8 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -3,13 +3,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.DTOs.Settings; using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; +using AutoMapper; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; @@ -24,13 +24,18 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ITaskScheduler _taskScheduler; private readonly IAccountService _accountService; + private readonly IDirectoryService _directoryService; + private readonly IMapper _mapper; - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IAccountService accountService) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, + IAccountService accountService, IDirectoryService directoryService, IMapper mapper) { _logger = logger; _unitOfWork = unitOfWork; _taskScheduler = taskScheduler; _accountService = accountService; + _directoryService = directoryService; + _mapper = mapper; } [AllowAnonymous] @@ -46,11 +51,21 @@ namespace API.Controllers public async Task> GetSettings() { var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + // TODO: Is this needed as it gets updated in the DB on startup settingsDto.Port = Configuration.Port; settingsDto.LoggingLevel = Configuration.LogLevel; 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")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) @@ -70,6 +85,20 @@ namespace API.Controllers // We do not allow CacheDirectory changes, so we will ignore. var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); var updateAuthentication = false; + 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"); + } + + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + { + bookmarkDirectory = _directoryService.BookmarkDirectory; + } foreach (var setting in currentSettings) { @@ -118,6 +147,22 @@ namespace API.Controllers _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // 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; + + } + if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value) { setting.Value = updateSettingsDto.EnableAuthentication + string.Empty; @@ -160,6 +205,13 @@ namespace API.Controllers _logger.LogInformation("Server authentication changed. Updated all non-admins to default password"); } + + if (updateBookmarks) + { + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); + } } catch (Exception ex) { @@ -170,7 +222,7 @@ namespace API.Controllers _logger.LogInformation("Server Settings updated"); - _taskScheduler.ScheduleTasks(); + await _taskScheduler.ScheduleTasks(); return Ok(updateSettingsDto); } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 43c9b8d09..b383021e2 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -1,8 +1,7 @@ using System; using System.Threading.Tasks; +using API.Data; using API.DTOs.Uploads; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index f5171b819..7662fdf95 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.Data.Repositories; using API.DTOs; using API.Extensions; -using API.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 8c791a395..6a4effe16 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using API.DTOs.Metadata; +using API.Entities; namespace API.DTOs { @@ -50,5 +52,41 @@ namespace API.DTOs /// 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 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; } + public ICollection Writers { get; set; } = new List(); + public ICollection Penciller { get; set; } = new List(); + public ICollection Inker { get; set; } = new List(); + public ICollection Colorist { get; set; } = new List(); + public ICollection Letterer { get; set; } = new List(); + public ICollection CoverArtist { get; set; } = new List(); + public ICollection Editor { get; set; } = new List(); + public ICollection Publisher { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); } } diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs index c5956f17d..863e66e6e 100644 --- a/API/DTOs/Filtering/FilterDto.cs +++ b/API/DTOs/Filtering/FilterDto.cs @@ -1,13 +1,98 @@ -using API.Entities.Enums; +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; namespace API.DTOs.Filtering { public class FilterDto { /// - /// Pass null if you want all formats + /// The type of Formats you want to be returned. An empty list will return all formats back /// - public MangaFormat? MangaFormat { get; init; } = null; + 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(); + + /// + /// 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 genres by passing an empty list + /// + public IList Writers { get; init; } = new List(); + /// + /// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Penciller { get; init; } = new List(); + /// + /// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Inker { get; init; } = new List(); + /// + /// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Colorist { get; init; } = new List(); + /// + /// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Letterer { get; init; } = new List(); + /// + /// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList CoverArtist { get; init; } = new List(); + /// + /// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Editor { get; init; } = new List(); + /// + /// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Publisher { get; init; } = new List(); + /// + /// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list + /// + public IList Character { get; init; } = new List(); + /// + /// A list of Translator ids to restrict search to. Defaults to all genres 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 genres by passing an empty list + /// + public IList CollectionTags { get; init; } = new List(); + /// + /// A list of Tag ids to restrict search to. Defaults to all genres 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; init; } = 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(); } } diff --git a/API/DTOs/Filtering/LanguageDto.cs b/API/DTOs/Filtering/LanguageDto.cs new file mode 100644 index 000000000..b09aed5d1 --- /dev/null +++ b/API/DTOs/Filtering/LanguageDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Filtering; + +public class LanguageDto +{ + public string IsoCode { get; set; } + public string Title { get; set; } +} diff --git a/API/DTOs/Filtering/ReadStatus.cs b/API/DTOs/Filtering/ReadStatus.cs new file mode 100644 index 000000000..e2452fdc1 --- /dev/null +++ b/API/DTOs/Filtering/ReadStatus.cs @@ -0,0 +1,13 @@ +using System; + +namespace API.DTOs.Filtering; + +/// +/// Represents the Reading Status. This is a flag and allows multiple statues +/// +public class ReadStatus +{ + public bool NotRead { get; set; } = true; + public bool InProgress { get; set; } = true; + public bool Read { get; set; } = true; +} diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs new file mode 100644 index 000000000..0e465f6aa --- /dev/null +++ b/API/DTOs/Filtering/SortField.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Filtering; + +public enum SortField +{ + SortName = 1, + CreatedDate = 2, + LastModifiedDate = 3, +} diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs new file mode 100644 index 000000000..00bf91675 --- /dev/null +++ b/API/DTOs/Filtering/SortOptions.cs @@ -0,0 +1,10 @@ +namespace API.DTOs.Filtering; + +/// +/// Sorting Options for a query +/// +public class SortOptions +{ + public SortField SortField { get; set; } + public bool IsAscending { get; set; } = true; +} diff --git a/API/DTOs/Metadata/AgeRatingDto.cs b/API/DTOs/Metadata/AgeRatingDto.cs new file mode 100644 index 000000000..cbeb44e33 --- /dev/null +++ b/API/DTOs/Metadata/AgeRatingDto.cs @@ -0,0 +1,9 @@ +using API.Entities.Enums; + +namespace API.DTOs.Metadata; + +public class AgeRatingDto +{ + public AgeRating Value { get; set; } + public string Title { get; set; } +} diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs new file mode 100644 index 000000000..77b89fe94 --- /dev/null +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace API.DTOs.Metadata +{ + public class ChapterMetadataDto + { + public int Id { get; set; } + public string Title { get; set; } + public ICollection Writers { get; set; } = new List(); + public ICollection Penciller { get; set; } = new List(); + public ICollection Inker { get; set; } = new List(); + public ICollection Colorist { get; set; } = new List(); + public ICollection Letterer { get; set; } = new List(); + public ICollection CoverArtist { get; set; } = new List(); + public ICollection Editor { get; set; } = new List(); + public ICollection Publisher { get; set; } = new List(); + public int ChapterId { get; set; } + } +} diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs new file mode 100644 index 000000000..e6ea03130 --- /dev/null +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Metadata +{ + public class GenreTagDto + { + public int Id { get; set; } + public string Title { get; set; } + } +} diff --git a/API/DTOs/Metadata/PublicationStatusDto.cs b/API/DTOs/Metadata/PublicationStatusDto.cs new file mode 100644 index 000000000..332223428 --- /dev/null +++ b/API/DTOs/Metadata/PublicationStatusDto.cs @@ -0,0 +1,9 @@ +using API.Entities.Enums; + +namespace API.DTOs.Metadata; + +public class PublicationStatusDto +{ + public PublicationStatus Value { get; set; } + public string Title { get; set; } +} diff --git a/API/DTOs/Metadata/TagDto.cs b/API/DTOs/Metadata/TagDto.cs new file mode 100644 index 000000000..6e9b2f71e --- /dev/null +++ b/API/DTOs/Metadata/TagDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Metadata; + +public class TagDto +{ + 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 95f08448e..efbffe8ac 100644 --- a/API/DTOs/OPDS/Feed.cs +++ b/API/DTOs/OPDS/Feed.cs @@ -23,7 +23,7 @@ namespace API.DTOs.OPDS public string Icon { get; set; } = "/favicon.ico"; [XmlElement("author")] - public Author Author { get; set; } = new Author() + public FeedAuthor Author { get; set; } = new FeedAuthor() { Name = "Kavita", Uri = "https://kavitareader.com" diff --git a/API/DTOs/OPDS/Author.cs b/API/DTOs/OPDS/FeedAuthor.cs similarity index 88% rename from API/DTOs/OPDS/Author.cs rename to API/DTOs/OPDS/FeedAuthor.cs index 1758a037e..ec0446d73 100644 --- a/API/DTOs/OPDS/Author.cs +++ b/API/DTOs/OPDS/FeedAuthor.cs @@ -2,7 +2,7 @@ namespace API.DTOs.OPDS { - public class Author + public class FeedAuthor { [XmlElement("name")] public string Name { get; set; } diff --git a/API/DTOs/PersonDto.cs b/API/DTOs/PersonDto.cs index 646817c1d..0ab7a4076 100644 --- a/API/DTOs/PersonDto.cs +++ b/API/DTOs/PersonDto.cs @@ -4,7 +4,8 @@ namespace API.DTOs { public class PersonDto { + public int Id { get; set; } public string Name { get; set; } public PersonRole Role { get; set; } } -} \ No newline at end of file +} diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index 6705c9647..b881c1b10 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -14,5 +14,6 @@ namespace API.DTOs.Reader 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/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index ec512670d..e29f3798c 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using System; +using API.Entities.Enums; namespace API.DTOs.Reader { @@ -12,7 +13,7 @@ namespace API.DTOs.Reader public MangaFormat SeriesFormat { get; set; } public int SeriesId { get; set; } public int LibraryId { get; set; } - public string ChapterTitle { get; set; } = ""; + public string ChapterTitle { get; set; } = string.Empty; public int Pages { get; set; } public string FileName { get; set; } public bool IsSpecial { get; set; } diff --git a/API/DTOs/Reader/IChapterInfoDto.cs b/API/DTOs/Reader/IChapterInfoDto.cs index 67aa6caf6..e448e5e13 100644 --- a/API/DTOs/Reader/IChapterInfoDto.cs +++ b/API/DTOs/Reader/IChapterInfoDto.cs @@ -13,6 +13,7 @@ namespace API.DTOs.Reader 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/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 69dcae2d9..fbee305ac 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,16 +1,61 @@ using System.Collections.Generic; using API.DTOs.CollectionTags; -using API.Entities; +using API.DTOs.Metadata; +using API.Entities.Enums; namespace API.DTOs { public class SeriesMetadataDto { public int Id { get; set; } - public ICollection Genres { get; set; } - public ICollection Tags { get; set; } - public ICollection Persons { get; set; } - public string Publisher { get; set; } + public string Summary { get; set; } + /// + /// 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; + /// + /// Number in the TotalCount of issues + /// + public int Count { get; set; } + /// + /// Total number of issues for the series + /// + public int TotalCount { get; set; } + /// + /// Publication status of the Series + /// + public PublicationStatus PublicationStatus { get; set; } + public int SeriesId { get; set; } } -} \ No newline at end of file +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index aace57127..4004c65b1 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Settings +using API.Services; + +namespace API.DTOs.Settings { public class ServerSettingDto { @@ -30,5 +32,10 @@ /// 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; } } } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 03c56c567..66c979cc4 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,6 +1,4 @@ -using System; - -namespace API.DTOs.Update +namespace API.DTOs.Update { /// /// Update Notification denoting a new release available for user to update to diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 03dbeaa5e..c36c9d146 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -18,4 +18,4 @@ namespace API.DTOs public ReadingDirection BookReaderReadingDirection { get; set; } public bool SiteDarkMode { get; set; } } -} \ No newline at end of file +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 8e4dc263e..c1e100d48 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using API.Entities; using API.Entities.Interfaces; +using API.Entities.Metadata; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,6 @@ namespace API.Data 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; } @@ -37,6 +37,9 @@ namespace API.Data 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; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index a57ba4037..d5b3434f7 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.IO; +using API.Data.Metadata; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Extensions; using API.Parser; using API.Services.Tasks; @@ -21,12 +25,16 @@ namespace API.Data LocalizedName = name, NormalizedName = Parser.Parser.Normalize(name), SortName = name, - Summary = string.Empty, Volumes = new List(), Metadata = SeriesMetadata(Array.Empty()) }; } + public static SeriesMetadata SeriesMetadata(ComicInfo info) + { + return SeriesMetadata(Array.Empty()); + } + public static Volume Volume(string volumeNumber) { return new Volume() @@ -57,7 +65,8 @@ namespace API.Data { return new SeriesMetadata() { - CollectionTags = collectionTags + CollectionTags = collectionTags, + Summary = string.Empty }; } @@ -72,5 +81,47 @@ namespace API.Data Promoted = promoted }; } + + public static Genre Genre(string name, bool external) + { + return new Genre() + { + Title = name.Trim().SentenceCase(), + NormalizedTitle = Parser.Parser.Normalize(name), + ExternalTag = external + }; + } + + public static Tag Tag(string name, bool external) + { + return new Tag() + { + Title = name.Trim().SentenceCase(), + NormalizedTitle = Parser.Parser.Normalize(name), + ExternalTag = external + }; + } + + public static Person Person(string name, PersonRole role) + { + return new Person() + { + Name = name.Trim(), + NormalizedName = 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/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 9f846ea42..cc7154f93 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -1,51 +1,121 @@ -namespace API.Data.Metadata +using System; +using System.Linq; +using API.Entities.Enums; +using Kavita.Common.Extensions; + +namespace API.Data.Metadata { /// /// A representation of a ComicInfo.xml file /// - /// See reference of the loose spec here: https://github.com/Kussie/ComicInfoStandard/blob/main/ComicInfo.xsd + /// See reference of the loose spec here: https://anansi-project.github.io/docs/comicinfo/documentation public class ComicInfo { - public string Summary { get; set; } - public string Title { get; set; } - public string Series { get; set; } - public string Number { get; set; } - public string Volume { get; set; } - public string Notes { get; set; } - public string Genre { get; set; } + public string Summary { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Series { 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 - public string LanguageISO { get; set; } - public string Web { get; set; } - public int Month { get; set; } - public int Year { get; set; } /// - /// Rating based on the content. Think PG-13, R for movies + /// ISO 639-1 Code to represent the language of the content /// - public string AgeRating { get; set; } + 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; } - public string AlternateSeries { get; set; } - public string StoryArc { get; set; } - public string SeriesGroup { get; set; } - public string AlternativeSeries { get; set; } - public string AlternativeNumber { get; set; } + public string AlternateSeries { get; set; } = string.Empty; + public string StoryArc { get; set; } = string.Empty; + public string SeriesGroup { get; set; } = string.Empty; + public string AlternativeSeries { get; set; } = string.Empty; + public string AlternativeNumber { 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; + + /// + /// 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; } // TODO: Validate if we should make this a list of writers - public string Penciller { get; set; } - public string Inker { get; set; } - public string Colorist { get; set; } - public string Letterer { get; set; } - public string CoverArtist { get; set; } - public string Editor { get; set; } - public string Publisher { get; set; } + 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 void CleanComicInfo(ComicInfo info) + { + if (info == null) return; + + info.Writer = Parser.Parser.CleanAuthor(info.Writer); + info.Colorist = Parser.Parser.CleanAuthor(info.Colorist); + info.Editor = Parser.Parser.CleanAuthor(info.Editor); + info.Inker = Parser.Parser.CleanAuthor(info.Inker); + info.Letterer = Parser.Parser.CleanAuthor(info.Letterer); + info.Penciller = Parser.Parser.CleanAuthor(info.Penciller); + info.Publisher = Parser.Parser.CleanAuthor(info.Publisher); + info.Characters = Parser.Parser.CleanAuthor(info.Characters); + info.Translator = Parser.Parser.CleanAuthor(info.Translator); + info.CoverArtist = Parser.Parser.CleanAuthor(info.CoverArtist); + + + // if (!string.IsNullOrEmpty(info.Web)) + // { + // // ComicVine stores the Issue number in Number field and does not use Volume. + // if (!info.Web.Contains("https://comicvine.gamespot.com/")) return; + // if (info.Volume.Equals("1")) + // { + // info.Volume = Parser.Parser.DefaultVolume; + // } + // } + } + } } diff --git a/API/Data/MigrateBookmarks.cs b/API/Data/MigrateBookmarks.cs new file mode 100644 index 000000000..043b3e0a4 --- /dev/null +++ b/API/Data/MigrateBookmarks.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Comparators; +using API.Entities.Enums; +using API.Services; +using Microsoft.Extensions.Logging; + +namespace API.Data; + +/// +/// Responsible to migrate existing bookmarks to files +/// +public static class MigrateBookmarks +{ + private static readonly Version VersionBookmarksChanged = new Version(0, 4, 9, 27); + /// + /// This will migrate existing bookmarks to bookmark folder based + /// + /// Bookmark directory is configurable. This will always use the default bookmark directory. + /// + /// + public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork, + ILogger logger, ICacheService cacheService) + { + var bookmarkDirectory = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)) + .Value; + if (string.IsNullOrEmpty(bookmarkDirectory)) + { + bookmarkDirectory = directoryService.BookmarkDirectory; + } + + if (directoryService.Exists(bookmarkDirectory)) return; + + logger.LogInformation("Bookmark migration is needed....This may take some time"); + + var allBookmarks = (await unitOfWork.UserRepository.GetAllBookmarksAsync()).ToList(); + + var uniqueChapterIds = allBookmarks.Select(b => b.ChapterId).Distinct().ToList(); + var uniqueUserIds = allBookmarks.Select(b => b.AppUserId).Distinct().ToList(); + foreach (var userId in uniqueUserIds) + { + foreach (var chapterId in uniqueChapterIds) + { + var chapterBookmarks = allBookmarks.Where(b => b.ChapterId == chapterId).ToList(); + var chapterPages = chapterBookmarks + .Select(b => b.Page).ToList(); + var seriesId = chapterBookmarks + .Select(b => b.SeriesId).First(); + var mangaFiles = await unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + var chapterExtractPath = directoryService.FileSystem.Path.Join(directoryService.TempDirectory, $"bookmark_c{chapterId}_u{userId}_s{seriesId}"); + + var numericComparer = new NumericComparer(); + if (!mangaFiles.Any()) continue; + + switch (mangaFiles.First().Format) + { + case MangaFormat.Image: + directoryService.ExistOrCreate(chapterExtractPath); + directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath); + break; + case MangaFormat.Archive: + case MangaFormat.Pdf: + cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList()); + break; + case MangaFormat.Epub: + continue; + default: + continue; + } + + var files = directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions); + // Filter out images that aren't in bookmarks + Array.Sort(files, numericComparer); + foreach (var chapterPage in chapterPages) + { + var file = files.ElementAt(chapterPage); + var bookmark = allBookmarks.FirstOrDefault(b => + b.ChapterId == chapterId && b.SeriesId == seriesId && b.AppUserId == userId && + b.Page == chapterPage); + if (bookmark == null) continue; + + var filename = directoryService.FileSystem.Path.GetFileName(file); + var newLocation = directoryService.FileSystem.Path.Join( + ReaderService.FormatBookmarkFolderPath(String.Empty, userId, seriesId, chapterId), + filename); + bookmark.FileName = newLocation; + directoryService.CopyFileToDirectory(file, + ReaderService.FormatBookmarkFolderPath(bookmarkDirectory, userId, seriesId, chapterId)); + unitOfWork.UserRepository.Update(bookmark); + } + } + // Clear temp after each user to avoid too much space being eaten + directoryService.ClearDirectory(directoryService.TempDirectory); + } + + await unitOfWork.CommitAsync(); + // Run CleanupService as we cache a ton of files + directoryService.ClearDirectory(directoryService.TempDirectory); + + } +} diff --git a/API/Data/MigrateConfigFiles.cs b/API/Data/MigrateConfigFiles.cs index 752b03192..51ee37167 100644 --- a/API/Data/MigrateConfigFiles.cs +++ b/API/Data/MigrateConfigFiles.cs @@ -1,12 +1,16 @@ 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() @@ -31,7 +35,7 @@ namespace API.Data /// 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) + public static void Migrate(bool isDocker, IDirectoryService directoryService) { Console.WriteLine("Checking if migration to config/ is needed"); @@ -46,8 +50,8 @@ namespace API.Data Console.WriteLine( "Migrating files from pre-v0.4.8. All Kavita config files are now located in config/"); - CopyAppFolders(); - DeleteAppFolders(); + CopyAppFolders(directoryService); + DeleteAppFolders(directoryService); UpdateConfiguration(); @@ -64,14 +68,14 @@ namespace API.Data 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); + Console.WriteLine($"Creating {directoryService.ConfigDirectory}"); + directoryService.ExistOrCreate(directoryService.ConfigDirectory); try { - CopyLooseLeafFiles(); + CopyLooseLeafFiles(directoryService); - CopyAppFolders(); + CopyAppFolders(directoryService); // Then we need to update the config file to point to the new DB file UpdateConfiguration(); @@ -84,43 +88,43 @@ namespace API.Data // Finally delete everything in the source directory Console.WriteLine("Removing old files"); - DeleteLooseFiles(); - DeleteAppFolders(); + 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() + 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)); + directoryService.ClearAndDeleteDirectory(Path.Join(Directory.GetCurrentDirectory(), folderToDelete)); } } - private static void DeleteLooseFiles() + 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)); + directoryService.DeleteFiles(configFiles.Select(f => f.FullName)); } - private static void CopyAppFolders() + 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; + if (new DirectoryInfo(Path.Join(directoryService.ConfigDirectory, folderToMove)).Exists) continue; try { - DirectoryService.CopyDirectoryToDirectory( - Path.Join(Directory.GetCurrentDirectory(), folderToMove), - Path.Join(DirectoryService.ConfigDirectory, folderToMove)); + directoryService.CopyDirectoryToDirectory( + Path.Join(directoryService.FileSystem.Directory.GetCurrentDirectory(), folderToMove), + Path.Join(directoryService.ConfigDirectory, folderToMove)); } catch (Exception) { @@ -132,9 +136,9 @@ namespace API.Data Console.WriteLine("Moving folders to config...DONE"); } - private static void CopyLooseLeafFiles() + private static void CopyLooseLeafFiles(IDirectoryService directoryService) { - var configFiles = LooseLeafFiles.Select(file => new FileInfo(Path.Join(Directory.GetCurrentDirectory(), file))) + 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/"); @@ -142,7 +146,7 @@ namespace API.Data { try { - fileInfo.CopyTo(Path.Join(DirectoryService.ConfigDirectory, fileInfo.Name)); + fileInfo.CopyTo(Path.Join(directoryService.ConfigDirectory, fileInfo.Name)); } catch (Exception) { diff --git a/API/Data/MigrateCoverImages.cs b/API/Data/MigrateCoverImages.cs index 87e65cb81..b5839509a 100644 --- a/API/Data/MigrateCoverImages.cs +++ b/API/Data/MigrateCoverImages.cs @@ -29,10 +29,10 @@ namespace API.Data /// /// Run first. Will extract byte[]s from DB and write them to the cover directory. /// - public static void ExtractToImages(DbContext context) + public static void ExtractToImages(DbContext context, IDirectoryService directoryService, IImageService imageService) { Console.WriteLine("Migrating Cover Images to disk. Expect delay."); - DirectoryService.ExistOrCreate(DirectoryService.CoverImageDirectory); + 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 => @@ -45,14 +45,14 @@ namespace API.Data foreach (var series in lockedSeries) { if (series.CoverImage == null || !series.CoverImage.Any()) continue; - if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + 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))); + imageService.WriteCoverThumbnail(stream, ImageService.GetSeriesFormat(int.Parse(series.Id)), directoryService.CoverImageDirectory); } catch (Exception e) { @@ -71,14 +71,14 @@ namespace API.Data foreach (var chapter in chapters) { if (chapter.CoverImage == null || !chapter.CoverImage.Any()) continue; - if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + 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))}"); + imageService.WriteCoverThumbnail(stream, $"{ImageService.GetChapterFormat(int.Parse(chapter.Id), int.Parse(chapter.ParentId))}", directoryService.CoverImageDirectory); } catch (Exception e) { @@ -97,13 +97,13 @@ namespace API.Data foreach (var tag in tags) { if (tag.CoverImage == null || !tag.CoverImage.Any()) continue; - if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + 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))}"); + imageService.WriteCoverThumbnail(stream, $"{ImageService.GetCollectionTagFormat(int.Parse(tag.Id))}", directoryService.CoverImageDirectory); } catch (Exception e) { @@ -116,13 +116,13 @@ namespace API.Data /// Run after . Will update the DB with names of files that were extracted. /// /// - public static async Task UpdateDatabaseWithImages(DataContext context) + 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 (!File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + if (!directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, $"{ImageService.GetSeriesFormat(series.Id)}.png"))) continue; series.CoverImage = $"{ImageService.GetSeriesFormat(series.Id)}.png"; } @@ -131,9 +131,10 @@ namespace API.Data Console.WriteLine("Updating Chapter entities"); var chapters = await context.Chapter.ToListAsync(); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator foreach (var chapter in chapters) { - if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + 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"; @@ -149,7 +150,7 @@ namespace API.Data { var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting).FirstOrDefault(); if (firstChapter == null) continue; - if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + 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"; @@ -161,9 +162,10 @@ namespace API.Data Console.WriteLine("Updating Collection Tag entities"); var tags = await context.CollectionTag.ToListAsync(); + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator foreach (var tag in tags) { - if (File.Exists(Path.Join(DirectoryService.CoverImageDirectory, + if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory, $"{ImageService.GetCollectionTagFormat(tag.Id)}.png"))) { tag.CoverImage = $"{ImageService.GetCollectionTagFormat(tag.Id)}.png"; diff --git a/API/Data/Migrations/20211127200244_MetadataFoundation.Designer.cs b/API/Data/Migrations/20211127200244_MetadataFoundation.Designer.cs new file mode 100644 index 000000000..32408164b --- /dev/null +++ b/API/Data/Migrations/20211127200244_MetadataFoundation.Designer.cs @@ -0,0 +1,1215 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211127200244_MetadataFoundation")] + partial class MetadataFoundation + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId") + .IsUnique(); + + b.ToTable("ChapterMetadata"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterMetadataPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterMetadataPerson"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterMetadata", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithOne("ChapterMetadata") + .HasForeignKey("API.Entities.ChapterMetadata", "ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterMetadataPerson", b => + { + b.HasOne("API.Entities.ChapterMetadata", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ChapterMetadata"); + + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211127200244_MetadataFoundation.cs b/API/Data/Migrations/20211127200244_MetadataFoundation.cs new file mode 100644 index 000000000..f2ea2c9c1 --- /dev/null +++ b/API/Data/Migrations/20211127200244_MetadataFoundation.cs @@ -0,0 +1,203 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class MetadataFoundation : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Summary", + table: "Series"); + + migrationBuilder.AddColumn( + name: "Summary", + table: "SeriesMetadata", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterMetadata", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + Year = table.Column(type: "TEXT", nullable: true), + StoryArc = table.Column(type: "TEXT", nullable: true), + ChapterId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadata", x => x.Id); + table.ForeignKey( + name: "FK_ChapterMetadata_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Genre", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Genre", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Person", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + Role = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Person", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GenreSeriesMetadata", + columns: table => new + { + GenresId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GenreSeriesMetadata", x => new { x.GenresId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_GenreSeriesMetadata_Genre_GenresId", + column: x => x.GenresId, + principalTable: "Genre", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GenreSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChapterMetadataPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadataPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_ChapterMetadata_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "ChapterMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PersonSeriesMetadata", + columns: table => new + { + PeopleId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonSeriesMetadata", x => new { x.PeopleId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_PersonSeriesMetadata_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PersonSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadataPerson_PeopleId", + table: "ChapterMetadataPerson", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedName", + table: "Genre", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GenreSeriesMetadata_SeriesMetadatasId", + table: "GenreSeriesMetadata", + column: "SeriesMetadatasId"); + + migrationBuilder.CreateIndex( + name: "IX_PersonSeriesMetadata_SeriesMetadatasId", + table: "PersonSeriesMetadata", + column: "SeriesMetadatasId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterMetadataPerson"); + + migrationBuilder.DropTable( + name: "GenreSeriesMetadata"); + + migrationBuilder.DropTable( + name: "PersonSeriesMetadata"); + + migrationBuilder.DropTable( + name: "ChapterMetadata"); + + migrationBuilder.DropTable( + name: "Genre"); + + migrationBuilder.DropTable( + name: "Person"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "SeriesMetadata"); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Series", + type: "TEXT", + nullable: true); + } + } +} diff --git a/API/Data/Migrations/20211129231007_RemoveChapterMetadata.Designer.cs b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.Designer.cs new file mode 100644 index 000000000..27436b91f --- /dev/null +++ b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.Designer.cs @@ -0,0 +1,1232 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211129231007_RemoveChapterMetadata")] + partial class RemoveChapterMetadata + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ChapterMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ChapterMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterMetadataId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ChapterMetadata", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.HasOne("API.Entities.Metadata.ChapterMetadata", null) + .WithMany("People") + .HasForeignKey("ChapterMetadataId"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ChapterMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211129231007_RemoveChapterMetadata.cs b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.cs new file mode 100644 index 000000000..c50578ff9 --- /dev/null +++ b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class RemoveChapterMetadata : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterMetadataPerson"); + + migrationBuilder.DropIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata"); + + migrationBuilder.AddColumn( + name: "ChapterMetadataId", + table: "Person", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "TitleName", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterPerson_Chapter_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterPerson_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterPerson_PeopleId", + table: "ChapterPerson", + column: "PeopleId"); + + migrationBuilder.AddForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId", + principalTable: "ChapterMetadata", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropTable( + name: "ChapterPerson"); + + migrationBuilder.DropIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata"); + + migrationBuilder.DropColumn( + name: "ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "TitleName", + table: "Chapter"); + + migrationBuilder.CreateTable( + name: "ChapterMetadataPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadataPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_ChapterMetadata_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "ChapterMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadataPerson_PeopleId", + table: "ChapterMetadataPerson", + column: "PeopleId"); + } + } +} diff --git a/API/Data/Migrations/20211130134642_GenreProvider.Designer.cs b/API/Data/Migrations/20211130134642_GenreProvider.Designer.cs new file mode 100644 index 000000000..4b90e75ba --- /dev/null +++ b/API/Data/Migrations/20211130134642_GenreProvider.Designer.cs @@ -0,0 +1,1182 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211130134642_GenreProvider")] + partial class GenreProvider + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211130134642_GenreProvider.cs b/API/Data/Migrations/20211130134642_GenreProvider.cs new file mode 100644 index 000000000..260210d54 --- /dev/null +++ b/API/Data/Migrations/20211130134642_GenreProvider.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class GenreProvider : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropTable( + name: "ChapterMetadata"); + + migrationBuilder.DropIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "ChapterMetadataId", + table: "Person"); + + migrationBuilder.AddColumn( + name: "ExternalTag", + table: "Genre", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExternalTag", + table: "Genre"); + + migrationBuilder.AddColumn( + name: "ChapterMetadataId", + table: "Person", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterMetadata", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ChapterId = table.Column(type: "INTEGER", nullable: false), + StoryArc = table.Column(type: "TEXT", nullable: true), + Title = table.Column(type: "TEXT", nullable: true), + Year = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadata", x => x.Id); + table.ForeignKey( + name: "FK_ChapterMetadata_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId"); + + migrationBuilder.AddForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId", + principalTable: "ChapterMetadata", + principalColumn: "Id"); + } + } +} diff --git a/API/Data/Migrations/20211201230003_GenreTitle.Designer.cs b/API/Data/Migrations/20211201230003_GenreTitle.Designer.cs new file mode 100644 index 000000000..81f69b5a0 --- /dev/null +++ b/API/Data/Migrations/20211201230003_GenreTitle.Designer.cs @@ -0,0 +1,1196 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211201230003_GenreTitle")] + partial class GenreTitle + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211201230003_GenreTitle.cs b/API/Data/Migrations/20211201230003_GenreTitle.cs new file mode 100644 index 000000000..ab3e65daf --- /dev/null +++ b/API/Data/Migrations/20211201230003_GenreTitle.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class GenreTitle : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Genre_NormalizedName", + table: "Genre"); + + migrationBuilder.RenameColumn( + name: "NormalizedName", + table: "Genre", + newName: "Title"); + + migrationBuilder.RenameColumn( + name: "Name", + table: "Genre", + newName: "NormalizedTitle"); + + migrationBuilder.AddColumn( + name: "GenreId", + table: "Chapter", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedTitle_ExternalTag", + table: "Genre", + columns: new[] { "NormalizedTitle", "ExternalTag" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_GenreId", + table: "Chapter", + column: "GenreId"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter", + column: "GenreId", + principalTable: "Genre", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Genre_NormalizedTitle_ExternalTag", + table: "Genre"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_GenreId", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "GenreId", + table: "Chapter"); + + migrationBuilder.RenameColumn( + name: "Title", + table: "Genre", + newName: "NormalizedName"); + + migrationBuilder.RenameColumn( + name: "NormalizedTitle", + table: "Genre", + newName: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedName", + table: "Genre", + column: "NormalizedName", + unique: true); + } + } +} diff --git a/API/Data/Migrations/20211205185207_MetadataAgeRating.Designer.cs b/API/Data/Migrations/20211205185207_MetadataAgeRating.Designer.cs new file mode 100644 index 000000000..58704e29d --- /dev/null +++ b/API/Data/Migrations/20211205185207_MetadataAgeRating.Designer.cs @@ -0,0 +1,1199 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211205185207_MetadataAgeRating")] + partial class MetadataAgeRating + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211205185207_MetadataAgeRating.cs b/API/Data/Migrations/20211205185207_MetadataAgeRating.cs new file mode 100644 index 000000000..8f03753f6 --- /dev/null +++ b/API/Data/Migrations/20211205185207_MetadataAgeRating.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class MetadataAgeRating : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRating", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRating", + table: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/20211206193225_AgeRatingAndReleaseDate.Designer.cs b/API/Data/Migrations/20211206193225_AgeRatingAndReleaseDate.Designer.cs new file mode 100644 index 000000000..eade9e871 --- /dev/null +++ b/API/Data/Migrations/20211206193225_AgeRatingAndReleaseDate.Designer.cs @@ -0,0 +1,1208 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211206193225_AgeRatingAndReleaseDate")] + partial class AgeRatingAndReleaseDate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211206193225_AgeRatingAndReleaseDate.cs b/API/Data/Migrations/20211206193225_AgeRatingAndReleaseDate.cs new file mode 100644 index 000000000..76a7f05c6 --- /dev/null +++ b/API/Data/Migrations/20211206193225_AgeRatingAndReleaseDate.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class AgeRatingAndReleaseDate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReleaseYear", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "AgeRating", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ReleaseDate", + table: "Chapter", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ReleaseYear", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "AgeRating", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ReleaseDate", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs b/API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs new file mode 100644 index 000000000..5db4111f6 --- /dev/null +++ b/API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs @@ -0,0 +1,1317 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211217013734_BookmarkRefactor")] + partial class BookmarkRefactor + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211217013734_BookmarkRefactor.cs b/API/Data/Migrations/20211217013734_BookmarkRefactor.cs new file mode 100644 index 000000000..7ac831e07 --- /dev/null +++ b/API/Data/Migrations/20211217013734_BookmarkRefactor.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class BookmarkRefactor : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FileName", + table: "AppUserBookmark", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FileName", + table: "AppUserBookmark"); + } + } +} diff --git a/API/Data/Migrations/20211217180457_filteringChanges.Designer.cs b/API/Data/Migrations/20211217180457_filteringChanges.Designer.cs new file mode 100644 index 000000000..39377a6c3 --- /dev/null +++ b/API/Data/Migrations/20211217180457_filteringChanges.Designer.cs @@ -0,0 +1,1314 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211217180457_filteringChanges")] + partial class filteringChanges + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211217180457_filteringChanges.cs b/API/Data/Migrations/20211217180457_filteringChanges.cs new file mode 100644 index 000000000..28c4d00b3 --- /dev/null +++ b/API/Data/Migrations/20211217180457_filteringChanges.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class filteringChanges : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + table: "SeriesMetadata", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "Tag", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + NormalizedTitle = table.Column(type: "TEXT", nullable: true), + ExternalTag = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tag", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ChapterTag", + columns: table => new + { + ChaptersId = table.Column(type: "INTEGER", nullable: false), + TagsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterTag", x => new { x.ChaptersId, x.TagsId }); + table.ForeignKey( + name: "FK_ChapterTag_Chapter_ChaptersId", + column: x => x.ChaptersId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterTag_Tag_TagsId", + column: x => x.TagsId, + principalTable: "Tag", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SeriesMetadataTag", + columns: table => new + { + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false), + TagsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesMetadataTag", x => new { x.SeriesMetadatasId, x.TagsId }); + table.ForeignKey( + name: "FK_SeriesMetadataTag_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SeriesMetadataTag_Tag_TagsId", + column: x => x.TagsId, + principalTable: "Tag", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserRating_SeriesId", + table: "AppUserRating", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserProgresses_SeriesId", + table: "AppUserProgresses", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterTag_TagsId", + table: "ChapterTag", + column: "TagsId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadataTag_TagsId", + table: "SeriesMetadataTag", + column: "TagsId"); + + migrationBuilder.CreateIndex( + name: "IX_Tag_NormalizedTitle_ExternalTag", + table: "Tag", + columns: new[] { "NormalizedTitle", "ExternalTag" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserProgresses_Series_SeriesId", + table: "AppUserProgresses", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserRating_Series_SeriesId", + table: "AppUserRating", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserProgresses_Series_SeriesId", + table: "AppUserProgresses"); + + migrationBuilder.DropForeignKey( + name: "FK_AppUserRating_Series_SeriesId", + table: "AppUserRating"); + + migrationBuilder.DropTable( + name: "ChapterTag"); + + migrationBuilder.DropTable( + name: "SeriesMetadataTag"); + + migrationBuilder.DropTable( + name: "Tag"); + + migrationBuilder.DropIndex( + name: "IX_AppUserRating_SeriesId", + table: "AppUserRating"); + + migrationBuilder.DropIndex( + name: "IX_AppUserProgresses_SeriesId", + table: "AppUserProgresses"); + + migrationBuilder.DropColumn( + name: "Language", + table: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/20211227180752_FullscreenPref.Designer.cs b/API/Data/Migrations/20211227180752_FullscreenPref.Designer.cs new file mode 100644 index 000000000..b649b12b6 --- /dev/null +++ b/API/Data/Migrations/20211227180752_FullscreenPref.Designer.cs @@ -0,0 +1,1317 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211227180752_FullscreenPref")] + partial class FullscreenPref + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("FullscreenMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211227180752_FullscreenPref.cs b/API/Data/Migrations/20211227180752_FullscreenPref.cs new file mode 100644 index 000000000..ab6cbc8a8 --- /dev/null +++ b/API/Data/Migrations/20211227180752_FullscreenPref.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class FullscreenPref : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FullscreenMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FullscreenMode", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs new file mode 100644 index 000000000..9df425dbd --- /dev/null +++ b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.Designer.cs @@ -0,0 +1,1339 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20220107232822_ChapterMetadataOptimization")] + partial class ChapterMetadataOptimization + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs new file mode 100644 index 000000000..28e874f03 --- /dev/null +++ b/API/Data/Migrations/20220107232822_ChapterMetadataOptimization.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class ChapterMetadataOptimization : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_GenreId", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "GenreId", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "FullscreenMode", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "Language", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterGenre", + columns: table => new + { + ChaptersId = table.Column(type: "INTEGER", nullable: false), + GenresId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterGenre", x => new { x.ChaptersId, x.GenresId }); + table.ForeignKey( + name: "FK_ChapterGenre_Chapter_ChaptersId", + column: x => x.ChaptersId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterGenre_Genre_GenresId", + column: x => x.GenresId, + principalTable: "Genre", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterGenre_GenresId", + table: "ChapterGenre", + column: "GenresId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterGenre"); + + migrationBuilder.DropColumn( + name: "Language", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "Chapter"); + + migrationBuilder.AddColumn( + name: "GenreId", + table: "Chapter", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "FullscreenMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_GenreId", + table: "Chapter", + column: "GenreId"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter", + column: "GenreId", + principalTable: "Genre", + principalColumn: "Id"); + } + } +} diff --git a/API/Data/Migrations/20220108200822_CountMetadata.Designer.cs b/API/Data/Migrations/20220108200822_CountMetadata.Designer.cs new file mode 100644 index 000000000..1866b6e58 --- /dev/null +++ b/API/Data/Migrations/20220108200822_CountMetadata.Designer.cs @@ -0,0 +1,1345 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20220108200822_CountMetadata")] + partial class CountMetadata + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220108200822_CountMetadata.cs b/API/Data/Migrations/20220108200822_CountMetadata.cs new file mode 100644 index 000000000..98a7f7e11 --- /dev/null +++ b/API/Data/Migrations/20220108200822_CountMetadata.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class CountMetadata : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Count", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Count", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Count", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "Count", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20220108202027_PublicationStatus.Designer.cs b/API/Data/Migrations/20220108202027_PublicationStatus.Designer.cs new file mode 100644 index 000000000..8479775bf --- /dev/null +++ b/API/Data/Migrations/20220108202027_PublicationStatus.Designer.cs @@ -0,0 +1,1351 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20220108202027_PublicationStatus")] + partial class PublicationStatus + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220108202027_PublicationStatus.cs b/API/Data/Migrations/20220108202027_PublicationStatus.cs new file mode 100644 index 000000000..a8d676ed0 --- /dev/null +++ b/API/Data/Migrations/20220108202027_PublicationStatus.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class PublicationStatus : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PublicationStatus", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "TotalCount", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PublicationStatus", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "TotalCount", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 21a9d930a..c0aefbcd2 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +#nullable disable + namespace API.Data.Migrations { [DbContext(typeof(DataContext))] @@ -13,8 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "5.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -40,7 +41,7 @@ namespace API.Data.Migrations .IsUnique() .HasDatabaseName("RoleNameIndex"); - b.ToTable("AspNetRoles"); + b.ToTable("AspNetRoles", (string)null); }); modelBuilder.Entity("API.Entities.AppUser", b => @@ -118,7 +119,7 @@ namespace API.Data.Migrations .IsUnique() .HasDatabaseName("UserNameIndex"); - b.ToTable("AspNetUsers"); + b.ToTable("AspNetUsers", (string)null); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => @@ -133,6 +134,9 @@ namespace API.Data.Migrations b.Property("ChapterId") .HasColumnType("INTEGER"); + b.Property("FileName") + .HasColumnType("TEXT"); + b.Property("Page") .HasColumnType("INTEGER"); @@ -239,6 +243,8 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); + b.HasIndex("SeriesId"); + b.ToTable("AppUserProgresses"); }); @@ -264,6 +270,8 @@ namespace API.Data.Migrations b.HasIndex("AppUserId"); + b.HasIndex("SeriesId"); + b.ToTable("AppUserRating"); }); @@ -279,7 +287,7 @@ namespace API.Data.Migrations b.HasIndex("RoleId"); - b.ToTable("AspNetUserRoles"); + b.ToTable("AspNetUserRoles", (string)null); }); modelBuilder.Entity("API.Entities.Chapter", b => @@ -288,6 +296,12 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + b.Property("CoverImage") .HasColumnType("TEXT"); @@ -300,6 +314,9 @@ namespace API.Data.Migrations b.Property("IsSpecial") .HasColumnType("INTEGER"); + b.Property("Language") + .HasColumnType("TEXT"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -312,9 +329,21 @@ namespace API.Data.Migrations b.Property("Range") .HasColumnType("TEXT"); + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + b.Property("Title") .HasColumnType("TEXT"); + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + b.Property("VolumeId") .HasColumnType("INTEGER"); @@ -382,6 +411,29 @@ namespace API.Data.Migrations b.ToTable("FolderPath"); }); + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Property("Id") @@ -439,6 +491,68 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.Property("Id") @@ -546,9 +660,6 @@ namespace API.Data.Migrations b.Property("SortName") .HasColumnType("TEXT"); - b.Property("Summary") - .HasColumnType("TEXT"); - b.HasKey("Id"); b.HasIndex("LibraryId"); @@ -559,30 +670,6 @@ namespace API.Data.Migrations b.ToTable("Series"); }); - modelBuilder.Entity("API.Entities.SeriesMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("SeriesId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("SeriesId") - .IsUnique(); - - b.HasIndex("Id", "SeriesId") - .IsUnique(); - - b.ToTable("SeriesMetadata"); - }); - modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -600,6 +687,29 @@ namespace API.Data.Migrations b.ToTable("ServerSetting"); }); + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + modelBuilder.Entity("API.Entities.Volume", b => { b.Property("Id") @@ -649,6 +759,51 @@ namespace API.Data.Migrations b.ToTable("AppUserLibrary"); }); + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + modelBuilder.Entity("CollectionTagSeriesMetadata", b => { b.Property("CollectionTagsId") @@ -664,6 +819,21 @@ namespace API.Data.Migrations b.ToTable("CollectionTagSeriesMetadata"); }); + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") @@ -683,7 +853,7 @@ namespace API.Data.Migrations b.HasIndex("RoleId"); - b.ToTable("AspNetRoleClaims"); + b.ToTable("AspNetRoleClaims", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => @@ -705,7 +875,7 @@ namespace API.Data.Migrations b.HasIndex("UserId"); - b.ToTable("AspNetUserClaims"); + b.ToTable("AspNetUserClaims", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => @@ -726,7 +896,7 @@ namespace API.Data.Migrations b.HasIndex("UserId"); - b.ToTable("AspNetUserLogins"); + b.ToTable("AspNetUserLogins", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => @@ -745,7 +915,37 @@ namespace API.Data.Migrations b.HasKey("UserId", "LoginProvider", "Name"); - b.ToTable("AspNetUserTokens"); + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => @@ -778,6 +978,12 @@ namespace API.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("AppUser"); }); @@ -789,6 +995,12 @@ namespace API.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("AppUser"); }); @@ -844,6 +1056,17 @@ namespace API.Data.Migrations b.Navigation("Chapter"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -901,17 +1124,6 @@ namespace API.Data.Migrations b.Navigation("Library"); }); - modelBuilder.Entity("API.Entities.SeriesMetadata", b => - { - b.HasOne("API.Entities.Series", "Series") - .WithOne("Metadata") - .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Series"); - }); - modelBuilder.Entity("API.Entities.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -938,6 +1150,51 @@ namespace API.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("CollectionTagSeriesMetadata", b => { b.HasOne("API.Entities.CollectionTag", null) @@ -946,7 +1203,22 @@ namespace API.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("API.Entities.SeriesMetadata", null) + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) .WithMany() .HasForeignKey("SeriesMetadatasId") .OnDelete(DeleteBehavior.Cascade) @@ -989,6 +1261,36 @@ namespace API.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("API.Entities.AppRole", b => { b.Navigation("UserRoles"); @@ -1030,6 +1332,10 @@ namespace API.Data.Migrations { b.Navigation("Metadata"); + b.Navigation("Progress"); + + b.Navigation("Ratings"); + b.Navigation("Volumes"); }); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index c91e61cd0..37fc68693 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -2,78 +2,84 @@ using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; -using API.Interfaces.Repositories; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface IAppUserProgressRepository { - public class AppUserProgressRepository : IAppUserProgressRepository + void Update(AppUserProgress userProgress); + Task CleanupAbandonedChapters(); + Task UserHasProgress(LibraryType libraryType, int userId); + Task GetUserProgressAsync(int chapterId, int userId); +} + +public class AppUserProgressRepository : IAppUserProgressRepository +{ + private readonly DataContext _context; + + public AppUserProgressRepository(DataContext context) { - private readonly DataContext _context; + _context = context; + } - public AppUserProgressRepository(DataContext context) - { - _context = context; - } + public void Update(AppUserProgress userProgress) + { + _context.Entry(userProgress).State = EntityState.Modified; + } - public void Update(AppUserProgress userProgress) - { - _context.Entry(userProgress).State = EntityState.Modified; - } + /// + /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. + /// + public async Task CleanupAbandonedChapters() + { + var chapterIds = _context.Chapter.Select(c => c.Id); - /// - /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. - /// - public async Task CleanupAbandonedChapters() - { - var chapterIds = _context.Chapter.Select(c => c.Id); + var rowsToRemove = await _context.AppUserProgresses + .Where(progress => !chapterIds.Contains(progress.ChapterId)) + .ToListAsync(); - var rowsToRemove = await _context.AppUserProgresses - .Where(progress => !chapterIds.Contains(progress.ChapterId)) - .ToListAsync(); + var rowsToRemoveBookmarks = await _context.AppUserBookmark + .Where(progress => !chapterIds.Contains(progress.ChapterId)) + .ToListAsync(); - var rowsToRemoveBookmarks = await _context.AppUserBookmark - .Where(progress => !chapterIds.Contains(progress.ChapterId)) - .ToListAsync(); + var rowsToRemoveReadingLists = await _context.ReadingListItem + .Where(item => !chapterIds.Contains(item.ChapterId)) + .ToListAsync(); - var rowsToRemoveReadingLists = await _context.ReadingListItem - .Where(item => !chapterIds.Contains(item.ChapterId)) - .ToListAsync(); + _context.RemoveRange(rowsToRemove); + _context.RemoveRange(rowsToRemoveBookmarks); + _context.RemoveRange(rowsToRemoveReadingLists); + return await _context.SaveChangesAsync() > 0 ? rowsToRemove.Count : 0; + } - _context.RemoveRange(rowsToRemove); - _context.RemoveRange(rowsToRemoveBookmarks); - _context.RemoveRange(rowsToRemoveReadingLists); - return await _context.SaveChangesAsync() > 0 ? rowsToRemove.Count : 0; - } + /// + /// Checks if user has any progress against a library of passed type + /// + /// + /// + /// + public async Task UserHasProgress(LibraryType libraryType, int userId) + { + var seriesIds = await _context.AppUserProgresses + .Where(aup => aup.PagesRead > 0 && aup.AppUserId == userId) + .AsNoTracking() + .Select(aup => aup.SeriesId) + .ToListAsync(); - /// - /// Checks if user has any progress against a library of passed type - /// - /// - /// - /// - public async Task UserHasProgress(LibraryType libraryType, int userId) - { - var seriesIds = await _context.AppUserProgresses - .Where(aup => aup.PagesRead > 0 && aup.AppUserId == userId) - .AsNoTracking() - .Select(aup => aup.SeriesId) - .ToListAsync(); + if (seriesIds.Count == 0) return false; - if (seriesIds.Count == 0) return false; + return await _context.Series + .Include(s => s.Library) + .Where(s => seriesIds.Contains(s.Id) && s.Library.Type == libraryType) + .AsNoTracking() + .AnyAsync(); + } - return await _context.Series - .Include(s => s.Library) - .Where(s => seriesIds.Contains(s.Id) && s.Library.Type == libraryType) - .AsNoTracking() - .AnyAsync(); - } - - public async Task GetUserProgressAsync(int chapterId, int userId) - { - return await _context.AppUserProgresses - .Where(p => p.ChapterId == chapterId && p.AppUserId == userId) - .FirstOrDefaultAsync(); - } + public async Task GetUserProgressAsync(int chapterId, int userId) + { + return await _context.AppUserProgresses + .Where(p => p.ChapterId == chapterId && p.AppUserId == userId) + .FirstOrDefaultAsync(); } } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 54c808d9c..f89304f74 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -4,186 +4,205 @@ using System.Threading.Tasks; using API.DTOs; using API.DTOs.Reader; using API.Entities; -using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface IChapterRepository { - public class ChapterRepository : IChapterRepository + void Update(Chapter chapter); + Task> GetChaptersByIdsAsync(IList chapterIds); + Task GetChapterInfoDtoAsync(int chapterId); + Task GetChapterTotalPagesAsync(int chapterId); + Task GetChapterAsync(int chapterId); + Task GetChapterDtoAsync(int chapterId); + Task> GetFilesForChapterAsync(int chapterId); + Task> GetChaptersAsync(int volumeId); + Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); + Task GetChapterCoverImageAsync(int chapterId); + Task> GetAllCoverImagesAsync(); + Task> GetCoverImagesForLockedChaptersAsync(); +} +public class ChapterRepository : IChapterRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public ChapterRepository(DataContext context, IMapper mapper) { - private readonly DataContext _context; - private readonly IMapper _mapper; + _context = context; + _mapper = mapper; + } - public ChapterRepository(DataContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } + public void Update(Chapter chapter) + { + _context.Entry(chapter).State = EntityState.Modified; + } - public void Update(Chapter chapter) - { - _context.Entry(chapter).State = EntityState.Modified; - } + public async Task> GetChaptersByIdsAsync(IList chapterIds) + { + return await _context.Chapter + .Where(c => chapterIds.Contains(c.Id)) + .Include(c => c.Volume) + .ToListAsync(); + } - public async Task> GetChaptersByIdsAsync(IList chapterIds) - { - return await _context.Chapter - .Where(c => chapterIds.Contains(c.Id)) - .Include(c => c.Volume) - .ToListAsync(); - } + /// + /// Populates a partial IChapterInfoDto + /// + /// + public async Task GetChapterInfoDtoAsync(int chapterId) + { + var chapterInfo = await _context.Chapter + .Where(c => c.Id == chapterId) + .Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new + { + ChapterNumber = chapter.Range, + VolumeNumber = volume.Number, + VolumeId = volume.Id, + chapter.IsSpecial, + chapter.TitleName, + volume.SeriesId, + chapter.Pages, + }) + .Join(_context.Series, data => data.SeriesId, series => series.Id, (data, series) => new + { + data.ChapterNumber, + data.VolumeNumber, + data.VolumeId, + data.IsSpecial, + data.SeriesId, + data.Pages, + data.TitleName, + SeriesFormat = series.Format, + SeriesName = series.Name, + series.LibraryId + }) + .Select(data => new ChapterInfoDto() + { + ChapterNumber = data.ChapterNumber, + VolumeNumber = data.VolumeNumber + string.Empty, + VolumeId = data.VolumeId, + IsSpecial = data.IsSpecial, + SeriesId =data.SeriesId, + SeriesFormat = data.SeriesFormat, + SeriesName = data.SeriesName, + LibraryId = data.LibraryId, + Pages = data.Pages, + ChapterTitle = data.TitleName + }) + .AsNoTracking() + .AsSplitQuery() + .SingleOrDefaultAsync(); - /// - /// Populates a partial IChapterInfoDto - /// - /// - public async Task GetChapterInfoDtoAsync(int chapterId) - { - return await _context.Chapter - .Where(c => c.Id == chapterId) - .Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new - { - ChapterNumber = chapter.Range, - VolumeNumber = volume.Number, - VolumeId = volume.Id, - chapter.IsSpecial, - volume.SeriesId, - chapter.Pages - }) - .Join(_context.Series, data => data.SeriesId, series => series.Id, (data, series) => new - { - data.ChapterNumber, - data.VolumeNumber, - data.VolumeId, - data.IsSpecial, - data.SeriesId, - data.Pages, - SeriesFormat = series.Format, - SeriesName = series.Name, - series.LibraryId - }) - .Select(data => new BookInfoDto() - { - ChapterNumber = data.ChapterNumber, - VolumeNumber = data.VolumeNumber + string.Empty, - VolumeId = data.VolumeId, - IsSpecial = data.IsSpecial, - SeriesId =data.SeriesId, - SeriesFormat = data.SeriesFormat, - SeriesName = data.SeriesName, - LibraryId = data.LibraryId, - Pages = data.Pages - }) - .AsNoTracking() - .SingleAsync(); - } + return chapterInfo; + } - public Task GetChapterTotalPagesAsync(int chapterId) - { - return _context.Chapter - .Where(c => c.Id == chapterId) - .Select(c => c.Pages) - .SingleOrDefaultAsync(); - } - public async Task GetChapterDtoAsync(int chapterId) - { - var chapter = await _context.Chapter - .Include(c => c.Files) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .SingleOrDefaultAsync(c => c.Id == chapterId); + public Task GetChapterTotalPagesAsync(int chapterId) + { + return _context.Chapter + .Where(c => c.Id == chapterId) + .Select(c => c.Pages) + .SingleOrDefaultAsync(); + } + public async Task GetChapterDtoAsync(int chapterId) + { + var chapter = await _context.Chapter + .Include(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .SingleOrDefaultAsync(c => c.Id == chapterId); - return chapter; - } + return chapter; + } - /// - /// Returns non-tracked files for a given chapterId - /// - /// - /// - public async Task> GetFilesForChapterAsync(int chapterId) - { - return await _context.MangaFile - .Where(c => chapterId == c.ChapterId) - .AsNoTracking() - .ToListAsync(); - } + /// + /// Returns non-tracked files for a given chapterId + /// + /// + /// + public async Task> GetFilesForChapterAsync(int chapterId) + { + return await _context.MangaFile + .Where(c => chapterId == c.ChapterId) + .AsNoTracking() + .ToListAsync(); + } - /// - /// Returns a Chapter for an Id. Includes linked s. - /// - /// - /// - public async Task GetChapterAsync(int chapterId) - { - return await _context.Chapter - .Include(c => c.Files) - .SingleOrDefaultAsync(c => c.Id == chapterId); - } + /// + /// Returns a Chapter for an Id. Includes linked s. + /// + /// + /// + public async Task GetChapterAsync(int chapterId) + { + return await _context.Chapter + .Include(c => c.Files) + .SingleOrDefaultAsync(c => c.Id == chapterId); + } - /// - /// Returns Chapters for a volume id. - /// - /// - /// - public async Task> GetChaptersAsync(int volumeId) - { - return await _context.Chapter - .Where(c => c.VolumeId == volumeId) - .ToListAsync(); - } + /// + /// Returns Chapters for a volume id. + /// + /// + /// + public async Task> GetChaptersAsync(int volumeId) + { + return await _context.Chapter + .Where(c => c.VolumeId == volumeId) + .ToListAsync(); + } - /// - /// Returns the cover image for a chapter id. - /// - /// - /// - public async Task GetChapterCoverImageAsync(int chapterId) - { + /// + /// Returns the cover image for a chapter id. + /// + /// + /// + public async Task GetChapterCoverImageAsync(int chapterId) + { - return await _context.Chapter - .Where(c => c.Id == chapterId) - .Select(c => c.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } + return await _context.Chapter + .Where(c => c.Id == chapterId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } - public async Task> GetAllCoverImagesAsync() - { - return await _context.Chapter - .Select(c => c.CoverImage) - .Where(t => !string.IsNullOrEmpty(t)) - .AsNoTracking() - .ToListAsync(); - } + public async Task> GetAllCoverImagesAsync() + { + return await _context.Chapter + .Select(c => c.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .AsNoTracking() + .ToListAsync(); + } - /// - /// Returns cover images for locked chapters - /// - /// - public async Task> GetCoverImagesForLockedChaptersAsync() - { - return await _context.Chapter - .Where(c => c.CoverImageLocked) - .Select(c => c.CoverImage) - .Where(t => !string.IsNullOrEmpty(t)) - .AsNoTracking() - .ToListAsync(); - } + /// + /// Returns cover images for locked chapters + /// + /// + public async Task> GetCoverImagesForLockedChaptersAsync() + { + return await _context.Chapter + .Where(c => c.CoverImageLocked) + .Select(c => c.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .AsNoTracking() + .ToListAsync(); + } - /// - /// Returns non-tracked files for a set of - /// - /// List of chapter Ids - /// - public async Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds) - { - return await _context.MangaFile - .Where(c => chapterIds.Contains(c.ChapterId)) - .AsNoTracking() - .ToListAsync(); - } + /// + /// Returns non-tracked files for a set of + /// + /// List of chapter Ids + /// + public async Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds) + { + return await _context.MangaFile + .Where(c => chapterIds.Contains(c.ChapterId)) + .AsNoTracking() + .ToListAsync(); } } diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index f47ce721f..a519c774f 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -2,126 +2,138 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -using API.DTOs; using API.DTOs.CollectionTags; using API.Entities; -using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface ICollectionTagRepository { - public class CollectionTagRepository : ICollectionTagRepository + void Add(CollectionTag tag); + void Remove(CollectionTag tag); + Task> GetAllTagDtosAsync(); + Task> SearchTagDtosAsync(string searchQuery); + Task GetCoverImageAsync(int collectionTagId); + Task> GetAllPromotedTagDtosAsync(); + Task GetTagAsync(int tagId); + Task GetFullTagAsync(int tagId); + void Update(CollectionTag tag); + Task RemoveTagsWithoutSeries(); + Task> GetAllTagsAsync(); + Task> GetAllCoverImagesAsync(); +} +public class CollectionTagRepository : ICollectionTagRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public CollectionTagRepository(DataContext context, IMapper mapper) { - private readonly DataContext _context; - private readonly IMapper _mapper; + _context = context; + _mapper = mapper; + } - public CollectionTagRepository(DataContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } + public void Add(CollectionTag tag) + { + _context.CollectionTag.Add(tag); + } - public void Add(CollectionTag tag) - { - _context.CollectionTag.Add(tag); - } + public void Remove(CollectionTag tag) + { + _context.CollectionTag.Remove(tag); + } - public void Remove(CollectionTag tag) - { - _context.CollectionTag.Remove(tag); - } + public void Update(CollectionTag tag) + { + _context.Entry(tag).State = EntityState.Modified; + } - public void Update(CollectionTag tag) - { - _context.Entry(tag).State = EntityState.Modified; - } + /// + /// Removes any collection tags without any series + /// + public async Task RemoveTagsWithoutSeries() + { + var tagsToDelete = await _context.CollectionTag + .Include(c => c.SeriesMetadatas) + .Where(c => c.SeriesMetadatas.Count == 0) + .ToListAsync(); + _context.RemoveRange(tagsToDelete); - /// - /// Removes any collection tags without any series - /// - public async Task RemoveTagsWithoutSeries() - { - var tagsToDelete = await _context.CollectionTag - .Include(c => c.SeriesMetadatas) - .Where(c => c.SeriesMetadatas.Count == 0) - .ToListAsync(); - _context.RemoveRange(tagsToDelete); + return await _context.SaveChangesAsync(); + } - return await _context.SaveChangesAsync(); - } + public async Task> GetAllTagsAsync() + { + return await _context.CollectionTag + .OrderBy(c => c.NormalizedTitle) + .ToListAsync(); + } - public async Task> GetAllTagsAsync() - { - return await _context.CollectionTag - .OrderBy(c => c.NormalizedTitle) - .ToListAsync(); - } + public async Task> GetAllCoverImagesAsync() + { + return await _context.CollectionTag + .Select(t => t.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .AsNoTracking() + .ToListAsync(); + } - public async Task> GetAllCoverImagesAsync() - { - return await _context.CollectionTag - .Select(t => t.CoverImage) - .Where(t => !string.IsNullOrEmpty(t)) - .AsNoTracking() - .ToListAsync(); - } + public async Task> GetAllTagDtosAsync() + { + return await _context.CollectionTag + .Select(c => c) + .OrderBy(c => c.NormalizedTitle) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } - public async Task> GetAllTagDtosAsync() - { - return await _context.CollectionTag - .Select(c => c) - .OrderBy(c => c.NormalizedTitle) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } + public async Task> GetAllPromotedTagDtosAsync() + { + return await _context.CollectionTag + .Where(c => c.Promoted) + .OrderBy(c => c.NormalizedTitle) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } - public async Task> GetAllPromotedTagDtosAsync() - { - return await _context.CollectionTag - .Where(c => c.Promoted) - .OrderBy(c => c.NormalizedTitle) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } + public async Task GetTagAsync(int tagId) + { + return await _context.CollectionTag + .Where(c => c.Id == tagId) + .SingleOrDefaultAsync(); + } - public async Task GetTagAsync(int tagId) - { - return await _context.CollectionTag - .Where(c => c.Id == tagId) - .SingleOrDefaultAsync(); - } + public async Task GetFullTagAsync(int tagId) + { + return await _context.CollectionTag + .Where(c => c.Id == tagId) + .Include(c => c.SeriesMetadatas) + .SingleOrDefaultAsync(); + } - public async Task GetFullTagAsync(int tagId) - { - return await _context.CollectionTag - .Where(c => c.Id == tagId) - .Include(c => c.SeriesMetadatas) - .SingleOrDefaultAsync(); - } + public async Task> SearchTagDtosAsync(string searchQuery) + { + return await _context.CollectionTag + .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") + || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) + .OrderBy(s => s.Title) + .AsNoTracking() + .OrderBy(c => c.NormalizedTitle) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } - public async Task> SearchTagDtosAsync(string searchQuery) - { - return await _context.CollectionTag - .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") - || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) - .OrderBy(s => s.Title) - .AsNoTracking() - .OrderBy(c => c.NormalizedTitle) - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - public async Task GetCoverImageAsync(int collectionTagId) - { - return await _context.CollectionTag - .Where(c => c.Id == collectionTagId) - .Select(c => c.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } + public async Task GetCoverImageAsync(int collectionTagId) + { + return await _context.CollectionTag + .Where(c => c.Id == collectionTagId) + .Select(c => c.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); } } diff --git a/API/Data/Repositories/FileRepository.cs b/API/Data/Repositories/FileRepository.cs deleted file mode 100644 index 4665dac7e..000000000 --- a/API/Data/Repositories/FileRepository.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using API.Interfaces.Repositories; -using Microsoft.EntityFrameworkCore; - -namespace API.Data.Repositories -{ - public class FileRepository : IFileRepository - { - private readonly DataContext _dbContext; - - public FileRepository(DataContext context) - { - _dbContext = context; - } - - public async Task> GetFileExtensions() - { - var fileExtensions = await _dbContext.MangaFile - .AsNoTracking() - .Select(x => x.FilePath.ToLower()) - .Distinct() - .ToArrayAsync(); - - var uniqueFileTypes = fileExtensions - .Select(Path.GetExtension) - .Where(x => x is not null) - .Distinct(); - - return uniqueFileTypes; - } - } -} diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs new file mode 100644 index 000000000..05f2052f4 --- /dev/null +++ b/API/Data/Repositories/GenreRepository.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IGenreRepository +{ + void Attach(Genre genre); + void Remove(Genre genre); + Task FindByNameAsync(string genreName); + Task> GetAllGenresAsync(); + Task> GetAllGenreDtosAsync(); + Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); + Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds); +} + +public class GenreRepository : IGenreRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public GenreRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(Genre genre) + { + _context.Genre.Attach(genre); + } + + public void Remove(Genre genre) + { + _context.Genre.Remove(genre); + } + + public async Task FindByNameAsync(string genreName) + { + var normalizedName = Parser.Parser.Normalize(genreName); + return await _context.Genre + .FirstOrDefaultAsync(g => g.NormalizedTitle.Equals(normalizedName)); + } + + public async Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false) + { + var genresWithNoConnections = await _context.Genre + .Include(p => p.SeriesMetadatas) + .Include(p => p.Chapters) + .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .ToListAsync(); + + _context.Genre.RemoveRange(genresWithNoConnections); + + await _context.SaveChangesAsync(); + } + + public async Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .SelectMany(s => s.Metadata.Genres) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllGenresAsync() + { + return await _context.Genre.ToListAsync(); + } + + public async Task> GetAllGenreDtosAsync() + { + return await _context.Genre + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index caae93dd6..26fc517a2 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -5,194 +5,208 @@ using System.Threading.Tasks; using API.DTOs; using API.Entities; using API.Entities.Enums; -using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +[Flags] +public enum LibraryIncludes { - - [Flags] - public enum LibraryIncludes - { - None = 1, - Series = 2, - AppUser = 4, - Folders = 8, - // Ratings = 16 - } - - public class LibraryRepository : ILibraryRepository - { - private readonly DataContext _context; - private readonly IMapper _mapper; - - public LibraryRepository(DataContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } - - public void Add(Library library) - { - _context.Library.Add(library); - } - - public void Update(Library library) - { - _context.Entry(library).State = EntityState.Modified; - } - - public void Delete(Library library) - { - _context.Library.Remove(library); - } - - public async Task> GetLibraryDtosForUsernameAsync(string userName) - { - return await _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(x => x.UserName == userName)) - .OrderBy(l => l.Name) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .AsSingleQuery() - .ToListAsync(); - } - - public async Task> GetLibrariesAsync() - { - return await _context.Library - .Include(l => l.AppUsers) - .ToListAsync(); - } - - public async Task DeleteLibrary(int libraryId) - { - var library = await GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.Series); - _context.Library.Remove(library); - return await _context.SaveChangesAsync() > 0; - } - - public async Task> GetLibrariesForUserIdAsync(int userId) - { - return await _context.Library - .Include(l => l.AppUsers) - .Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId)) - .AsNoTracking() - .ToListAsync(); - } - - public async Task GetLibraryTypeAsync(int libraryId) - { - return await _context.Library - .Where(l => l.Id == libraryId) - .AsNoTracking() - .Select(l => l.Type) - .SingleAsync(); - } - - public async Task> GetLibraryDtosAsync() - { - return await _context.Library - .Include(f => f.Folders) - .OrderBy(l => l.Name) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .ToListAsync(); - } - - public async Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes) - { - - var query = _context.Library - .Where(x => x.Id == libraryId); - - query = AddIncludesToQuery(query, includes); - return await query.SingleAsync(); - } - - private static IQueryable AddIncludesToQuery(IQueryable query, LibraryIncludes includeFlags) - { - if (includeFlags.HasFlag(LibraryIncludes.Folders)) - { - query = query.Include(l => l.Folders); - } - - if (includeFlags.HasFlag(LibraryIncludes.Series)) - { - query = query.Include(l => l.Series); - } - - if (includeFlags.HasFlag(LibraryIncludes.AppUser)) - { - query = query.Include(l => l.AppUsers); - } - - return query; - } - - - /// - /// This returns a Library with all it's Series -> Volumes -> Chapters. This is expensive. Should only be called when needed. - /// - /// - /// - public async Task GetFullLibraryForIdAsync(int libraryId) - { - return await _context.Library - .Where(x => x.Id == libraryId) - .Include(f => f.Folders) - .Include(l => l.Series) - .ThenInclude(s => s.Metadata) - .Include(l => l.Series) - .ThenInclude(s => s.Volumes) - .ThenInclude(v => v.Chapters) - .ThenInclude(c => c.Files) - .AsSplitQuery() - .SingleAsync(); - } - - /// - /// This is a heavy call, pulls all entities for a Library, except this version only grabs for one series id - /// - /// - /// - /// - public async Task GetFullLibraryForIdAsync(int libraryId, int seriesId) - { - - return await _context.Library - .Where(x => x.Id == libraryId) - .Include(f => f.Folders) - .Include(l => l.Series.Where(s => s.Id == seriesId)) - .ThenInclude(s => s.Metadata) - .Include(l => l.Series.Where(s => s.Id == seriesId)) - .ThenInclude(s => s.Volumes) - .ThenInclude(v => v.Chapters) - .ThenInclude(c => c.Files) - .AsSplitQuery() - .SingleAsync(); - } - - public async Task LibraryExists(string libraryName) - { - return await _context.Library - .AsNoTracking() - .AnyAsync(x => x.Name == libraryName); - } - - public async Task> GetLibrariesForUserAsync(AppUser user) - { - return await _context.Library - .Where(library => library.AppUsers.Contains(user)) - .Include(l => l.Folders) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - - } + None = 1, + Series = 2, + AppUser = 4, + Folders = 8, + // Ratings = 16 +} + +public interface ILibraryRepository +{ + void Add(Library library); + void Update(Library library); + void Delete(Library library); + Task> GetLibraryDtosAsync(); + Task LibraryExists(string libraryName); + Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes); + Task GetFullLibraryForIdAsync(int libraryId); + Task GetFullLibraryForIdAsync(int libraryId, int seriesId); + Task> GetLibraryDtosForUsernameAsync(string userName); + Task> GetLibrariesAsync(); + Task DeleteLibrary(int libraryId); + Task> GetLibrariesForUserIdAsync(int userId); + Task GetLibraryTypeAsync(int libraryId); +} + +public class LibraryRepository : ILibraryRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public LibraryRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Add(Library library) + { + _context.Library.Add(library); + } + + public void Update(Library library) + { + _context.Entry(library).State = EntityState.Modified; + } + + public void Delete(Library library) + { + _context.Library.Remove(library); + } + + public async Task> GetLibraryDtosForUsernameAsync(string userName) + { + return await _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(x => x.UserName == userName)) + .OrderBy(l => l.Name) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .AsSingleQuery() + .ToListAsync(); + } + + public async Task> GetLibrariesAsync() + { + return await _context.Library + .Include(l => l.AppUsers) + .ToListAsync(); + } + + public async Task DeleteLibrary(int libraryId) + { + var library = await GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.Series); + _context.Library.Remove(library); + return await _context.SaveChangesAsync() > 0; + } + + public async Task> GetLibrariesForUserIdAsync(int userId) + { + return await _context.Library + .Include(l => l.AppUsers) + .Where(l => l.AppUsers.Select(ap => ap.Id).Contains(userId)) + .AsNoTracking() + .ToListAsync(); + } + + public async Task GetLibraryTypeAsync(int libraryId) + { + return await _context.Library + .Where(l => l.Id == libraryId) + .AsNoTracking() + .Select(l => l.Type) + .SingleAsync(); + } + + public async Task> GetLibraryDtosAsync() + { + return await _context.Library + .Include(f => f.Folders) + .OrderBy(l => l.Name) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .ToListAsync(); + } + + public async Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes) + { + + var query = _context.Library + .Where(x => x.Id == libraryId); + + query = AddIncludesToQuery(query, includes); + return await query.SingleAsync(); + } + + private static IQueryable AddIncludesToQuery(IQueryable query, LibraryIncludes includeFlags) + { + if (includeFlags.HasFlag(LibraryIncludes.Folders)) + { + query = query.Include(l => l.Folders); + } + + if (includeFlags.HasFlag(LibraryIncludes.Series)) + { + query = query.Include(l => l.Series); + } + + if (includeFlags.HasFlag(LibraryIncludes.AppUser)) + { + query = query.Include(l => l.AppUsers); + } + + return query; + } + + + /// + /// This returns a Library with all it's Series -> Volumes -> Chapters. This is expensive. Should only be called when needed. + /// + /// + /// + public async Task GetFullLibraryForIdAsync(int libraryId) + { + return await _context.Library + .Where(x => x.Id == libraryId) + .Include(f => f.Folders) + .Include(l => l.Series) + .ThenInclude(s => s.Metadata) + .Include(l => l.Series) + .ThenInclude(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .AsSplitQuery() + .SingleAsync(); + } + + /// + /// This is a heavy call, pulls all entities for a Library, except this version only grabs for one series id + /// + /// + /// + /// + public async Task GetFullLibraryForIdAsync(int libraryId, int seriesId) + { + + return await _context.Library + .Where(x => x.Id == libraryId) + .Include(f => f.Folders) + .Include(l => l.Series.Where(s => s.Id == seriesId)) + .ThenInclude(s => s.Metadata) + .Include(l => l.Series.Where(s => s.Id == seriesId)) + .ThenInclude(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .AsSplitQuery() + .SingleAsync(); + } + + public async Task LibraryExists(string libraryName) + { + return await _context.Library + .AsNoTracking() + .AnyAsync(x => x.Name == libraryName); + } + + public async Task> GetLibrariesForUserAsync(AppUser user) + { + return await _context.Library + .Where(library => library.AppUsers.Contains(user)) + .Include(l => l.Folders) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs new file mode 100644 index 000000000..71ec69639 --- /dev/null +++ b/API/Data/Repositories/PersonRepository.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IPersonRepository +{ + void Attach(Person person); + void Remove(Person person); + Task> GetAllPeople(); + Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false); + Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds); +} + +public class PersonRepository : IPersonRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public PersonRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(Person person) + { + _context.Person.Attach(person); + } + + public void Remove(Person person) + { + _context.Person.Remove(person); + } + + public async Task FindByNameAsync(string name) + { + var normalizedName = Parser.Parser.Normalize(name); + return await _context.Person + .Where(p => normalizedName.Equals(p.NormalizedName)) + .SingleOrDefaultAsync(); + } + + public async Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false) + { + var peopleWithNoConnections = await _context.Person + .Include(p => p.SeriesMetadatas) + .Include(p => p.ChapterMetadatas) + .Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0) + .ToListAsync(); + + _context.Person.RemoveRange(peopleWithNoConnections); + + await _context.SaveChangesAsync(); + } + + public async Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .SelectMany(s => s.Metadata.People) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + + public async Task> GetAllPeople() + { + return await _context.Person + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index fc9199ccb..329ec47a8 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -4,175 +4,187 @@ using System.Threading.Tasks; using API.DTOs.ReadingLists; using API.Entities; using API.Helpers; -using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface IReadingListRepository { - public class ReadingListRepository : IReadingListRepository + Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams); + Task GetReadingListByIdAsync(int readingListId); + Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId); + Task GetReadingListDtoByIdAsync(int readingListId, int userId); + Task> AddReadingProgressModifiers(int userId, IList items); + Task GetReadingListDtoByTitleAsync(string title); + Task> GetReadingListItemsByIdAsync(int readingListId); + void Remove(ReadingListItem item); + void BulkRemove(IEnumerable items); + void Update(ReadingList list); +} + +public class ReadingListRepository : IReadingListRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public ReadingListRepository(DataContext context, IMapper mapper) { - private readonly DataContext _context; - private readonly IMapper _mapper; + _context = context; + _mapper = mapper; + } - public ReadingListRepository(DataContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } + public void Update(ReadingList list) + { + _context.Entry(list).State = EntityState.Modified; + } - public void Update(ReadingList list) - { - _context.Entry(list).State = EntityState.Modified; - } + public void Remove(ReadingListItem item) + { + _context.ReadingListItem.Remove(item); + } - public void Remove(ReadingListItem item) - { - _context.ReadingListItem.Remove(item); - } - - public void BulkRemove(IEnumerable items) - { - _context.ReadingListItem.RemoveRange(items); - } + public void BulkRemove(IEnumerable items) + { + _context.ReadingListItem.RemoveRange(items); + } - public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams) - { - var query = _context.ReadingList - .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) - .OrderBy(l => l.LastModified) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking(); + public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams) + { + var query = _context.ReadingList + .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .OrderBy(l => l.LastModified) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); - return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); - } + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } - public async Task GetReadingListByIdAsync(int readingListId) - { - return await _context.ReadingList - .Where(r => r.Id == readingListId) - .Include(r => r.Items.OrderBy(item => item.Order)) - .SingleOrDefaultAsync(); - } + public async Task GetReadingListByIdAsync(int readingListId) + { + return await _context.ReadingList + .Where(r => r.Id == readingListId) + .Include(r => r.Items.OrderBy(item => item.Order)) + .SingleOrDefaultAsync(); + } - public async Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId) - { - var userLibraries = _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(user => user.Id == userId)) - .AsNoTracking() - .Select(library => library.Id) - .ToList(); + public async Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId) + { + var userLibraries = _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsNoTracking() + .Select(library => library.Id) + .ToList(); - var items = await _context.ReadingListItem - .Where(s => s.ReadingListId == readingListId) - .Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new - { - TotalPages = chapter.Pages, - ChapterNumber = chapter.Range, - readingListItem = data - }) - .Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new + var items = await _context.ReadingListItem + .Where(s => s.ReadingListId == readingListId) + .Join(_context.Chapter, s => s.ChapterId, chapter => chapter.Id, (data, chapter) => new + { + TotalPages = chapter.Pages, + ChapterNumber = chapter.Range, + readingListItem = data + }) + .Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new + { + data.readingListItem, + data.TotalPages, + data.ChapterNumber, + VolumeId = volume.Id, + VolumeNumber = volume.Name, + }) + .Join(_context.Series, s => s.readingListItem.SeriesId, series => series.Id, + (data, s) => new { + SeriesName = s.Name, + SeriesFormat = s.Format, + s.LibraryId, data.readingListItem, data.TotalPages, data.ChapterNumber, - VolumeId = volume.Id, - VolumeNumber = volume.Name, + data.VolumeNumber, + data.VolumeId }) - .Join(_context.Series, s => s.readingListItem.SeriesId, series => series.Id, - (data, s) => new - { - SeriesName = s.Name, - SeriesFormat = s.Format, - s.LibraryId, - data.readingListItem, - data.TotalPages, - data.ChapterNumber, - data.VolumeNumber, - data.VolumeId - }) - .Select(data => new ReadingListItemDto() - { - Id = data.readingListItem.Id, - ChapterId = data.readingListItem.ChapterId, - Order = data.readingListItem.Order, - SeriesId = data.readingListItem.SeriesId, - SeriesName = data.SeriesName, - SeriesFormat = data.SeriesFormat, - PagesTotal = data.TotalPages, - ChapterNumber = data.ChapterNumber, - VolumeNumber = data.VolumeNumber, - LibraryId = data.LibraryId, - VolumeId = data.VolumeId, - ReadingListId = data.readingListItem.ReadingListId - }) - .Where(o => userLibraries.Contains(o.LibraryId)) - .OrderBy(rli => rli.Order) - .AsNoTracking() - .ToListAsync(); - - // Attach progress information - var fetchedChapterIds = items.Select(i => i.ChapterId); - var progresses = await _context.AppUserProgresses - .Where(p => fetchedChapterIds.Contains(p.ChapterId)) - .AsNoTracking() - .ToListAsync(); - - foreach (var progress in progresses) + .Select(data => new ReadingListItemDto() { - var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId); - if (progressItem == null) continue; + Id = data.readingListItem.Id, + ChapterId = data.readingListItem.ChapterId, + Order = data.readingListItem.Order, + SeriesId = data.readingListItem.SeriesId, + SeriesName = data.SeriesName, + SeriesFormat = data.SeriesFormat, + PagesTotal = data.TotalPages, + ChapterNumber = data.ChapterNumber, + VolumeNumber = data.VolumeNumber, + LibraryId = data.LibraryId, + VolumeId = data.VolumeId, + ReadingListId = data.readingListItem.ReadingListId + }) + .Where(o => userLibraries.Contains(o.LibraryId)) + .OrderBy(rli => rli.Order) + .AsNoTracking() + .ToListAsync(); - progressItem.PagesRead = progress.PagesRead; - } + // Attach progress information + var fetchedChapterIds = items.Select(i => i.ChapterId); + var progresses = await _context.AppUserProgresses + .Where(p => fetchedChapterIds.Contains(p.ChapterId)) + .AsNoTracking() + .ToListAsync(); - return items; - } - - public async Task GetReadingListDtoByIdAsync(int readingListId, int userId) + foreach (var progress in progresses) { - return await _context.ReadingList - .Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted)) - .ProjectTo(_mapper.ConfigurationProvider) - .SingleOrDefaultAsync(); + var progressItem = items.SingleOrDefault(i => i.ChapterId == progress.ChapterId && i.ReadingListId == readingListId); + if (progressItem == null) continue; + + progressItem.PagesRead = progress.PagesRead; } - public async Task> AddReadingProgressModifiers(int userId, IList items) - { - var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList(); - var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId)) - .AsNoTracking() - .ToListAsync(); - - foreach (var item in items) - { - var progress = userProgress.Where(p => p.ChapterId == item.ChapterId); - item.PagesRead = progress.Sum(p => p.PagesRead); - } - - return items; - } - - public async Task GetReadingListDtoByTitleAsync(string title) - { - return await _context.ReadingList - .Where(r => r.Title.Equals(title)) - .ProjectTo(_mapper.ConfigurationProvider) - .SingleOrDefaultAsync(); - } - - public async Task> GetReadingListItemsByIdAsync(int readingListId) - { - return await _context.ReadingListItem - .Where(r => r.ReadingListId == readingListId) - .OrderBy(r => r.Order) - .ToListAsync(); - } - - + return items; } + + public async Task GetReadingListDtoByIdAsync(int readingListId, int userId) + { + return await _context.ReadingList + .Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + public async Task> AddReadingProgressModifiers(int userId, IList items) + { + var chapterIds = items.Select(i => i.ChapterId).Distinct().ToList(); + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && chapterIds.Contains(p.ChapterId)) + .AsNoTracking() + .ToListAsync(); + + foreach (var item in items) + { + var progress = userProgress.Where(p => p.ChapterId == item.ChapterId); + item.PagesRead = progress.Sum(p => p.PagesRead); + } + + return items; + } + + public async Task GetReadingListDtoByTitleAsync(string title) + { + return await _context.ReadingList + .Where(r => r.Title.Equals(title)) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleOrDefaultAsync(); + } + + public async Task> GetReadingListItemsByIdAsync(int readingListId) + { + return await _context.ReadingListItem + .Where(r => r.ReadingListId == readingListId) + .OrderBy(r => r.Order) + .ToListAsync(); + } + + } diff --git a/API/Data/Repositories/SeriesMetadataRepository.cs b/API/Data/Repositories/SeriesMetadataRepository.cs index 32ab0f4e2..0a3efee26 100644 --- a/API/Data/Repositories/SeriesMetadataRepository.cs +++ b/API/Data/Repositories/SeriesMetadataRepository.cs @@ -1,20 +1,23 @@ -using API.Entities; -using API.Interfaces.Repositories; +using API.Entities.Metadata; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface ISeriesMetadataRepository { - public class SeriesMetadataRepository : ISeriesMetadataRepository + void Update(SeriesMetadata seriesMetadata); +} + +public class SeriesMetadataRepository : ISeriesMetadataRepository +{ + private readonly DataContext _context; + + public SeriesMetadataRepository(DataContext context) { - private readonly DataContext _context; + _context = context; + } - public SeriesMetadataRepository(DataContext context) - { - _context = context; - } - - public void Update(SeriesMetadata seriesMetadata) - { - _context.SeriesMetadata.Update(seriesMetadata); - } + public void Update(SeriesMetadata seriesMetadata) + { + _context.SeriesMetadata.Update(seriesMetadata); } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index baa55330f..e8ffa9e16 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,527 +1,805 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using API.Data.Scanner; using API.DTOs; using API.DTOs.CollectionTags; using API.DTOs.Filtering; +using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Helpers; -using API.Interfaces.Repositories; using API.Services.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; +using Kavita.Common.Extensions; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface ISeriesRepository { - public class SeriesRepository : ISeriesRepository + void Attach(Series series); + void Update(Series series); + void Remove(Series series); + void Remove(IEnumerable series); + Task DoesSeriesNameExistInLibrary(string name, MangaFormat format); + /// + /// Adds user information like progress, ratings, etc + /// + /// + /// + /// + /// + Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter); + /// + /// Does not add user information like progress, ratings, etc. + /// + /// + /// Series name to search for + /// + Task> SearchSeries(int[] libraryIds, string searchQuery); + Task> GetSeriesForLibraryIdAsync(int libraryId); + Task GetSeriesDtoByIdAsync(int seriesId, int userId); + Task DeleteSeriesAsync(int seriesId); + Task GetSeriesByIdAsync(int seriesId); + Task> GetSeriesByIdsAsync(IList seriesIds); + Task GetChapterIdsForSeriesAsync(IList seriesIds); + Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); + /// + /// Used to add Progress/Rating information to series list. + /// + /// + /// + /// + Task AddSeriesModifiers(int userId, List series); + Task GetSeriesCoverImageAsync(int seriesId); + Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); + Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo + Task GetSeriesMetadata(int seriesId); + Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); + Task> GetFilesForSeries(int seriesId); + Task> GetSeriesDtoForIdsAsync(IEnumerable seriesIds, int userId); + Task> GetAllCoverImagesAsync(); + Task> GetLockedCoverImagesAsync(); + Task> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams); + Task GetFullSeriesForSeriesIdAsync(int seriesId); + Task GetChunkInfo(int libraryId = 0); + Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds); + Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); + Task> GetAllLanguagesForLibrariesAsync(List libraryIds); + Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); +} + +public class SeriesRepository : ISeriesRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + public SeriesRepository(DataContext context, IMapper mapper) { - private readonly DataContext _context; - private readonly IMapper _mapper; - public SeriesRepository(DataContext context, IMapper mapper) + _context = context; + _mapper = mapper; + } + + public void Attach(Series series) + { + _context.Series.Attach(series); + } + + public void Update(Series series) + { + _context.Entry(series).State = EntityState.Modified; + } + + public void Remove(Series series) + { + _context.Series.Remove(series); + } + + public void Remove(IEnumerable series) + { + _context.Series.RemoveRange(series); + } + + /// + /// Returns if a series name and format exists already in a library + /// + /// Name of series + /// Format of series + /// + public async Task DoesSeriesNameExistInLibrary(string name, MangaFormat format) + { + var libraries = _context.Series + .AsNoTracking() + .Where(x => x.Name.Equals(name) && x.Format == format) + .Select(s => s.LibraryId); + + return await _context.Series + .AsNoTracking() + .Where(s => libraries.Contains(s.LibraryId) && s.Name.Equals(name) && s.Format == format) + .CountAsync() > 1; + } + + public async Task> GetSeriesForLibraryIdAsync(int libraryId) + { + return await _context.Series + .Where(s => s.LibraryId == libraryId) + .OrderBy(s => s.SortName) + .ToListAsync(); + } + + /// + /// Used for to + /// + /// + /// + public async Task> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams) + { + var query = _context.Series + .Where(s => s.LibraryId == libraryId) + + .Include(s => s.Metadata) + .ThenInclude(m => m.People) + + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(cm => cm.People) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Genres) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Tags) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .AsSplitQuery() + .OrderBy(s => s.SortName); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + /// + /// This is a heavy call. Returns all entities down to Files and Library and Series Metadata. + /// + /// + /// + public async Task GetFullSeriesForSeriesIdAsync(int seriesId) + { + return await _context.Series + .Where(s => s.Id == seriesId) + .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + .Include(s => s.Library) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(cm => cm.People) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Tags) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Genres) + + + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags) + + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .AsSplitQuery() + .SingleOrDefaultAsync(); + } + + public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) + { + var query = await CreateFilteredSearchQueryable(userId, libraryId, filter); + + if (filter.SortOptions == null) { - _context = context; - _mapper = mapper; + query = query.OrderBy(s => s.SortName); } - public void Attach(Series series) + var retSeries = query + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .AsNoTracking(); + + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); + } + + private async Task> GetUserLibraries(int libraryId, int userId) + { + if (libraryId == 0) { - _context.Series.Attach(series); - } - - public void Update(Series series) - { - _context.Entry(series).State = EntityState.Modified; - } - - public void Remove(Series series) - { - _context.Series.Remove(series); - } - - public void Remove(IEnumerable series) - { - _context.Series.RemoveRange(series); - } - - /// - /// Returns if a series name and format exists already in a library - /// - /// Name of series - /// Format of series - /// - public async Task DoesSeriesNameExistInLibrary(string name, MangaFormat format) - { - var libraries = _context.Series - .AsNoTracking() - .Where(x => x.Name.Equals(name) && x.Format == format) - .Select(s => s.LibraryId); - - return await _context.Series - .AsNoTracking() - .Where(s => libraries.Contains(s.LibraryId) && s.Name.Equals(name) && s.Format == format) - .CountAsync() > 1; - } - - public async Task> GetSeriesForLibraryIdAsync(int libraryId) - { - return await _context.Series - .Where(s => s.LibraryId == libraryId) - .OrderBy(s => s.SortName) - .ToListAsync(); - } - - /// - /// Used for to - /// - /// - /// - public async Task> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams) - { - var query = _context.Series - .Where(s => s.LibraryId == libraryId) - .Include(s => s.Metadata) - .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters) - .ThenInclude(c => c.Files) - .AsSplitQuery() - .OrderBy(s => s.SortName); - - return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); - } - - /// - /// This is a heavy call. Returns all entities down to Files and Library and Series Metadata. - /// - /// - /// - public async Task GetFullSeriesForSeriesIdAsync(int seriesId) - { - return await _context.Series - .Where(s => s.Id == seriesId) - .Include(s => s.Metadata) - .Include(s => s.Library) - .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters) - .ThenInclude(c => c.Files) - .AsSplitQuery() - .SingleOrDefaultAsync(); - } - - public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) - { - var formats = filter.GetSqlFilter(); - var query = _context.Series - .Where(s => s.LibraryId == libraryId && formats.Contains(s.Format)) - .OrderBy(s => s.SortName) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking(); - - return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); - } - - public async Task> SearchSeries(int[] libraryIds, string searchQuery) - { - return await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) - .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") - || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") - || EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) - .Include(s => s.Library) - .OrderBy(s => s.SortName) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - - - - - - - - public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) - { - var series = await _context.Series.Where(x => x.Id == seriesId) - .ProjectTo(_mapper.ConfigurationProvider) - .SingleAsync(); - - var seriesList = new List() {series}; - await AddSeriesModifiers(userId, seriesList); - - return seriesList[0]; - } - - - - - public async Task DeleteSeriesAsync(int seriesId) - { - var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync(); - _context.Series.Remove(series); - - return await _context.SaveChangesAsync() > 0; - } - - - /// - /// Returns Volumes, Metadata, and Collection Tags - /// - /// - /// - public async Task GetSeriesByIdAsync(int seriesId) - { - return await _context.Series - .Include(s => s.Volumes) - .Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags) - .Where(s => s.Id == seriesId) - .SingleOrDefaultAsync(); - } - - /// - /// Returns Volumes, Metadata, and Collection Tags - /// - /// - /// - public async Task> GetSeriesByIdsAsync(IList seriesIds) - { - return await _context.Series - .Include(s => s.Volumes) - .Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags) - .Where(s => seriesIds.Contains(s.Id)) - .ToListAsync(); - } - - public async Task GetChapterIdsForSeriesAsync(int[] seriesIds) - { - var volumes = await _context.Volume - .Where(v => seriesIds.Contains(v.SeriesId)) - .Include(v => v.Chapters) - .ToListAsync(); - - IList chapterIds = new List(); - foreach (var v in volumes) - { - foreach (var c in v.Chapters) - { - chapterIds.Add(c.Id); - } - } - - return chapterIds.ToArray(); - } - - /// - /// This returns a dictonary mapping seriesId -> list of chapters back for each series id passed - /// - /// - /// - public async Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds) - { - var volumes = await _context.Volume - .Where(v => seriesIds.Contains(v.SeriesId)) - .Include(v => v.Chapters) - .ToListAsync(); - - var seriesChapters = new Dictionary>(); - foreach (var v in volumes) - { - foreach (var c in v.Chapters) - { - if (!seriesChapters.ContainsKey(v.SeriesId)) - { - var list = new List(); - seriesChapters.Add(v.SeriesId, list); - } - seriesChapters[v.SeriesId].Add(c.Id); - } - } - - return seriesChapters; - } - - public async Task AddSeriesModifiers(int userId, List series) - { - var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId)) - .ToListAsync(); - - var userRatings = await _context.AppUserRating - .Where(r => r.AppUserId == userId && series.Select(s => s.Id).Contains(r.SeriesId)) - .ToListAsync(); - - foreach (var s in series) - { - s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead); - var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id); - if (rating == null) continue; - s.UserRating = rating.Rating; - s.UserReview = rating.Review; - } - } - - public async Task GetSeriesCoverImageAsync(int seriesId) - { - return await _context.Series - .Where(s => s.Id == seriesId) - .Select(s => s.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } - - - - /// - /// Returns a list of Series that were added, ordered by Created desc - /// - /// - /// Library to restrict to, if 0, will apply to all libraries - /// Contains pagination information - /// Optional filter on query - /// - public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter) - { - var formats = filter.GetSqlFilter(); - - if (libraryId == 0) - { - var userLibraries = _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(user => user.Id == userId)) - .AsNoTracking() - .Select(library => library.Id) - .ToList(); - - var allQuery = _context.Series - .Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format)) - .OrderByDescending(s => s.Created) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking(); - - return await PagedList.CreateAsync(allQuery, userParams.PageNumber, userParams.PageSize); - } - - var query = _context.Series - .Where(s => s.LibraryId == libraryId && formats.Contains(s.Format)) - .OrderByDescending(s => s.Created) - .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() - .AsNoTracking(); - - return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); - } - - /// - /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, but if a series - /// has been updated recently, bump it to the front. - /// - /// - /// Library to restrict to, if 0, will apply to all libraries - /// Pagination information - /// Optional (default null) filter on query - /// - public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) - { - var formats = filter.GetSqlFilter(); - IList userLibraries; - if (libraryId == 0) - { - userLibraries = _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(user => user.Id == userId)) - .AsNoTracking() - .Select(library => library.Id) - .ToList(); - } - else - { - userLibraries = new List() {libraryId}; - } - - var series = _context.Series - .Where(s => formats.Contains(s.Format) && userLibraries.Contains(s.LibraryId)) - .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new - { - Series = s, - PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId).Sum(s1 => s1.PagesRead), - progress.AppUserId, - LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId).Max(p => p.LastModified) - }) - .AsNoTracking(); - - var retSeries = series.Where(s => s.AppUserId == userId - && s.PagesRead > 0 - && s.PagesRead < s.Series.Pages) - .OrderByDescending(s => s.LastModified) - .ThenByDescending(s => s.Series.LastModified) - .Select(s => s.Series) - .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() - .AsNoTracking(); - - // Pagination does not work for this query as when we pull the data back, we get multiple rows of the same series. See controller for pagination code - return await retSeries.ToListAsync(); - } - - public async Task GetSeriesMetadata(int seriesId) - { - var metadataDto = await _context.SeriesMetadata - .Where(metadata => metadata.SeriesId == seriesId) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .SingleOrDefaultAsync(); - - if (metadataDto != null) - { - metadataDto.Tags = await _context.CollectionTag - .Include(t => t.SeriesMetadatas) - .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .ToListAsync(); - } - - return metadataDto; - } - - public async Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams) - { - var userLibraries = _context.Library + return await _context.Library .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(user => user.Id == userId)) .AsNoTracking() .Select(library => library.Id) - .ToList(); - - var query = _context.CollectionTag - .Where(s => s.Id == collectionId) - .Include(c => c.SeriesMetadatas) - .ThenInclude(m => m.Series) - .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) - .OrderBy(s => s.LibraryId) - .ThenBy(s => s.SortName) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking(); - - return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); - } - - public async Task> GetFilesForSeries(int seriesId) - { - return await _context.Volume - .Where(v => v.SeriesId == seriesId) - .Include(v => v.Chapters) - .ThenInclude(c => c.Files) - .SelectMany(v => v.Chapters.SelectMany(c => c.Files)) - .AsNoTracking() .ToListAsync(); } - public async Task> GetSeriesDtoForIdsAsync(IEnumerable seriesIds, int userId) + return new List() { - var allowedLibraries = _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(x => x.Id == userId)) - .Select(l => l.Id); + libraryId + }; + } - return await _context.Series - .Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId)) - .OrderBy(s => s.SortName) - .ProjectTo(_mapper.ConfigurationProvider) + public async Task> SearchSeries(int[] libraryIds, string searchQuery) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") + || EF.Functions.Like(s.OriginalName, $"%{searchQuery}%") + || EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) + .Include(s => s.Library) + .OrderBy(s => s.SortName) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + + + + + + + + public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) + { + var series = await _context.Series.Where(x => x.Id == seriesId) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleAsync(); + + var seriesList = new List() {series}; + await AddSeriesModifiers(userId, seriesList); + + return seriesList[0]; + } + + + + + public async Task DeleteSeriesAsync(int seriesId) + { + var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync(); + if (series != null) _context.Series.Remove(series); + + return await _context.SaveChangesAsync() > 0; + } + + + /// + /// Returns Volumes, Metadata, and Collection Tags + /// + /// + /// + public async Task GetSeriesByIdAsync(int seriesId) + { + return await _context.Series + .Include(s => s.Volumes) + .Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Where(s => s.Id == seriesId) + .AsSplitQuery() + .SingleOrDefaultAsync(); + } + + /// + /// Returns Volumes, Metadata, and Collection Tags + /// + /// + /// + public async Task> GetSeriesByIdsAsync(IList seriesIds) + { + return await _context.Series + .Include(s => s.Volumes) + .Include(s => s.Metadata) + .ThenInclude(m => m.CollectionTags) + .Where(s => seriesIds.Contains(s.Id)) + .AsSplitQuery() + .ToListAsync(); + } + + public async Task GetChapterIdsForSeriesAsync(IList seriesIds) + { + var volumes = await _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)) + .Include(v => v.Chapters) + .ToListAsync(); + + IList chapterIds = new List(); + foreach (var v in volumes) + { + foreach (var c in v.Chapters) + { + chapterIds.Add(c.Id); + } + } + + return chapterIds.ToArray(); + } + + /// + /// This returns a dictonary mapping seriesId -> list of chapters back for each series id passed + /// + /// + /// + public async Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds) + { + var volumes = await _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)) + .Include(v => v.Chapters) + .ToListAsync(); + + var seriesChapters = new Dictionary>(); + foreach (var v in volumes) + { + foreach (var c in v.Chapters) + { + if (!seriesChapters.ContainsKey(v.SeriesId)) + { + var list = new List(); + seriesChapters.Add(v.SeriesId, list); + } + seriesChapters[v.SeriesId].Add(c.Id); + } + } + + return seriesChapters; + } + + public async Task AddSeriesModifiers(int userId, List series) + { + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId)) + .ToListAsync(); + + var userRatings = await _context.AppUserRating + .Where(r => r.AppUserId == userId && series.Select(s => s.Id).Contains(r.SeriesId)) + .ToListAsync(); + + foreach (var s in series) + { + s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead); + var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id); + if (rating == null) continue; + s.UserRating = rating.Rating; + s.UserReview = rating.Review; + } + } + + public async Task GetSeriesCoverImageAsync(int seriesId) + { + return await _context.Series + .Where(s => s.Id == seriesId) + .Select(s => s.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + + + + /// + /// Returns a list of Series that were added, ordered by Created desc + /// + /// + /// Library to restrict to, if 0, will apply to all libraries + /// Contains pagination information + /// Optional filter on query + /// + public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter) + { + var query = await CreateFilteredSearchQueryable(userId, libraryId, filter); + + var retSeries = query + .OrderByDescending(s => s.Created) + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .AsNoTracking(); + + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); + } + + private IList ExtractFilters(int libraryId, int userId, FilterDto filter, ref List userLibraries, + out List allPeopleIds, out bool hasPeopleFilter, out bool hasGenresFilter, out bool hasCollectionTagFilter, + out bool hasRatingFilter, out bool hasProgressFilter, out IList seriesIds, out bool hasAgeRating, out bool hasTagsFilter, + out bool hasLanguageFilter, out bool hasPublicationFilter) + { + var formats = filter.GetSqlFilter(); + + if (filter.Libraries.Count > 0) + { + userLibraries = userLibraries.Where(l => filter.Libraries.Contains(l)).ToList(); + } + else if (libraryId > 0) + { + userLibraries = userLibraries.Where(l => l == libraryId).ToList(); + } + + allPeopleIds = new List(); + allPeopleIds.AddRange(filter.Writers); + allPeopleIds.AddRange(filter.Character); + allPeopleIds.AddRange(filter.Colorist); + allPeopleIds.AddRange(filter.Editor); + allPeopleIds.AddRange(filter.Inker); + allPeopleIds.AddRange(filter.Letterer); + allPeopleIds.AddRange(filter.Penciller); + allPeopleIds.AddRange(filter.Publisher); + allPeopleIds.AddRange(filter.CoverArtist); + allPeopleIds.AddRange(filter.Translators); + //allPeopleIds.AddRange(filter.Artist); + + hasPeopleFilter = allPeopleIds.Count > 0; + hasGenresFilter = filter.Genres.Count > 0; + hasCollectionTagFilter = filter.CollectionTags.Count > 0; + hasRatingFilter = filter.Rating > 0; + hasProgressFilter = !filter.ReadStatus.Read || !filter.ReadStatus.InProgress || !filter.ReadStatus.NotRead; + hasAgeRating = filter.AgeRating.Count > 0; + hasTagsFilter = filter.Tags.Count > 0; + hasLanguageFilter = filter.Languages.Count > 0; + hasPublicationFilter = filter.PublicationStatus.Count > 0; + + + bool ProgressComparison(int pagesRead, int totalPages) + { + var result = false; + if (filter.ReadStatus.NotRead) + { + result = (pagesRead == 0); + } + + if (filter.ReadStatus.Read) + { + result = result || (pagesRead == totalPages); + } + + if (filter.ReadStatus.InProgress) + { + result = result || (pagesRead > 0 && pagesRead < totalPages); + } + + return result; + } + + seriesIds = new List(); + if (hasProgressFilter) + { + seriesIds = _context.Series + .Include(s => s.Progress) + .Select(s => new + { + Series = s, + PagesRead = s.Progress.Where(p => p.AppUserId == userId).Sum(p => p.PagesRead), + }) + .AsEnumerable() + .Where(s => ProgressComparison(s.PagesRead, s.Series.Pages)) + .Select(s => s.Series.Id) + .ToList(); + } + + return formats; + } + + /// + /// Returns Series that the user has some partial progress on. Sorts based on activity. Sort first by User progress, but if a series + /// has been updated recently, bump it to the front. + /// + /// + /// Library to restrict to, if 0, will apply to all libraries + /// Pagination information + /// Optional (default null) filter on query + /// + public async Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter) + { + //var allSeriesWithProgress = await _context.AppUserProgresses.Select(p => p.SeriesId).ToListAsync(); + //var allChapters = await GetChapterIdsForSeriesAsync(allSeriesWithProgress); + + var query = (await CreateFilteredSearchQueryable(userId, libraryId, filter)) + .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => + new + { + Series = s, + PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId) + .Sum(s1 => s1.PagesRead), + progress.AppUserId, + LastReadingProgress = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId) + .Max(p => p.LastModified), + // This is only taking into account chapters that have progress on them, not all chapters in said series + LastChapterCreated = _context.Chapter.Where(c => progress.ChapterId == c.Id).Max(c => c.Created) + //LastChapterCreated = _context.Chapter.Where(c => allChapters.Contains(c.Id)).Max(c => c.Created) + }); + // I think I need another Join statement. The problem is the chapters are still limited to progress + + + + var retSeries = query.Where(s => s.AppUserId == userId + && s.PagesRead > 0 + && s.PagesRead < s.Series.Pages) + .OrderByDescending(s => s.LastReadingProgress) + .ThenByDescending(s => s.LastChapterCreated) + .Select(s => s.Series) + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .AsNoTracking(); + + // Pagination does not work for this query as when we pull the data back, we get multiple rows of the same series. See controller for pagination code + return await retSeries.ToListAsync(); + } + + private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter) + { + var userLibraries = await GetUserLibraries(libraryId, userId); + var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, + out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, + out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter, + out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter); + + var query = _context.Series + .Where(s => userLibraries.Contains(s.LibraryId) + && formats.Contains(s.Format) + && (!hasGenresFilter || s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id))) + && (!hasPeopleFilter || s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id))) + && (!hasCollectionTagFilter || + s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) + && (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating)) + && (!hasProgressFilter || seriesIds.Contains(s.Id)) + && (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating)) + && (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id))) + && (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language)) + && (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)) + ) + .AsNoTracking(); + + if (filter.SortOptions != null) + { + if (filter.SortOptions.IsAscending) + { + if (filter.SortOptions.SortField == SortField.SortName) + { + query = query.OrderBy(s => s.SortName); + } else if (filter.SortOptions.SortField == SortField.CreatedDate) + { + query = query.OrderBy(s => s.Created); + } else if (filter.SortOptions.SortField == SortField.LastModifiedDate) + { + query = query.OrderBy(s => s.LastModified); + } + } + else + { + if (filter.SortOptions.SortField == SortField.SortName) + { + query = query.OrderByDescending(s => s.SortName); + } else if (filter.SortOptions.SortField == SortField.CreatedDate) + { + query = query.OrderByDescending(s => s.Created); + } else if (filter.SortOptions.SortField == SortField.LastModifiedDate) + { + query = query.OrderByDescending(s => s.LastModified); + } + } + } + + return query; + } + + public async Task GetSeriesMetadata(int seriesId) + { + var metadataDto = await _context.SeriesMetadata + .Where(metadata => metadata.SeriesId == seriesId) + .Include(m => m.Genres) + .Include(m => m.Tags) + .Include(m => m.People) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .SingleOrDefaultAsync(); + + if (metadataDto != null) + { + metadataDto.CollectionTags = await _context.CollectionTag + .Include(t => t.SeriesMetadatas) + .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) + .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() .AsSplitQuery() .ToListAsync(); } - public async Task> GetAllCoverImagesAsync() + return metadataDto; + } + + public async Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams) + { + var userLibraries = _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(user => user.Id == userId)) + .AsNoTracking() + .Select(library => library.Id) + .ToList(); + + var query = _context.CollectionTag + .Where(s => s.Id == collectionId) + .Include(c => c.SeriesMetadatas) + .ThenInclude(m => m.Series) + .SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId))) + .OrderBy(s => s.LibraryId) + .ThenBy(s => s.SortName) + .ProjectTo(_mapper.ConfigurationProvider) + .AsSplitQuery() + .AsNoTracking(); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task> GetFilesForSeries(int seriesId) + { + return await _context.Volume + .Where(v => v.SeriesId == seriesId) + .Include(v => v.Chapters) + .ThenInclude(c => c.Files) + .SelectMany(v => v.Chapters.SelectMany(c => c.Files)) + .AsNoTracking() + .ToListAsync(); + } + + public async Task> GetSeriesDtoForIdsAsync(IEnumerable seriesIds, int userId) + { + var allowedLibraries = _context.Library + .Include(l => l.AppUsers) + .Where(library => library.AppUsers.Any(x => x.Id == userId)) + .Select(l => l.Id); + + return await _context.Series + .Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId)) + .OrderBy(s => s.SortName) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(); + } + + public async Task> GetAllCoverImagesAsync() + { + return await _context.Series + .Select(s => s.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .AsNoTracking() + .ToListAsync(); + } + + public async Task> GetLockedCoverImagesAsync() + { + return await _context.Series + .Where(s => s.CoverImageLocked && !string.IsNullOrEmpty(s.CoverImage)) + .Select(s => s.CoverImage) + .AsNoTracking() + .ToListAsync(); + } + + /// + /// Returns the number of series for a given library (or all libraries if libraryId is 0) + /// + /// Defaults to 0, library to restrict count to + /// + private async Task GetSeriesCount(int libraryId = 0) + { + if (libraryId > 0) { return await _context.Series - .Select(s => s.CoverImage) - .Where(t => !string.IsNullOrEmpty(t)) - .AsNoTracking() - .ToListAsync(); + .Where(s => s.LibraryId == libraryId) + .CountAsync(); } + return await _context.Series.CountAsync(); + } - public async Task> GetLockedCoverImagesAsync() - { - return await _context.Series - .Where(s => s.CoverImageLocked && !string.IsNullOrEmpty(s.CoverImage)) - .Select(s => s.CoverImage) - .AsNoTracking() - .ToListAsync(); - } + /// + /// Returns the number of series that should be processed in parallel to optimize speed and memory. Minimum of 50 + /// + /// Defaults to 0 meaning no library + /// + private async Task> GetChunkSize(int libraryId = 0) + { + var totalSeries = await GetSeriesCount(libraryId); + return new Tuple(totalSeries, 50); + } - /// - /// Returns the number of series for a given library (or all libraries if libraryId is 0) - /// - /// Defaults to 0, library to restrict count to - /// - private async Task GetSeriesCount(int libraryId = 0) + public async Task GetChunkInfo(int libraryId = 0) + { + var (totalSeries, chunkSize) = await GetChunkSize(libraryId); + + if (totalSeries == 0) return new Chunk() { - if (libraryId > 0) + TotalChunks = 0, + TotalSize = 0, + ChunkSize = 0 + }; + + var totalChunks = Math.Max((int) Math.Ceiling((totalSeries * 1.0) / chunkSize), 1); + + return new Chunk() + { + TotalSize = totalSeries, + ChunkSize = chunkSize, + TotalChunks = totalChunks + }; + } + + public async Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds) + { + return await _context.SeriesMetadata + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .Include(sm => sm.CollectionTags) + .ToListAsync(); + } + + public async Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Metadata.AgeRating) + .Distinct() + .Select(s => new AgeRatingDto() { - return await _context.Series - .Where(s => s.LibraryId == libraryId) - .CountAsync(); - } - return await _context.Series.CountAsync(); - } + Value = s, + Title = s.ToDescription() + }) + .ToListAsync(); + } - /// - /// Returns the number of series that should be processed in parallel to optimize speed and memory. Minimum of 50 - /// - /// Defaults to 0 meaning no library - /// - private async Task> GetChunkSize(int libraryId = 0) - { - // TODO: Think about making this bigger depending on number of files a user has in said library - // and number of cores and amount of memory. We can then make an optimal choice - var totalSeries = await GetSeriesCount(libraryId); - // var procCount = Math.Max(Environment.ProcessorCount - 1, 1); - // - // if (totalSeries < procCount * 2 || totalSeries < 50) - // { - // return new Tuple(totalSeries, totalSeries); - // } - // - // return new Tuple(totalSeries, Math.Max(totalSeries / procCount, 50)); - return new Tuple(totalSeries, 50); - } + public async Task> GetAllLanguagesForLibrariesAsync(List libraryIds) + { + var ret = await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Metadata.Language) + .Distinct() + .ToListAsync(); - public async Task GetChunkInfo(int libraryId = 0) - { - var (totalSeries, chunkSize) = await GetChunkSize(libraryId); - - if (totalSeries == 0) return new Chunk() + return ret + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => new LanguageDto() { - TotalChunks = 0, - TotalSize = 0, - ChunkSize = 0 - }; + Title = CultureInfo.GetCultureInfo(s).DisplayName, + IsoCode = s + }).ToList(); + } - var totalChunks = Math.Max((int) Math.Ceiling((totalSeries * 1.0) / chunkSize), 1); - - return new Chunk() + public async Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Metadata.PublicationStatus) + .Distinct() + .Select(s => new PublicationStatusDto() { - TotalSize = totalSeries, - ChunkSize = chunkSize, - TotalChunks = totalChunks - }; - } - - public async Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds) - { - return await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .Include(sm => sm.CollectionTags) - .ToListAsync(); - } + Value = s, + Title = s.ToDescription() + }) + .ToListAsync(); } } diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 4489cf3bd..be66cbe62 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -4,45 +4,50 @@ using System.Threading.Tasks; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; -using API.Interfaces.Repositories; using AutoMapper; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface ISettingsRepository { - public class SettingsRepository : ISettingsRepository + void Update(ServerSetting settings); + Task GetSettingsDtoAsync(); + Task GetSettingAsync(ServerSettingKey key); + Task> GetSettingsAsync(); +} +public class SettingsRepository : ISettingsRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SettingsRepository(DataContext context, IMapper mapper) { - private readonly DataContext _context; - private readonly IMapper _mapper; + _context = context; + _mapper = mapper; + } - public SettingsRepository(DataContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } + public void Update(ServerSetting settings) + { + _context.Entry(settings).State = EntityState.Modified; + } - public void Update(ServerSetting settings) - { - _context.Entry(settings).State = EntityState.Modified; - } + public async Task GetSettingsDtoAsync() + { + var settings = await _context.ServerSetting + .Select(x => x) + .AsNoTracking() + .ToListAsync(); + return _mapper.Map(settings); + } - public async Task GetSettingsDtoAsync() - { - var settings = await _context.ServerSetting - .Select(x => x) - .AsNoTracking() - .ToListAsync(); - return _mapper.Map(settings); - } + public Task GetSettingAsync(ServerSettingKey key) + { + return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key); + } - public Task GetSettingAsync(ServerSettingKey key) - { - return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key); - } - - public async Task> GetSettingsAsync() - { - return await _context.ServerSetting.ToListAsync(); - } + public async Task> GetSettingsAsync() + { + return await _context.ServerSetting.ToListAsync(); } } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs new file mode 100644 index 000000000..772957aa9 --- /dev/null +++ b/API/Data/Repositories/TagRepository.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata; +using API.Entities; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface ITagRepository +{ + void Attach(Tag tag); + void Remove(Tag tag); + Task FindByNameAsync(string tagName); + Task> GetAllTagsAsync(); + Task> GetAllTagDtosAsync(); + Task RemoveAllTagNoLongerAssociated(bool removeExternal = false); + Task> GetAllTagDtosForLibrariesAsync(IList libraryIds); +} + +public class TagRepository : ITagRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public TagRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(Tag tag) + { + _context.Tag.Attach(tag); + } + + public void Remove(Tag tag) + { + _context.Tag.Remove(tag); + } + + public async Task FindByNameAsync(string tagName) + { + var normalizedName = Parser.Parser.Normalize(tagName); + return await _context.Tag + .FirstOrDefaultAsync(g => g.NormalizedTitle.Equals(normalizedName)); + } + + public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false) + { + var TagsWithNoConnections = await _context.Tag + .Include(p => p.SeriesMetadatas) + .Include(p => p.Chapters) + .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .ToListAsync(); + + _context.Tag.RemoveRange(TagsWithNoConnections); + + await _context.SaveChangesAsync(); + } + + public async Task> GetAllTagDtosForLibrariesAsync(IList libraryIds) + { + return await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .SelectMany(s => s.Metadata.Tags) + .Distinct() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllTagsAsync() + { + return await _context.Tag.ToListAsync(); + } + + public async Task> GetAllTagDtosAsync() + { + return await _context.Tag + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index ece1356fd..138ef15b8 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -6,254 +6,303 @@ using API.Constants; using API.DTOs; using API.DTOs.Reader; using API.Entities; -using API.Interfaces.Repositories; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +[Flags] +public enum AppUserIncludes { - [Flags] - public enum AppUserIncludes + None = 1, + Progress = 2, + Bookmarks = 4, + ReadingLists = 8, + Ratings = 16 +} + +public interface IUserRepository +{ + void Update(AppUser user); + void Update(AppUserPreferences preferences); + void Update(AppUserBookmark bookmark); + public void Delete(AppUser user); + Task> GetMembersAsync(); + Task> GetAdminUsersAsync(); + Task> GetNonAdminUsersAsync(); + Task IsUserAdminAsync(AppUser user); + Task GetUserRatingAsync(int seriesId, int userId); + Task GetPreferencesAsync(string username); + Task> GetBookmarkDtosForSeries(int userId, int seriesId); + Task> GetBookmarkDtosForVolume(int userId, int volumeId); + Task> GetBookmarkDtosForChapter(int userId, int chapterId); + Task> GetAllBookmarkDtos(int userId); + Task> GetAllBookmarksAsync(); + Task GetBookmarkForPage(int page, int chapterId, int userId); + Task GetBookmarkAsync(int bookmarkId); + Task GetUserIdByApiKeyAsync(string apiKey); + Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); + Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); + Task GetUserIdByUsernameAsync(string username); + Task GetUserWithReadingListsByUsernameAsync(string username); + Task> GetAllBookmarksByIds(IList bookmarkIds); +} + +public class UserRepository : IUserRepository +{ + private readonly DataContext _context; + private readonly UserManager _userManager; + private readonly IMapper _mapper; + + public UserRepository(DataContext context, UserManager userManager, IMapper mapper) { - None = 1, - Progress = 2, - Bookmarks = 4, - ReadingLists = 8, - Ratings = 16 + _context = context; + _userManager = userManager; + _mapper = mapper; } - public class UserRepository : IUserRepository + public void Update(AppUser user) { - private readonly DataContext _context; - private readonly UserManager _userManager; - private readonly IMapper _mapper; + _context.Entry(user).State = EntityState.Modified; + } - public UserRepository(DataContext context, UserManager userManager, IMapper mapper) + public void Update(AppUserPreferences preferences) + { + _context.Entry(preferences).State = EntityState.Modified; + } + + public void Update(AppUserBookmark bookmark) + { + _context.Entry(bookmark).State = EntityState.Modified; + } + + public void Delete(AppUser user) + { + _context.AppUser.Remove(user); + } + + /// + /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. + /// + /// + /// Includes() you want. Pass multiple with flag1 | flag2 + /// + public async Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None) + { + var query = _context.Users + .Where(x => x.UserName == username); + + query = AddIncludesToQuery(query, includeFlags); + + return await query.SingleOrDefaultAsync(); + } + + /// + /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. + /// + /// + /// Includes() you want. Pass multiple with flag1 | flag2 + /// + public async Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None) + { + var query = _context.Users + .Where(x => x.Id == userId); + + query = AddIncludesToQuery(query, includeFlags); + + return await query.SingleOrDefaultAsync(); + } + + public async Task> GetAllBookmarksAsync() + { + return await _context.AppUserBookmark.ToListAsync(); + } + + public async Task GetBookmarkForPage(int page, int chapterId, int userId) + { + return await _context.AppUserBookmark + .Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId) + .SingleOrDefaultAsync(); + } + + public async Task GetBookmarkAsync(int bookmarkId) + { + return await _context.AppUserBookmark + .Where(b => b.Id == bookmarkId) + .SingleOrDefaultAsync(); + } + + private static IQueryable AddIncludesToQuery(IQueryable query, AppUserIncludes includeFlags) + { + if (includeFlags.HasFlag(AppUserIncludes.Bookmarks)) { - _context = context; - _userManager = userManager; - _mapper = mapper; + query = query.Include(u => u.Bookmarks); } - public void Update(AppUser user) + if (includeFlags.HasFlag(AppUserIncludes.Progress)) { - _context.Entry(user).State = EntityState.Modified; + query = query.Include(u => u.Progresses); } - public void Update(AppUserPreferences preferences) + if (includeFlags.HasFlag(AppUserIncludes.ReadingLists)) { - _context.Entry(preferences).State = EntityState.Modified; + query = query.Include(u => u.ReadingLists); } - public void Update(AppUserBookmark bookmark) + if (includeFlags.HasFlag(AppUserIncludes.Ratings)) { - _context.Entry(bookmark).State = EntityState.Modified; + query = query.Include(u => u.Ratings); } - public void Delete(AppUser user) - { - _context.AppUser.Remove(user); - } + return query; + } - /// - /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. - /// - /// - /// Includes() you want. Pass multiple with flag1 | flag2 - /// - public async Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None) - { - var query = _context.Users - .Where(x => x.UserName == username); - query = AddIncludesToQuery(query, includeFlags); + /// + /// This fetches the Id for a user. Use whenever you just need an ID. + /// + /// + /// + public async Task GetUserIdByUsernameAsync(string username) + { + return await _context.Users + .Where(x => x.UserName == username) + .Select(u => u.Id) + .SingleOrDefaultAsync(); + } - return await query.SingleOrDefaultAsync(); - } + /// + /// Gets an AppUser by username. Returns back Reading List and their Items. + /// + /// + /// + public async Task GetUserWithReadingListsByUsernameAsync(string username) + { + return await _context.Users + .Include(u => u.ReadingLists) + .ThenInclude(l => l.Items) + .SingleOrDefaultAsync(x => x.UserName == username); + } - /// - /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. - /// - /// - /// Includes() you want. Pass multiple with flag1 | flag2 - /// - public async Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None) - { - var query = _context.Users - .Where(x => x.Id == userId); + /// + /// Returns all Bookmarks for a given set of Ids + /// + /// + /// + public async Task> GetAllBookmarksByIds(IList bookmarkIds) + { + return await _context.AppUserBookmark + .Where(b => bookmarkIds.Contains(b.Id)) + .ToListAsync(); + } - query = AddIncludesToQuery(query, includeFlags); + public async Task> GetAdminUsersAsync() + { + return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); + } - return await query.SingleOrDefaultAsync(); - } + public async Task> GetNonAdminUsersAsync() + { + return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole); + } - public async Task GetBookmarkForPage(int page, int chapterId, int userId) - { - return await _context.AppUserBookmark - .Where(b => b.Page == page && b.ChapterId == chapterId && b.AppUserId == userId) - .SingleOrDefaultAsync(); - } + public async Task IsUserAdminAsync(AppUser user) + { + return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + } - private static IQueryable AddIncludesToQuery(IQueryable query, AppUserIncludes includeFlags) - { - if (includeFlags.HasFlag(AppUserIncludes.Bookmarks)) + public async Task GetUserRatingAsync(int seriesId, int userId) + { + return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId) + .SingleOrDefaultAsync(); + } + + public async Task GetPreferencesAsync(string username) + { + return await _context.AppUserPreferences + .Include(p => p.AppUser) + .SingleOrDefaultAsync(p => p.AppUser.UserName == username); + } + + public async Task> GetBookmarkDtosForSeries(int userId, int seriesId) + { + return await _context.AppUserBookmark + .Where(x => x.AppUserId == userId && x.SeriesId == seriesId) + .OrderBy(x => x.Page) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetBookmarkDtosForVolume(int userId, int volumeId) + { + return await _context.AppUserBookmark + .Where(x => x.AppUserId == userId && x.VolumeId == volumeId) + .OrderBy(x => x.Page) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetBookmarkDtosForChapter(int userId, int chapterId) + { + return await _context.AppUserBookmark + .Where(x => x.AppUserId == userId && x.ChapterId == chapterId) + .OrderBy(x => x.Page) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetAllBookmarkDtos(int userId) + { + return await _context.AppUserBookmark + .Where(x => x.AppUserId == userId) + .OrderBy(x => x.Page) + .AsNoTracking() + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + /// + /// Fetches the UserId by API Key. This does not include any extra information + /// + /// + /// + public async Task GetUserIdByApiKeyAsync(string apiKey) + { + return await _context.AppUser + .Where(u => u.ApiKey.Equals(apiKey)) + .Select(u => u.Id) + .SingleOrDefaultAsync(); + } + + + public async Task> GetMembersAsync() + { + return await _context.Users + .Include(x => x.Libraries) + .Include(r => r.UserRoles) + .ThenInclude(r => r.Role) + .OrderBy(u => u.UserName) + .Select(u => new MemberDto { - query = query.Include(u => u.Bookmarks); - } - - if (includeFlags.HasFlag(AppUserIncludes.Progress)) - { - query = query.Include(u => u.Progresses); - } - - if (includeFlags.HasFlag(AppUserIncludes.ReadingLists)) - { - query = query.Include(u => u.ReadingLists); - } - - if (includeFlags.HasFlag(AppUserIncludes.Ratings)) - { - query = query.Include(u => u.Ratings); - } - - return query; - } - - - /// - /// This fetches the Id for a user. Use whenever you just need an ID. - /// - /// - /// - public async Task GetUserIdByUsernameAsync(string username) - { - return await _context.Users - .Where(x => x.UserName == username) - .Select(u => u.Id) - .SingleOrDefaultAsync(); - } - - /// - /// Gets an AppUser by username. Returns back Reading List and their Items. - /// - /// - /// - public async Task GetUserWithReadingListsByUsernameAsync(string username) - { - return await _context.Users - .Include(u => u.ReadingLists) - .ThenInclude(l => l.Items) - .SingleOrDefaultAsync(x => x.UserName == username); - } - - public async Task> GetAdminUsersAsync() - { - return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); - } - - public async Task> GetNonAdminUsersAsync() - { - return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole); - } - - public async Task IsUserAdmin(AppUser user) - { - return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); - } - - public async Task GetUserRating(int seriesId, int userId) - { - return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId) - .SingleOrDefaultAsync(); - } - - public async Task GetPreferencesAsync(string username) - { - return await _context.AppUserPreferences - .Include(p => p.AppUser) - .SingleOrDefaultAsync(p => p.AppUser.UserName == username); - } - - public async Task> GetBookmarkDtosForSeries(int userId, int seriesId) - { - return await _context.AppUserBookmark - .Where(x => x.AppUserId == userId && x.SeriesId == seriesId) - .OrderBy(x => x.Page) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - public async Task> GetBookmarkDtosForVolume(int userId, int volumeId) - { - return await _context.AppUserBookmark - .Where(x => x.AppUserId == userId && x.VolumeId == volumeId) - .OrderBy(x => x.Page) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - public async Task> GetBookmarkDtosForChapter(int userId, int chapterId) - { - return await _context.AppUserBookmark - .Where(x => x.AppUserId == userId && x.ChapterId == chapterId) - .OrderBy(x => x.Page) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - public async Task> GetAllBookmarkDtos(int userId) - { - return await _context.AppUserBookmark - .Where(x => x.AppUserId == userId) - .OrderBy(x => x.Page) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - /// - /// Fetches the UserId by API Key. This does not include any extra information - /// - /// - /// - public async Task GetUserIdByApiKeyAsync(string apiKey) - { - return await _context.AppUser - .Where(u => u.ApiKey.Equals(apiKey)) - .Select(u => u.Id) - .SingleOrDefaultAsync(); - } - - - public async Task> GetMembersAsync() - { - return await _context.Users - .Include(x => x.Libraries) - .Include(r => r.UserRoles) - .ThenInclude(r => r.Role) - .OrderBy(u => u.UserName) - .Select(u => new MemberDto + Id = u.Id, + Username = u.UserName, + Created = u.Created, + LastActive = u.LastActive, + Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), + Libraries = u.Libraries.Select(l => new LibraryDto { - Id = u.Id, - Username = u.UserName, - Created = u.Created, - LastActive = u.LastActive, - Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), - Libraries = u.Libraries.Select(l => new LibraryDto - { - Name = l.Name, - Type = l.Type, - LastScanned = l.LastScanned, - Folders = l.Folders.Select(x => x.Path).ToList() - }).ToList() - }) - .AsNoTracking() - .ToListAsync(); - } + Name = l.Name, + Type = l.Type, + LastScanned = l.LastScanned, + Folders = l.Folders.Select(x => x.Path).ToList() + }).ToList() + }) + .AsNoTracking() + .ToListAsync(); } } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 339da798d..e63b469e6 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -4,205 +4,223 @@ using System.Threading.Tasks; using API.Comparators; using API.DTOs; using API.Entities; -using API.Interfaces.Repositories; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; -namespace API.Data.Repositories +namespace API.Data.Repositories; + +public interface IVolumeRepository { - public class VolumeRepository : IVolumeRepository - { - private readonly DataContext _context; - private readonly IMapper _mapper; - - public VolumeRepository(DataContext context, IMapper mapper) - { - _context = context; - _mapper = mapper; - } - - public void Add(Volume volume) - { - _context.Volume.Add(volume); - } - - public void Update(Volume volume) - { - _context.Entry(volume).State = EntityState.Modified; - } - - public void Remove(Volume volume) - { - _context.Volume.Remove(volume); - } - - /// - /// Returns a list of non-tracked files for a given volume. - /// - /// - /// - public async Task> GetFilesForVolume(int volumeId) - { - return await _context.Chapter - .Where(c => volumeId == c.VolumeId) - .Include(c => c.Files) - .SelectMany(c => c.Files) - .AsNoTracking() - .ToListAsync(); - } - - /// - /// Returns the cover image file for the given volume - /// - /// - /// - public async Task GetVolumeCoverImageAsync(int volumeId) - { - return await _context.Volume - .Where(v => v.Id == volumeId) - .Select(v => v.CoverImage) - .AsNoTracking() - .SingleOrDefaultAsync(); - } - - /// - /// Returns all chapter Ids belonging to a list of Volume Ids - /// - /// - /// - public async Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds) - { - return await _context.Chapter - .Where(c => volumeIds.Contains(c.VolumeId)) - .Select(c => c.Id) - .ToListAsync(); - } - - /// - /// Returns all volumes that contain a seriesId in passed array. - /// - /// - /// - public async Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false) - { - var query = _context.Volume - .Where(v => seriesIds.Contains(v.SeriesId)); - - if (includeChapters) - { - query = query.Include(v => v.Chapters); - } - return await query.ToListAsync(); - } - - /// - /// Returns an individual Volume including Chapters and Files and Reading Progress for a given volumeId - /// - /// - /// - /// - public async Task GetVolumeDtoAsync(int volumeId, int userId) - { - var volume = await _context.Volume - .Where(vol => vol.Id == volumeId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) - .ProjectTo(_mapper.ConfigurationProvider) - .SingleAsync(vol => vol.Id == volumeId); - - var volumeList = new List() {volume}; - await AddVolumeModifiers(userId, volumeList); - - return volumeList[0]; - } - - /// - /// Returns the full Volumes including Chapters and Files for a given series - /// - /// - /// - public async Task> GetVolumes(int seriesId) - { - return await _context.Volume - .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) - .OrderBy(vol => vol.Number) - .ToListAsync(); - } - - /// - /// Returns a single volume with Chapter and Files - /// - /// - /// - public async Task GetVolumeAsync(int volumeId) - { - return await _context.Volume - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) - .SingleOrDefaultAsync(vol => vol.Id == volumeId); - } - - - /// - /// Returns all volumes for a given series with progress information attached. Includes all Chapters as well. - /// - /// - /// - /// - public async Task> GetVolumesDtoAsync(int seriesId, int userId) - { - var volumes = await _context.Volume - .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Chapters) - .OrderBy(volume => volume.Number) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .ToListAsync(); - - await AddVolumeModifiers(userId, volumes); - SortSpecialChapters(volumes); - - return volumes; - } - - public async Task GetVolumeByIdAsync(int volumeId) - { - return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); - } - - - private static void SortSpecialChapters(IEnumerable volumes) - { - var sorter = new NaturalSortComparer(); - foreach (var v in volumes.Where(vDto => vDto.Number == 0)) - { - v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList(); - } - } - - - private async Task AddVolumeModifiers(int userId, IReadOnlyCollection volumes) - { - var volIds = volumes.Select(s => s.Id); - var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId)) - .AsNoTracking() - .ToListAsync(); - - foreach (var v in volumes) - { - foreach (var c in v.Chapters) - { - c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); - } - - v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); - } - } - - - } + void Add(Volume volume); + void Update(Volume volume); + void Remove(Volume volume); + Task> GetFilesForVolume(int volumeId); + Task GetVolumeCoverImageAsync(int volumeId); + Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); + Task> GetVolumesDtoAsync(int seriesId, int userId); + Task GetVolumeAsync(int volumeId); + Task GetVolumeDtoAsync(int volumeId, int userId); + Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); + Task> GetVolumes(int seriesId); + Task GetVolumeByIdAsync(int volumeId); +} +public class VolumeRepository : IVolumeRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public VolumeRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Add(Volume volume) + { + _context.Volume.Add(volume); + } + + public void Update(Volume volume) + { + _context.Entry(volume).State = EntityState.Modified; + } + + public void Remove(Volume volume) + { + _context.Volume.Remove(volume); + } + + /// + /// Returns a list of non-tracked files for a given volume. + /// + /// + /// + public async Task> GetFilesForVolume(int volumeId) + { + return await _context.Chapter + .Where(c => volumeId == c.VolumeId) + .Include(c => c.Files) + .SelectMany(c => c.Files) + .AsNoTracking() + .ToListAsync(); + } + + /// + /// Returns the cover image file for the given volume + /// + /// + /// + public async Task GetVolumeCoverImageAsync(int volumeId) + { + return await _context.Volume + .Where(v => v.Id == volumeId) + .Select(v => v.CoverImage) + .AsNoTracking() + .SingleOrDefaultAsync(); + } + + /// + /// Returns all chapter Ids belonging to a list of Volume Ids + /// + /// + /// + public async Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds) + { + return await _context.Chapter + .Where(c => volumeIds.Contains(c.VolumeId)) + .Select(c => c.Id) + .ToListAsync(); + } + + /// + /// Returns all volumes that contain a seriesId in passed array. + /// + /// + /// Include chapter entities + /// + public async Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false) + { + var query = _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)); + + if (includeChapters) + { + query = query.Include(v => v.Chapters); + } + return await query.ToListAsync(); + } + + /// + /// Returns an individual Volume including Chapters and Files and Reading Progress for a given volumeId + /// + /// + /// + /// + public async Task GetVolumeDtoAsync(int volumeId, int userId) + { + var volume = await _context.Volume + .Where(vol => vol.Id == volumeId) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleAsync(vol => vol.Id == volumeId); + + var volumeList = new List() {volume}; + await AddVolumeModifiers(userId, volumeList); + + return volumeList[0]; + } + + /// + /// Returns the full Volumes including Chapters and Files for a given series + /// + /// + /// + public async Task> GetVolumes(int seriesId) + { + return await _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) + .OrderBy(vol => vol.Number) + .ToListAsync(); + } + + /// + /// Returns a single volume with Chapter and Files + /// + /// + /// + public async Task GetVolumeAsync(int volumeId) + { + return await _context.Volume + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) + .SingleOrDefaultAsync(vol => vol.Id == volumeId); + } + + + /// + /// Returns all volumes for a given series with progress information attached. Includes all Chapters as well. + /// + /// + /// + /// + public async Task> GetVolumesDtoAsync(int seriesId, int userId) + { + var volumes = await _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.People) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Tags) + .OrderBy(volume => volume.Number) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .AsSplitQuery() + .ToListAsync(); + + await AddVolumeModifiers(userId, volumes); + SortSpecialChapters(volumes); + + return volumes; + } + + public async Task GetVolumeByIdAsync(int volumeId) + { + return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); + } + + + private static void SortSpecialChapters(IEnumerable volumes) + { + foreach (var v in volumes.Where(vDto => vDto.Number == 0)) + { + v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList(); + } + } + + + private async Task AddVolumeModifiers(int userId, IReadOnlyCollection volumes) + { + var volIds = volumes.Select(s => s.Id); + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId)) + .AsNoTracking() + .ToListAsync(); + + foreach (var v in volumes) + { + foreach (var c in v.Chapters) + { + c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); + } + + v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); + } + } + + } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 9cfbaeaa4..b7590e168 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -8,6 +8,7 @@ using API.Entities; using API.Entities.Enums; using API.Services; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -15,6 +16,11 @@ namespace API.Data { public static class Seed { + /// + /// Generated on Startup. Seed.SeedSettings must run before + /// + public static IList DefaultSettings; + public static async Task SeedRoles(RoleManager roleManager) { var roles = typeof(PolicyConstants) @@ -35,16 +41,16 @@ namespace API.Data } } - public static async Task SeedSettings(DataContext context) + public static async Task SeedSettings(DataContext context, IDirectoryService directoryService) { await context.Database.EnsureCreatedAsync(); - IList defaultSettings = new List() + DefaultSettings = new List() { - new () {Key = ServerSettingKey.CacheDirectory, Value = DirectoryService.CacheDirectory}, + 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 = "weekly"}, + 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"}, @@ -52,9 +58,11 @@ namespace API.Data 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}, }; - foreach (var defaultSetting in defaultSettings) + foreach (var defaultSetting in DefaultSettings) { var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); if (existing == null) @@ -71,7 +79,7 @@ namespace API.Data 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; + directoryService.CacheDirectory + string.Empty; context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = DirectoryService.BackupDirectory + string.Empty; diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index a1f797188..82046ca2a 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -1,84 +1,104 @@ using System.Threading.Tasks; using API.Data.Repositories; using API.Entities; -using API.Interfaces; -using API.Interfaces.Repositories; using AutoMapper; using Microsoft.AspNetCore.Identity; -namespace API.Data +namespace API.Data; + +public interface IUnitOfWork { - public class UnitOfWork : IUnitOfWork + ISeriesRepository SeriesRepository { get; } + IUserRepository UserRepository { get; } + ILibraryRepository LibraryRepository { get; } + IVolumeRepository VolumeRepository { get; } + ISettingsRepository SettingsRepository { get; } + IAppUserProgressRepository AppUserProgressRepository { get; } + ICollectionTagRepository CollectionTagRepository { get; } + IChapterRepository ChapterRepository { get; } + IReadingListRepository ReadingListRepository { get; } + ISeriesMetadataRepository SeriesMetadataRepository { get; } + IPersonRepository PersonRepository { get; } + IGenreRepository GenreRepository { get; } + ITagRepository TagRepository { get; } + bool Commit(); + Task CommitAsync(); + bool HasChanges(); + bool Rollback(); + Task RollbackAsync(); +} +public class UnitOfWork : IUnitOfWork +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + private readonly UserManager _userManager; + + public UnitOfWork(DataContext context, IMapper mapper, UserManager userManager) { - private readonly DataContext _context; - private readonly IMapper _mapper; - private readonly UserManager _userManager; + _context = context; + _mapper = mapper; + _userManager = userManager; + } - public UnitOfWork(DataContext context, IMapper mapper, UserManager userManager) - { - _context = context; - _mapper = mapper; - _userManager = userManager; - } + public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper); + public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper); + public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); - public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper); - public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper); - public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); + public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); - public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); + public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); - public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); + public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); + public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); + public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper); + public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); + public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context); + public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper); + public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper); + public ITagRepository TagRepository => new TagRepository(_context, _mapper); - public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); - public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); - public IFileRepository FileRepository => new FileRepository(_context); - public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper); - public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); - public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context); + /// + /// Commits changes to the DB. Completes the open transaction. + /// + /// + public bool Commit() + { + return _context.SaveChanges() > 0; + } + /// + /// Commits changes to the DB. Completes the open transaction. + /// + /// + public async Task CommitAsync() + { + return await _context.SaveChangesAsync() > 0; + } - /// - /// Commits changes to the DB. Completes the open transaction. - /// - /// - public bool Commit() - { - return _context.SaveChanges() > 0; - } - /// - /// Commits changes to the DB. Completes the open transaction. - /// - /// - public async Task CommitAsync() - { - return await _context.SaveChangesAsync() > 0; - } + /// + /// Is the DB Context aware of Changes in loaded entities + /// + /// + public bool HasChanges() + { + return _context.ChangeTracker.HasChanges(); + } - /// - /// Is the DB Context aware of Changes in loaded entities - /// - /// - public bool HasChanges() - { - return _context.ChangeTracker.HasChanges(); - } - - /// - /// Rollback transaction - /// - /// - public async Task RollbackAsync() - { - await _context.DisposeAsync(); - return true; - } - /// - /// Rollback transaction - /// - /// - public bool Rollback() - { - _context.Dispose(); - return true; - } + /// + /// Rollback transaction + /// + /// + public async Task RollbackAsync() + { + await _context.DisposeAsync(); + return true; + } + /// + /// Rollback transaction + /// + /// + public bool Rollback() + { + _context.Dispose(); + return true; } } diff --git a/API/Dockerfile b/API/Dockerfile index 4289aaa3d..ce607a02f 100644 --- a/API/Dockerfile +++ b/API/Dockerfile @@ -1,5 +1,5 @@ #This Dockerfile pulls the latest git commit and builds Kavita from source -FROM mcr.microsoft.com/dotnet/sdk:5.0-focal AS builder +FROM mcr.microsoft.com/dotnet/sdk:6.0-focal AS builder ENV DEBIAN_FRONTEND=noninteractive ARG TARGETPLATFORM @@ -37,4 +37,4 @@ EXPOSE 5000 WORKDIR /kavita ENTRYPOINT ["/bin/bash"] -CMD ["/entrypoint.sh"] \ No newline at end of file +CMD ["/entrypoint.sh"] diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index cfb9aa29a..81a26a08b 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -13,6 +13,11 @@ namespace API.Entities public int SeriesId { get; set; } public int ChapterId { get; set; } + /// + /// Filename in the Bookmark Directory + /// + public string FileName { get; set; } = string.Empty; + // Relationships [JsonIgnore] diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index ef12de8ce..e6e7926e3 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -41,6 +41,50 @@ namespace API.Entities /// 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; + + + /// + /// 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; } diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index ee966cafc..b38960f89 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.Entities.Metadata; using Microsoft.EntityFrameworkCore; namespace API.Entities diff --git a/API/Entities/Enums/AgeRating.cs b/API/Entities/Enums/AgeRating.cs new file mode 100644 index 000000000..ddb288ee1 --- /dev/null +++ b/API/Entities/Enums/AgeRating.cs @@ -0,0 +1,43 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +/// +/// Represents Age Rating for content. +/// +/// Based on ComicInfo.xml v2.1 https://github.com/anansi-project/comicinfo/blob/main/drafts/v2.1/ComicInfo.xsd +public enum AgeRating +{ + [Description("Unknown")] + Unknown = 0, + [Description("Rating Pending")] + RatingPending = 1, + [Description("Early Childhood")] + EarlyChildhood = 2, + [Description("Everyone")] + Everyone = 3, + [Description("G")] + G = 4, + [Description("Everyone 10+")] + Everyone10Plus = 5, + [Description("PG")] + PG = 6, + [Description("Kids to Adults")] + KidsToAdults = 7, + [Description("Teen")] + Teen = 8, + [Description("MA 15+")] + Mature15Plus = 9, + [Description("Mature 17+")] + Mature17Plus = 10, + [Description("M")] + Mature = 11, + [Description("R18+")] + R18Plus = 12, + [Description("Adults Only 18+")] + AdultsOnly = 13, + [Description("X18+")] + X18Plus = 14 + + +} diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index 23bb8df25..dd2c83b92 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -4,10 +4,19 @@ namespace API.Entities.Enums { 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, } diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs index 47e60721b..714e1d534 100644 --- a/API/Entities/Enums/PersonRole.cs +++ b/API/Entities/Enums/PersonRole.cs @@ -5,15 +5,31 @@ /// /// Another role, not covered by other types /// - Other = 0, - /// - /// Author - /// - Author = 1, + Other = 1, /// /// Artist /// - Artist = 2, - + //Artist = 2, + /// + /// 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 + + } -} \ No newline at end of file +} diff --git a/API/Entities/Enums/PublicationStatus.cs b/API/Entities/Enums/PublicationStatus.cs new file mode 100644 index 000000000..4d8124391 --- /dev/null +++ b/API/Entities/Enums/PublicationStatus.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +public enum PublicationStatus +{ + /// + /// Default Status. Publication is currently in progress + /// + [Description("On Going")] + OnGoing = 0, + /// + /// Series is on temp or indefinite Hiatus + /// + [Description("Hiatus")] + Hiatus = 1, + /// + /// Publication has finished releasing + /// + [Description("Completed")] + Completed = 2 + +} diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 3f097c675..80484d693 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -58,7 +58,18 @@ namespace API.Entities.Enums /// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files. /// [Description("InstallId")] - InstallId = 10 + 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, } } diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs index 9490c03e7..447f14943 100644 --- a/API/Entities/Genre.cs +++ b/API/Entities/Genre.cs @@ -1,22 +1,18 @@ -using System.ComponentModel.DataAnnotations; -using API.Entities.Interfaces; +using System.Collections.Generic; +using API.Entities.Metadata; +using Microsoft.EntityFrameworkCore; namespace API.Entities { - public class Genre : IHasConcurrencyToken + [Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] + public class Genre { public int Id { get; set; } - public string Name { get; set; } - // MetadataUpdate add ProviderId + public string Title { get; set; } + public string NormalizedTitle { get; set; } + public bool ExternalTag { get; set; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } - - /// - public void OnSavingChanges() - { - RowVersion++; - } + public ICollection SeriesMetadatas { get; set; } + public ICollection Chapters { get; set; } } } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 2865178c7..8cd99f3e1 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -23,24 +23,17 @@ namespace API.Entities /// /// Last time underlying file was modified /// + /// This gets updated anytime the file is scanned public DateTime LastModified { get; set; } + // Relationship Mapping public Chapter Chapter { get; set; } public int ChapterId { get; set; } - // Methods - /// - /// If the File on disk's last modified time is after what is stored in MangaFile - /// - /// - public bool HasFileBeenModified() - { - return File.GetLastWriteTime(FilePath) > LastModified; - } /// - /// Updates the Last Modified time of the underlying file + /// Updates the Last Modified time of the underlying file to the LastWriteTime /// public void UpdateLastModified() { diff --git a/API/Entities/Metadata/ChapterMetadata.cs b/API/Entities/Metadata/ChapterMetadata.cs new file mode 100644 index 000000000..ef4836c23 --- /dev/null +++ b/API/Entities/Metadata/ChapterMetadata.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace API.Entities.Metadata +{ + /// + /// Has a 1-to-1 relationship with a Chapter. Represents metadata about a chapter. + /// + public class ChapterMetadata + { + public int Id { get; set; } + + /// + /// Chapter title + /// + /// This should not be confused with Chapter.Title which is used for special filenames. + public string Title { get; set; } = string.Empty; + public string Year { get; set; } // Only time I can think this will be more than 1 year is for a volume which will be a spread + public string StoryArc { get; set; } // This might be a list + + /// + /// All people attached at a Chapter level. Usually Comics will have different people per issue. + /// + public ICollection People { get; set; } = new List(); + + + + + + // Relationships + public Chapter Chapter { get; set; } + public int ChapterId { get; set; } + + } +} diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs new file mode 100644 index 000000000..54ea8ccc0 --- /dev/null +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using API.Entities.Enums; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities.Metadata +{ + [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] + public class SeriesMetadata : IHasConcurrencyToken + { + public int Id { get; set; } + + public string Summary { 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(); + + /// + /// 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 in the series + /// + public int Count { get; set; } = 0; + public PublicationStatus PublicationStatus { get; set; } + + // Relationship + public Series Series { get; set; } + public int SeriesId { get; set; } + + /// + [ConcurrencyCheck] + public uint RowVersion { get; private set; } + + /// + public void OnSavingChanges() + { + RowVersion++; + } + } +} diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs index c9f182215..785a037bd 100644 --- a/API/Entities/Person.cs +++ b/API/Entities/Person.cs @@ -1,23 +1,24 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; using API.Entities.Enums; -using API.Entities.Interfaces; +using API.Entities.Metadata; namespace API.Entities { - public class Person : IHasConcurrencyToken + 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; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } - - /// - public void OnSavingChanges() - { - RowVersion++; - } + // Relationships + public ICollection SeriesMetadatas { get; set; } + public ICollection ChapterMetadatas { get; set; } } } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index de02ad427..a532028bb 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Metadata; using Microsoft.EntityFrameworkCore; namespace API.Entities @@ -30,10 +31,6 @@ namespace API.Entities /// Original Name on disk. Not exposed to UI. /// public string OriginalName { get; set; } - /// - /// Summary information related to the Series - /// - public string Summary { get; set; } // NOTE: Migrate into SeriesMetdata (with Metadata update) public DateTime Created { get; set; } public DateTime LastModified { get; set; } /// @@ -56,6 +53,8 @@ namespace API.Entities public MangaFormat Format { get; set; } = MangaFormat.Unknown; public SeriesMetadata Metadata { get; set; } + public ICollection Ratings { get; set; } = new List(); + public ICollection Progress { get; set; } = new List(); // Relationships public List Volumes { get; set; } diff --git a/API/Entities/SeriesMetadata.cs b/API/Entities/SeriesMetadata.cs deleted file mode 100644 index f86c5430e..000000000 --- a/API/Entities/SeriesMetadata.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using API.Entities.Interfaces; -using Microsoft.EntityFrameworkCore; - -namespace API.Entities -{ - [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] - public class SeriesMetadata : IHasConcurrencyToken - { - public int Id { get; set; } - - public ICollection CollectionTags { get; set; } - - // Relationship - public Series Series { get; set; } - public int SeriesId { get; set; } - - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } - - /// - public void OnSavingChanges() - { - RowVersion++; - } - } -} diff --git a/API/Entities/Tag.cs b/API/Entities/Tag.cs new file mode 100644 index 000000000..5d1631760 --- /dev/null +++ b/API/Entities/Tag.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using API.Entities.Metadata; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities; + +[Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] +public class Tag +{ + 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; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index cd5a621fc..fd0c5f5ca 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -1,7 +1,6 @@ -using API.Data; +using System.IO.Abstractions; +using API.Data; using API.Helpers; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; using API.Services.Tasks; using API.SignalR.Presence; @@ -36,8 +35,14 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSqLite(config, env); @@ -51,6 +56,7 @@ namespace API.Extensions services.AddDbContext(options => { options.UseSqlite(config.GetConnectionString("DefaultConnection")); + options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(env.IsDevelopment() || Configuration.LogLevel.Equals("Debug")); }); } diff --git a/API/Extensions/DateTimeExtensions.cs b/API/Extensions/DateTimeExtensions.cs new file mode 100644 index 000000000..da205608c --- /dev/null +++ b/API/Extensions/DateTimeExtensions.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.Extensions; + +public static class DateTimeExtensions +{ + /// + /// Truncates a DateTime to a specified resolution. + /// A convenient source for resolution is TimeSpan.TicksPerXXXX constants. + /// + /// The DateTime object to truncate + /// e.g. to round to nearest second, TimeSpan.TicksPerSecond + /// Truncated DateTime + public static DateTime Truncate(this DateTime date, long resolution) + { + return new DateTime(date.Ticks - (date.Ticks % resolution), date.Kind); + } +} diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs deleted file mode 100644 index b92901046..000000000 --- a/API/Extensions/DirectoryInfoExtensions.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.IO; -using System.Linq; -using API.Comparators; - -namespace API.Extensions -{ - public static class DirectoryInfoExtensions - { - private static readonly NaturalSortComparer Comparer = new NaturalSortComparer(); - public static void Empty(this DirectoryInfo directory) - { - // NOTE: We have this in DirectoryService.Empty(), do we need this here? - foreach(FileInfo file in directory.EnumerateFiles()) file.Delete(); - foreach(DirectoryInfo subDirectory in directory.EnumerateDirectories()) subDirectory.Delete(true); - } - - public static void RemoveNonImages(this DirectoryInfo directory) - { - foreach (var file in directory.EnumerateFiles()) - { - if (!Parser.Parser.IsImage(file.FullName)) - { - file.Delete(); - } - } - } - - /// - /// 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 - /// - /// - public static void Flatten(this DirectoryInfo directory) - { - var index = 0; - FlattenDirectory(directory, directory, ref index); - } - - private static void FlattenDirectory(DirectoryInfo root, DirectoryInfo directory, ref int directoryIndex) - { - if (!root.FullName.Equals(directory.FullName)) - { - var fileIndex = 1; - - foreach (var file in directory.EnumerateFiles().OrderBy(file => file.FullName, Comparer)) - { - if (file.Directory == null) continue; - var paddedIndex = 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}_{Parser.Parser.PadZeros(fileIndex + "")}{file.Extension}"; - var newPath = Path.Join(root.FullName, newName); - if (!File.Exists(newPath)) file.MoveTo(newPath); - fileIndex++; - } - - directoryIndex++; - } - - var sort = new NaturalSortComparer(); - foreach (var subDirectory in directory.EnumerateDirectories().OrderBy(d => d.FullName, sort)) - { - FlattenDirectory(root, subDirectory, ref directoryIndex); - } - } - } -} diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index b8293436e..c1dd412e2 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -1,21 +1,30 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; namespace API.Extensions { public static class EnumerableExtensions { - public static IEnumerable DistinctBy - (this IEnumerable source, Func keySelector) + 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 seenKeys = new HashSet(); - foreach (var element in source) - { - if (seenKeys.Add(keySelector(element))) - { - yield return element; - } - } + var maxDigits = items + .SelectMany(i => Regex.Matches(selector(i)) + .Select(digitChunk => (int?)digitChunk.Value.Length)) + .Max() ?? 0; + + return items.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture); } } -} \ No newline at end of file +} diff --git a/API/Extensions/FilterDtoExtensions.cs b/API/Extensions/FilterDtoExtensions.cs index 7e5a818ec..b0d9f80f6 100644 --- a/API/Extensions/FilterDtoExtensions.cs +++ b/API/Extensions/FilterDtoExtensions.cs @@ -11,15 +11,12 @@ namespace API.Extensions public static IList GetSqlFilter(this FilterDto filter) { - var format = filter.MangaFormat; - if (format != null) + if (filter.Formats == null || filter.Formats.Count == 0) { - return new List() - { - (MangaFormat) format - }; + return AllFormats; } - return AllFormats; + + return filter.Formats; } } } diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index 975cbde5f..68655f43d 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -1,5 +1,7 @@ using System.IO; using System.Linq; +using System.Runtime.Intrinsics.Arm; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using API.Helpers; @@ -30,7 +32,8 @@ namespace API.Extensions public static void AddCacheHeader(this HttpResponse response, byte[] content) { if (content == null || content.Length <= 0) return; - using var sha1 = new System.Security.Cryptography.SHA256CryptoServiceProvider(); + using var sha1 = SHA256.Create(); + response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(content).Select(x => x.ToString("X2")))); } @@ -43,7 +46,7 @@ namespace API.Extensions { if (filename == null || filename.Length <= 0) return; var hashContent = filename + File.GetLastWriteTimeUtc(filename); - using var sha1 = new System.Security.Cryptography.SHA256CryptoServiceProvider(); + using var sha1 = SHA256.Create(); response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")))); } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 9b32c9320..1d0638e67 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -17,8 +17,13 @@ namespace API.Extensions { services.AddIdentityCore(opt => { - // Change password / signin requirements here 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; }) .AddRoles() .AddRoleManager>() diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 0ea098b20..31a65c819 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -31,15 +31,15 @@ namespace API.Extensions : infos.Any(v => v.Chapters == chapter.Range); } - /// - /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos - /// - /// - /// - public static MangaFormat GetFormat(this IList infos) - { - if (infos.Count == 0) return MangaFormat.Unknown; - return infos.DistinctBy(x => x.Format).First().Format; - } + // /// + // /// Returns the MangaFormat that is common to all the files. Unknown if files are mixed (should never happen) or no infos + // /// + // /// + // /// + // public static MangaFormat GetFormat(this IList infos) + // { + // if (infos.Count == 0) return MangaFormat.Unknown; + // return infos.DistinctBy(x => x.Format).First().Format; + // } } } diff --git a/API/Extensions/PathExtensions.cs b/API/Extensions/PathExtensions.cs new file mode 100644 index 000000000..f45787d1a --- /dev/null +++ b/API/Extensions/PathExtensions.cs @@ -0,0 +1,14 @@ +using System.IO; + +namespace API.Extensions; + +public static class PathExtensions +{ + public static string GetFullPathWithoutExtension(this string filepath) + { + if (string.IsNullOrEmpty(filepath)) return filepath; + var extension = Path.GetExtension(filepath); + if (string.IsNullOrEmpty(extension)) return filepath; + return Path.GetFullPath(filepath.Replace(extension, string.Empty)); + } +} diff --git a/API/Extensions/SeriesExtensions.cs b/API/Extensions/SeriesExtensions.cs index 30a7b1b5b..cd3254e34 100644 --- a/API/Extensions/SeriesExtensions.cs +++ b/API/Extensions/SeriesExtensions.cs @@ -9,7 +9,7 @@ namespace API.Extensions public static class SeriesExtensions { /// - /// Checks against all the name variables of the Series if it matches anything in the list. + /// Checks against all the name variables of the Series if it matches anything in the list. This does not check against format. /// /// /// diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs new file mode 100644 index 000000000..b172c0e46 --- /dev/null +++ b/API/Extensions/StringExtensions.cs @@ -0,0 +1,13 @@ +using System.Text.RegularExpressions; + +namespace API.Extensions; + +public static class StringExtensions +{ + private static readonly Regex SentenceCaseRegex = new Regex(@"(^[a-z])|\.\s+(.)", RegexOptions.ExplicitCapture | RegexOptions.Compiled); + + public static string SentenceCase(this string value) + { + return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper()); + } +} diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index 4752cce5b..97126e28f 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Comparators; using API.Entities; using API.Entities.Enums; @@ -7,11 +8,12 @@ namespace API.Extensions { public static class VolumeListExtensions { - public static Volume FirstWithChapters(this IList volumes, bool inBookSeries) + public static Volume FirstWithChapters(this IEnumerable volumes, bool inBookSeries) { return inBookSeries ? volumes.FirstOrDefault(v => v.Chapters.Any()) - : volumes.FirstOrDefault(v => v.Chapters.Any() && (v.Number == 1)); + : volumes.OrderBy(v => v.Number, new ChapterSortComparer()) + .FirstOrDefault(v => v.Chapters.Any()); } /// diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 74bd8d57c..0b3f89161 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -2,10 +2,13 @@ using System.Linq; using API.DTOs; using API.DTOs.CollectionTags; +using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Settings; using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; using API.Helpers.Converters; using AutoMapper; @@ -21,15 +24,101 @@ namespace API.Helpers CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Writers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + .ForMember(dest => dest.CoverArtist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Colorist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inker, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterer, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Penciller, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Publisher, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Editor, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))) + .ForMember(dest => dest.Translators, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator))); 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.CoverArtist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Colorist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inker, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterer, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Penciller, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Publisher, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Editor, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + + + CreateMap(); @@ -55,8 +144,10 @@ namespace API.Helpers CreateMap(); + CreateMap, ServerSettingDto>() .ConvertUsing(); + } } } diff --git a/API/Helpers/CacheHelper.cs b/API/Helpers/CacheHelper.cs new file mode 100644 index 000000000..80a63490d --- /dev/null +++ b/API/Helpers/CacheHelper.cs @@ -0,0 +1,73 @@ +using System; +using API.Entities; +using API.Entities.Interfaces; +using API.Services; + +namespace API.Helpers; + +public interface ICacheHelper +{ + bool ShouldUpdateCoverImage(string coverPath, MangaFile firstFile, DateTime chapterCreated, + bool forceUpdate = false, + bool isCoverLocked = false); + + bool CoverImageExists(string path); + + bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile); + +} + +public class CacheHelper : ICacheHelper +{ + private readonly IFileService _fileService; + + public CacheHelper(IFileService fileService) + { + _fileService = fileService; + } + + /// + /// Determines whether an entity should regenerate cover image. + /// + /// If a cover image is locked but the underlying file has been deleted, this will allow regenerating. + /// This should just be the filename, no path information + /// + /// If the user has told us to force the refresh + /// If cover has been locked by user. This will force false + /// + public bool ShouldUpdateCoverImage(string coverPath, MangaFile firstFile, DateTime chapterCreated, bool forceUpdate = false, + bool isCoverLocked = false) + { + + var fileExists = !string.IsNullOrEmpty(coverPath) && _fileService.Exists(coverPath); + if (isCoverLocked && fileExists) return false; + if (forceUpdate) return true; + if (firstFile == null) return true; + return (_fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)) || !fileExists; + } + + /// + /// Has the file been modified since last scan or is user forcing an update + /// + /// + /// + /// + /// + public bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile) + { + return firstFile != null && + (!forceUpdate && + !(_fileService.HasFileBeenModifiedSince(firstFile.FilePath, chapter.Created) + || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified))); + } + + /// + /// Determines if a given coverImage path exists + /// + /// + /// + public bool CoverImageExists(string path) + { + return !string.IsNullOrEmpty(path) && _fileService.Exists(path); + } +} diff --git a/API/Helpers/Converters/CronConverter.cs b/API/Helpers/Converters/CronConverter.cs index cacf018b1..32df46753 100644 --- a/API/Helpers/Converters/CronConverter.cs +++ b/API/Helpers/Converters/CronConverter.cs @@ -26,16 +26,16 @@ namespace API.Helpers.Converters return destination; } - public static string ConvertFromCronNotation(string cronNotation) - { - var destination = string.Empty; - destination = cronNotation.ToLower() switch - { - "0 0 31 2 *" => "disabled", - _ => destination - }; - - return destination; - } + // public static string ConvertFromCronNotation(string cronNotation) + // { + // var destination = string.Empty; + // destination = cronNotation.ToLower() switch + // { + // "0 0 31 2 *" => "disabled", + // _ => destination + // }; + // + // return destination; + // } } -} \ No newline at end of file +} diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 86ed6235e..50a839010 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -42,6 +42,9 @@ namespace API.Helpers.Converters case ServerSettingKey.BaseUrl: destination.BaseUrl = row.Value; break; + case ServerSettingKey.BookmarkDirectory: + destination.BookmarksDirectory = row.Value; + break; } } diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs new file mode 100644 index 000000000..aa465f58e --- /dev/null +++ b/API/Helpers/GenreHelper.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; + +namespace API.Helpers; + +public static class GenreHelper +{ + /// + /// + /// + /// + /// + /// + /// + public static void UpdateGenre(ICollection allGenres, IEnumerable names, bool isExternal, Action action) + { + foreach (var name in names) + { + if (string.IsNullOrEmpty(name.Trim())) continue; + + var normalizedName = Parser.Parser.Normalize(name); + var genre = allGenres.FirstOrDefault(p => + p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + if (genre == null) + { + genre = DbFactory.Genre(name, false); + allGenres.Add(genre); + } + + action(genre); + } + } + + public static void KeepOnlySameGenreBetweenLists(ICollection existingGenres, ICollection removeAllExcept, Action action = null) + { + var existing = existingGenres.ToList(); + foreach (var genre in existing) + { + var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle)); + if (existingPerson != null) continue; + existingGenres.Remove(genre); + action?.Invoke(genre); + } + + } + + /// + /// Adds the genre to the list if it's not already in there. This will ignore the ExternalTag. + /// + /// + /// + public static void AddGenreIfNotExists(ICollection metadataGenres, Genre genre) + { + var existingGenre = metadataGenres.FirstOrDefault(p => + p.NormalizedTitle == Parser.Parser.Normalize(genre.Title)); + if (existingGenre == null) + { + metadataGenres.Add(genre); + } + } +} diff --git a/API/Helpers/ParserInfoHelpers.cs b/API/Helpers/ParserInfoHelpers.cs new file mode 100644 index 000000000..48421cd70 --- /dev/null +++ b/API/Helpers/ParserInfoHelpers.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Parser; +using API.Services.Tasks.Scanner; + +namespace API.Helpers; + +public static class ParserInfoHelpers +{ + /// + /// Checks each parser info to see if there is a name match and if so, checks if the format matches the Series object. + /// This accounts for if the Series has an Unknown type and if so, considers it matching. + /// + /// + /// + /// + public static bool SeriesHasMatchingParserInfoFormat(Series series, + Dictionary> parsedSeries) + { + var format = MangaFormat.Unknown; + foreach (var pSeries in parsedSeries.Keys) + { + var name = pSeries.Name; + var normalizedName = Parser.Parser.Normalize(name); + + //if (series.NameInParserInfo(pSeries.)) + if (normalizedName == series.NormalizedName || + normalizedName == Parser.Parser.Normalize(series.Name) || + name == series.Name || name == series.LocalizedName || + name == series.OriginalName || + normalizedName == Parser.Parser.Normalize(series.OriginalName)) + { + format = pSeries.Format; + if (format == series.Format) + { + return true; + } + } + } + + if (series.Format == MangaFormat.Unknown && format != MangaFormat.Unknown) + { + return true; + } + + return format == series.Format; + } +} diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs new file mode 100644 index 000000000..36d544d4d --- /dev/null +++ b/API/Helpers/PersonHelper.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; +using API.Entities.Enums; + +namespace API.Helpers; + +public static class PersonHelper +{ + /// + /// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and + /// add an entry. For each person in name, the callback will be executed. + /// + /// This is used to add new people to a list without worrying about duplicating rows in the DB + /// + /// + /// + /// + public static void UpdatePeople(ICollection allPeople, IEnumerable names, PersonRole role, Action action) + { + var allPeopleTypeRole = allPeople.Where(p => p.Role == role).ToList(); + + foreach (var name in names) + { + var normalizedName = Parser.Parser.Normalize(name); + var person = allPeopleTypeRole.FirstOrDefault(p => + p.NormalizedName.Equals(normalizedName)); + if (person == null) + { + person = DbFactory.Person(name, role); + allPeople.Add(person); + } + + action(person); + } + } + + /// + /// Remove people on a list for a given role + /// + /// Used to remove before we update/add new people + /// Existing people on Entity + /// People from metadata + /// Role to filter on + /// Callback which will be executed for each person removed + public static void RemovePeople(ICollection existingPeople, IEnumerable people, PersonRole role, Action action = null) + { + var normalizedPeople = people.Select(Parser.Parser.Normalize).ToList(); + foreach (var person in normalizedPeople) + { + var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName)); + if (existingPerson == null) continue; + + existingPeople.Remove(existingPerson); + action?.Invoke(existingPerson); + } + + } + + /// + /// Removes all people that are not present in the removeAllExcept list. + /// + /// + /// + /// Callback for all entities that was removed + public static void KeepOnlySamePeopleBetweenLists(ICollection existingPeople, ICollection removeAllExcept, Action action = null) + { + var existing = existingPeople.ToList(); + foreach (var person in existing) + { + var existingPerson = removeAllExcept.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); + if (existingPerson == null) + { + existingPeople.Remove(person); + action?.Invoke(person); + } + } + } + + /// + /// Adds the person to the list if it's not already in there + /// + /// + /// + public static void AddPersonIfNotExists(ICollection metadataPeople, Person person) + { + var existingPerson = metadataPeople.SingleOrDefault(p => + p.NormalizedName == Parser.Parser.Normalize(person.Name) && p.Role == person.Role); + if (existingPerson == null) + { + metadataPeople.Add(person); + } + } +} diff --git a/API/Helpers/SQLHelper.cs b/API/Helpers/SQLHelper.cs index fcd44e7da..d06d246ef 100644 --- a/API/Helpers/SQLHelper.cs +++ b/API/Helpers/SQLHelper.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Data; using System.Data.Common; +using API.DTOs; using Microsoft.EntityFrameworkCore; namespace API.Helpers diff --git a/API/Helpers/SeriesHelper.cs b/API/Helpers/SeriesHelper.cs new file mode 100644 index 000000000..b03ebad18 --- /dev/null +++ b/API/Helpers/SeriesHelper.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Linq; +using API.Entities; +using API.Entities.Enums; +using API.Services.Tasks.Scanner; + +namespace API.Helpers; + +public static class SeriesHelper +{ + /// + /// Given a parsedSeries checks if any of the names match against said Series and the format matches + /// + /// + /// + /// + public static bool FindSeries(Series series, ParsedSeries parsedInfoKey) + { + return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) + || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName) + || Parser.Parser.Normalize(series.LocalizedName).Equals(parsedInfoKey.NormalizedName)) + && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); + } + + /// + /// Removes all instances of missingSeries' Series from existingSeries Collection. Existing series is updated by + /// reference and the removed element count is returned. + /// + /// Existing Series in DB + /// Series not found on disk or can't be parsed + /// + /// the updated existingSeries + public static IEnumerable RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) + { + var existingCount = existingSeries.Count; + var missingList = missingSeries.ToList(); + + existingSeries = existingSeries.Where( + s => !missingList.Exists( + m => m.NormalizedName.Equals(s.NormalizedName) && m.Format == s.Format)).ToList(); + + removeCount = existingCount - existingSeries.Count; + + return existingSeries; + } +} diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs new file mode 100644 index 000000000..4c230a053 --- /dev/null +++ b/API/Helpers/TagHelper.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; + +namespace API.Helpers; + +public static class TagHelper +{ + /// + /// + /// + /// + /// + /// + /// Callback for every item. Will give said item back and a bool if item was added + public static void UpdateTag(ICollection allTags, IEnumerable names, bool isExternal, Action action) + { + foreach (var name in names) + { + if (string.IsNullOrEmpty(name.Trim())) continue; + + var added = false; + var normalizedName = Parser.Parser.Normalize(name); + + var genre = allTags.FirstOrDefault(p => + p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + if (genre == null) + { + added = true; + genre = DbFactory.Tag(name, false); + allTags.Add(genre); + } + + action(genre, added); + } + } + + public static void KeepOnlySameTagBetweenLists(ICollection existingTags, ICollection removeAllExcept, Action action = null) + { + var existing = existingTags.ToList(); + foreach (var genre in existing) + { + var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle)); + if (existingPerson != null) continue; + existingTags.Remove(genre); + action?.Invoke(genre); + } + + } + + /// + /// Adds the tag to the list if it's not already in there. This will ignore the ExternalTag. + /// + /// + /// + public static void AddTagIfNotExists(ICollection metadataTags, Tag tag) + { + var existingGenre = metadataTags.FirstOrDefault(p => + p.NormalizedTitle == Parser.Parser.Normalize(tag.Title)); + if (existingGenre == null) + { + metadataTags.Add(tag); + } + } + + /// + /// Remove tags on a list + /// + /// Used to remove before we update/add new tags + /// Existing tags on Entity + /// Tags from metadata + /// Remove external tags? + /// Callback which will be executed for each tag removed + public static void RemoveTags(ICollection existingTags, IEnumerable tags, bool isExternal, Action action = null) + { + var normalizedTags = tags.Select(Parser.Parser.Normalize).ToList(); + foreach (var person in normalizedTags) + { + var existingTag = existingTags.FirstOrDefault(p => p.ExternalTag == isExternal && person.Equals(p.NormalizedTitle)); + if (existingTag == null) continue; + + existingTags.Remove(existingTag); + action?.Invoke(existingTag); + } + + } +} + diff --git a/API/Interfaces/ITaskScheduler.cs b/API/Interfaces/ITaskScheduler.cs deleted file mode 100644 index 215cccf80..000000000 --- a/API/Interfaces/ITaskScheduler.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Threading.Tasks; - -namespace API.Interfaces -{ - public interface ITaskScheduler - { - /// - /// For use on Server startup - /// - void ScheduleTasks(); - Task ScheduleStatsTasks(); - void ScheduleUpdaterTasks(); - void ScanLibrary(int libraryId, bool forceUpdate = false); - void CleanupChapters(int[] chapterIds); - void RefreshMetadata(int libraryId, bool forceUpdate = true); - void CleanupTemp(); - void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); - void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); - void CancelStatsTasks(); - Task RunStatCollection(); - } -} diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs deleted file mode 100644 index 733008192..000000000 --- a/API/Interfaces/IUnitOfWork.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Threading.Tasks; -using API.Interfaces.Repositories; - -namespace API.Interfaces -{ - public interface IUnitOfWork - { - ISeriesRepository SeriesRepository { get; } - IUserRepository UserRepository { get; } - ILibraryRepository LibraryRepository { get; } - IVolumeRepository VolumeRepository { get; } - ISettingsRepository SettingsRepository { get; } - IAppUserProgressRepository AppUserProgressRepository { get; } - ICollectionTagRepository CollectionTagRepository { get; } - IFileRepository FileRepository { get; } - IChapterRepository ChapterRepository { get; } - IReadingListRepository ReadingListRepository { get; } - ISeriesMetadataRepository SeriesMetadataRepository { get; } - bool Commit(); - Task CommitAsync(); - bool HasChanges(); - bool Rollback(); - Task RollbackAsync(); - } -} diff --git a/API/Interfaces/Repositories/IAppUserProgressRepository.cs b/API/Interfaces/Repositories/IAppUserProgressRepository.cs deleted file mode 100644 index d37198fb2..000000000 --- a/API/Interfaces/Repositories/IAppUserProgressRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Threading.Tasks; -using API.Entities; -using API.Entities.Enums; - -namespace API.Interfaces.Repositories -{ - public interface IAppUserProgressRepository - { - void Update(AppUserProgress userProgress); - Task CleanupAbandonedChapters(); - Task UserHasProgress(LibraryType libraryType, int userId); - Task GetUserProgressAsync(int chapterId, int userId); - } -} diff --git a/API/Interfaces/Repositories/IChapterRepository.cs b/API/Interfaces/Repositories/IChapterRepository.cs deleted file mode 100644 index 9ce145f4c..000000000 --- a/API/Interfaces/Repositories/IChapterRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs; -using API.DTOs.Reader; -using API.Entities; - -namespace API.Interfaces.Repositories -{ - public interface IChapterRepository - { - void Update(Chapter chapter); - Task> GetChaptersByIdsAsync(IList chapterIds); - Task GetChapterInfoDtoAsync(int chapterId); - Task GetChapterTotalPagesAsync(int chapterId); - Task GetChapterAsync(int chapterId); - Task GetChapterDtoAsync(int chapterId); - Task> GetFilesForChapterAsync(int chapterId); - Task> GetChaptersAsync(int volumeId); - Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); - Task GetChapterCoverImageAsync(int chapterId); - Task> GetAllCoverImagesAsync(); - Task> GetCoverImagesForLockedChaptersAsync(); - } -} diff --git a/API/Interfaces/Repositories/ICollectionTagRepository.cs b/API/Interfaces/Repositories/ICollectionTagRepository.cs deleted file mode 100644 index 18c9f490b..000000000 --- a/API/Interfaces/Repositories/ICollectionTagRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs; -using API.DTOs.CollectionTags; -using API.Entities; - -namespace API.Interfaces.Repositories -{ - public interface ICollectionTagRepository - { - void Add(CollectionTag tag); - void Remove(CollectionTag tag); - Task> GetAllTagDtosAsync(); - Task> SearchTagDtosAsync(string searchQuery); - Task GetCoverImageAsync(int collectionTagId); - Task> GetAllPromotedTagDtosAsync(); - Task GetTagAsync(int tagId); - Task GetFullTagAsync(int tagId); - void Update(CollectionTag tag); - Task RemoveTagsWithoutSeries(); - Task> GetAllTagsAsync(); - Task> GetAllCoverImagesAsync(); - } -} diff --git a/API/Interfaces/Repositories/IFileRepository.cs b/API/Interfaces/Repositories/IFileRepository.cs deleted file mode 100644 index a852032d7..000000000 --- a/API/Interfaces/Repositories/IFileRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace API.Interfaces.Repositories -{ - public interface IFileRepository - { - Task> GetFileExtensions(); - } -} diff --git a/API/Interfaces/Repositories/ILibraryRepository.cs b/API/Interfaces/Repositories/ILibraryRepository.cs deleted file mode 100644 index 1ba6ac910..000000000 --- a/API/Interfaces/Repositories/ILibraryRepository.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.Data.Repositories; -using API.DTOs; -using API.Entities; -using API.Entities.Enums; - -namespace API.Interfaces.Repositories -{ - public interface ILibraryRepository - { - void Add(Library library); - void Update(Library library); - void Delete(Library library); - Task> GetLibraryDtosAsync(); - Task LibraryExists(string libraryName); - Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes); - Task GetFullLibraryForIdAsync(int libraryId); - Task GetFullLibraryForIdAsync(int libraryId, int seriesId); - Task> GetLibraryDtosForUsernameAsync(string userName); - Task> GetLibrariesAsync(); - Task DeleteLibrary(int libraryId); - Task> GetLibrariesForUserIdAsync(int userId); - Task GetLibraryTypeAsync(int libraryId); - } -} diff --git a/API/Interfaces/Repositories/IReadingListRepository.cs b/API/Interfaces/Repositories/IReadingListRepository.cs deleted file mode 100644 index 8b5ab085d..000000000 --- a/API/Interfaces/Repositories/IReadingListRepository.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs.ReadingLists; -using API.Entities; -using API.Helpers; - -namespace API.Interfaces.Repositories -{ - public interface IReadingListRepository - { - Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams); - Task GetReadingListByIdAsync(int readingListId); - Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId); - Task GetReadingListDtoByIdAsync(int readingListId, int userId); - Task> AddReadingProgressModifiers(int userId, IList items); - Task GetReadingListDtoByTitleAsync(string title); - Task> GetReadingListItemsByIdAsync(int readingListId); - void Remove(ReadingListItem item); - void BulkRemove(IEnumerable items); - void Update(ReadingList list); - } -} diff --git a/API/Interfaces/Repositories/ISeriesMetadataRepository.cs b/API/Interfaces/Repositories/ISeriesMetadataRepository.cs deleted file mode 100644 index 00dd234ee..000000000 --- a/API/Interfaces/Repositories/ISeriesMetadataRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using API.Entities; - -namespace API.Interfaces.Repositories -{ - public interface ISeriesMetadataRepository - { - void Update(SeriesMetadata seriesMetadata); - } -} diff --git a/API/Interfaces/Repositories/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs deleted file mode 100644 index 4c8b2e74e..000000000 --- a/API/Interfaces/Repositories/ISeriesRepository.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.Data.Scanner; -using API.DTOs; -using API.DTOs.Filtering; -using API.Entities; -using API.Entities.Enums; -using API.Helpers; - -namespace API.Interfaces.Repositories -{ - public interface ISeriesRepository - { - void Attach(Series series); - void Update(Series series); - void Remove(Series series); - void Remove(IEnumerable series); - Task DoesSeriesNameExistInLibrary(string name, MangaFormat format); - /// - /// Adds user information like progress, ratings, etc - /// - /// - /// - /// - /// - Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter); - /// - /// Does not add user information like progress, ratings, etc. - /// - /// - /// Series name to search for - /// - Task> SearchSeries(int[] libraryIds, string searchQuery); - Task> GetSeriesForLibraryIdAsync(int libraryId); - Task GetSeriesDtoByIdAsync(int seriesId, int userId); - Task DeleteSeriesAsync(int seriesId); - Task GetSeriesByIdAsync(int seriesId); - Task> GetSeriesByIdsAsync(IList seriesIds); - Task GetChapterIdsForSeriesAsync(int[] seriesIds); - Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); - /// - /// Used to add Progress/Rating information to series list. - /// - /// - /// - /// - Task AddSeriesModifiers(int userId, List series); - Task GetSeriesCoverImageAsync(int seriesId); - Task> GetOnDeck(int userId, int libraryId, UserParams userParams, FilterDto filter); - Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo - Task GetSeriesMetadata(int seriesId); - Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); - Task> GetFilesForSeries(int seriesId); - Task> GetSeriesDtoForIdsAsync(IEnumerable seriesIds, int userId); - Task> GetAllCoverImagesAsync(); - Task> GetLockedCoverImagesAsync(); - Task> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams); - Task GetFullSeriesForSeriesIdAsync(int seriesId); - Task GetChunkInfo(int libraryId = 0); - Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds); - } -} diff --git a/API/Interfaces/Repositories/ISettingsRepository.cs b/API/Interfaces/Repositories/ISettingsRepository.cs deleted file mode 100644 index 95178ea79..000000000 --- a/API/Interfaces/Repositories/ISettingsRepository.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs.Settings; -using API.Entities; -using API.Entities.Enums; - -namespace API.Interfaces.Repositories -{ - public interface ISettingsRepository - { - void Update(ServerSetting settings); - Task GetSettingsDtoAsync(); - Task GetSettingAsync(ServerSettingKey key); - Task> GetSettingsAsync(); - - } -} diff --git a/API/Interfaces/Repositories/IUserRepository.cs b/API/Interfaces/Repositories/IUserRepository.cs deleted file mode 100644 index 65d943623..000000000 --- a/API/Interfaces/Repositories/IUserRepository.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.Data.Repositories; -using API.DTOs; -using API.DTOs.Reader; -using API.Entities; - -namespace API.Interfaces.Repositories -{ - public interface IUserRepository - { - void Update(AppUser user); - void Update(AppUserPreferences preferences); - void Update(AppUserBookmark bookmark); - public void Delete(AppUser user); - Task> GetMembersAsync(); - Task> GetAdminUsersAsync(); - Task> GetNonAdminUsersAsync(); - Task IsUserAdmin(AppUser user); - Task GetUserRating(int seriesId, int userId); - Task GetPreferencesAsync(string username); - Task> GetBookmarkDtosForSeries(int userId, int seriesId); - Task> GetBookmarkDtosForVolume(int userId, int volumeId); - Task> GetBookmarkDtosForChapter(int userId, int chapterId); - Task> GetAllBookmarkDtos(int userId); - Task GetBookmarkForPage(int page, int chapterId, int userId); - Task GetUserIdByApiKeyAsync(string apiKey); - Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); - Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); - Task GetUserIdByUsernameAsync(string username); - Task GetUserWithReadingListsByUsernameAsync(string username); - } -} diff --git a/API/Interfaces/Repositories/IVolumeRepository.cs b/API/Interfaces/Repositories/IVolumeRepository.cs deleted file mode 100644 index 63045a38d..000000000 --- a/API/Interfaces/Repositories/IVolumeRepository.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs; -using API.Entities; - -namespace API.Interfaces.Repositories -{ - public interface IVolumeRepository - { - void Add(Volume volume); - void Update(Volume volume); - void Remove(Volume volume); - Task> GetFilesForVolume(int volumeId); - Task GetVolumeCoverImageAsync(int volumeId); - Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); - - // From Series Repo - Task> GetVolumesDtoAsync(int seriesId, int userId); - Task GetVolumeAsync(int volumeId); - Task GetVolumeDtoAsync(int volumeId, int userId); - Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); - Task> GetVolumes(int seriesId); - Task GetVolumeByIdAsync(int volumeId); - } -} diff --git a/API/Interfaces/Services/IAccountService.cs b/API/Interfaces/Services/IAccountService.cs deleted file mode 100644 index e07ce2f79..000000000 --- a/API/Interfaces/Services/IAccountService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.Entities; -using API.Errors; - -namespace API.Interfaces.Services -{ - public interface IAccountService - { - Task> ChangeUserPassword(AppUser user, string newPassword); - } -} diff --git a/API/Interfaces/Services/IArchiveService.cs b/API/Interfaces/Services/IArchiveService.cs deleted file mode 100644 index f2567341a..000000000 --- a/API/Interfaces/Services/IArchiveService.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO.Compression; -using System.Threading.Tasks; -using API.Archive; -using API.Data.Metadata; - -namespace API.Interfaces.Services -{ - public interface IArchiveService - { - void ExtractArchive(string archivePath, string extractPath); - int GetNumberOfPagesFromArchive(string archivePath); - string GetCoverImage(string archivePath, string fileName); - bool IsValidArchive(string archivePath); - ComicInfo GetComicInfo(string archivePath); - ArchiveLibrary CanOpen(string archivePath); - bool ArchiveNeedsFlattening(ZipArchive archive); - Task> CreateZipForDownload(IEnumerable files, string tempFolder); - } -} diff --git a/API/Interfaces/Services/IBackupService.cs b/API/Interfaces/Services/IBackupService.cs deleted file mode 100644 index 315b852f0..000000000 --- a/API/Interfaces/Services/IBackupService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; - -namespace API.Interfaces.Services -{ - public interface IBackupService - { - Task BackupDatabase(); - /// - /// Returns a list of full paths of the logs files detailed in . - /// - /// - /// - /// - IEnumerable LogFiles(int maxRollingFiles, string logFileName); - - void CleanupBackups(); - } -} \ No newline at end of file diff --git a/API/Interfaces/Services/IBookService.cs b/API/Interfaces/Services/IBookService.cs deleted file mode 100644 index e78669755..000000000 --- a/API/Interfaces/Services/IBookService.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.Data.Metadata; -using API.Parser; -using VersOne.Epub; - -namespace API.Interfaces.Services -{ - public interface IBookService - { - int GetNumberOfPages(string filePath); - string GetCoverImage(string fileFilePath, string fileName); - 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. - void ExtractPdfImages(string fileFilePath, string targetDirectory); - } -} diff --git a/API/Interfaces/Services/ICacheService.cs b/API/Interfaces/Services/ICacheService.cs deleted file mode 100644 index 395898dc2..000000000 --- a/API/Interfaces/Services/ICacheService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.Entities; - -namespace API.Interfaces.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 folders and files. - /// - void Cleanup(); - - /// - /// 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); - - - /// - /// Returns the absolute path of a cached page. - /// - /// Chapter entity with Files populated. - /// Page number to look for - /// - Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page); - - void EnsureCacheDirectory(); - string GetCachedEpubFile(int chapterId, Chapter chapter); - public void ExtractChapterFiles(string extractPath, IReadOnlyList files); - } -} diff --git a/API/Interfaces/Services/ICleanupService.cs b/API/Interfaces/Services/ICleanupService.cs deleted file mode 100644 index afabb9900..000000000 --- a/API/Interfaces/Services/ICleanupService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading.Tasks; - -namespace API.Interfaces.Services -{ - public interface ICleanupService - { - Task Cleanup(); - void CleanupCacheDirectory(); - } -} diff --git a/API/Interfaces/Services/IDirectoryService.cs b/API/Interfaces/Services/IDirectoryService.cs deleted file mode 100644 index a8ae8c05f..000000000 --- a/API/Interfaces/Services/IDirectoryService.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - -namespace API.Interfaces.Services -{ - public interface IDirectoryService - { - /// - /// 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); - } -} diff --git a/API/Interfaces/Services/IImageService.cs b/API/Interfaces/Services/IImageService.cs deleted file mode 100644 index 0aba07f39..000000000 --- a/API/Interfaces/Services/IImageService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using API.Entities; -using API.Services; - -namespace API.Interfaces.Services -{ - public interface IImageService - { - string GetCoverImage(string path, string fileName); - string GetCoverFile(MangaFile file); - /// - /// Creates a Thumbnail version of an image - /// - /// Path to the image file - /// File name with extension of the file. This will always write to - public string CreateThumbnail(string path, string fileName); - /// - /// Creates a Thumbnail version of a base64 image - /// - /// base64 encoded image - /// File name with extension of the file. This will always write to - public string CreateThumbnailFromBase64(string encodedImage, string fileName); - } -} diff --git a/API/Interfaces/Services/IMetadataService.cs b/API/Interfaces/Services/IMetadataService.cs deleted file mode 100644 index 6d4d725cf..000000000 --- a/API/Interfaces/Services/IMetadataService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Threading.Tasks; -using API.Entities; - -namespace API.Interfaces.Services -{ - public interface IMetadataService - { - /// - /// Recalculates metadata for all entities in a library. - /// - /// - /// - Task RefreshMetadata(int libraryId, bool forceUpdate = false); - - public bool UpdateMetadata(Chapter chapter, bool forceUpdate); - public bool UpdateMetadata(Volume volume, bool forceUpdate); - public bool UpdateMetadata(Series series, bool forceUpdate); - /// - /// Performs a forced refresh of metatdata just for a series and it's nested entities - /// - /// - /// - Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false); - } -} diff --git a/API/Interfaces/Services/IReaderService.cs b/API/Interfaces/Services/IReaderService.cs deleted file mode 100644 index a72b90699..000000000 --- a/API/Interfaces/Services/IReaderService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs; -using API.Entities; - -namespace API.Interfaces.Services -{ - public interface IReaderService - { - void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); - void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); - Task SaveReadingProgress(ProgressDto progressDto, int userId); - Task CapPageToChapter(int chapterId, int page); - Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); - Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); - } -} diff --git a/API/Interfaces/Services/IScannerService.cs b/API/Interfaces/Services/IScannerService.cs deleted file mode 100644 index bab0ca588..000000000 --- a/API/Interfaces/Services/IScannerService.cs +++ /dev/null @@ -1,18 +0,0 @@ - -using System.Threading; -using System.Threading.Tasks; - -namespace API.Interfaces.Services -{ - public interface IScannerService - { - /// - /// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite - /// cover images if forceUpdate is true. - /// - /// Library to scan against - Task ScanLibrary(int libraryId); - Task ScanLibraries(); - Task ScanSeries(int libraryId, int seriesId, CancellationToken token); - } -} diff --git a/API/Interfaces/Services/IStartupTask.cs b/API/Interfaces/Services/IStartupTask.cs deleted file mode 100644 index e2a99ecad..000000000 --- a/API/Interfaces/Services/IStartupTask.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace API.Interfaces.Services -{ - public interface IStartupTask - { - Task ExecuteAsync(CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/API/Interfaces/Services/IStatsService.cs b/API/Interfaces/Services/IStatsService.cs deleted file mode 100644 index 685c3057d..000000000 --- a/API/Interfaces/Services/IStatsService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Threading.Tasks; -using API.DTOs.Stats; - -namespace API.Interfaces.Services -{ - public interface IStatsService - { - Task Send(); - Task GetServerInfo(); - } -} diff --git a/API/Interfaces/Services/ITokenService.cs b/API/Interfaces/Services/ITokenService.cs deleted file mode 100644 index 14765f2f0..000000000 --- a/API/Interfaces/Services/ITokenService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading.Tasks; -using API.Entities; - -namespace API.Interfaces.Services -{ - public interface ITokenService - { - Task CreateToken(AppUser user); - } -} \ No newline at end of file diff --git a/API/Interfaces/Services/IVersionUpdaterService.cs b/API/Interfaces/Services/IVersionUpdaterService.cs deleted file mode 100644 index ddde09960..000000000 --- a/API/Interfaces/Services/IVersionUpdaterService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using API.DTOs.Update; - -namespace API.Interfaces.Services -{ - public interface IVersionUpdaterService - { - Task CheckForUpdate(); - Task PushUpdate(UpdateNotificationDto update); - Task> GetAllReleases(); - } -} diff --git a/API/Interfaces/Services/ReaderService.cs b/API/Interfaces/Services/ReaderService.cs deleted file mode 100644 index 7eb2e1118..000000000 --- a/API/Interfaces/Services/ReaderService.cs +++ /dev/null @@ -1,310 +0,0 @@ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using API.Comparators; -using API.Data.Repositories; -using API.DTOs; -using API.Entities; -using Microsoft.Extensions.Logging; - -namespace API.Interfaces.Services -{ - public class ReaderService : IReaderService - { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - - public ReaderService(IUnitOfWork unitOfWork, ILogger logger) - { - _unitOfWork = unitOfWork; - _logger = logger; - } - - /// - /// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit. - /// - /// - /// - /// - public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters) - { - foreach (var chapter in chapters) - { - var userProgress = GetUserProgressForChapter(user, chapter); - - if (userProgress == null) - { - user.Progresses.Add(new AppUserProgress - { - PagesRead = chapter.Pages, - VolumeId = chapter.VolumeId, - SeriesId = seriesId, - ChapterId = chapter.Id - }); - } - else - { - userProgress.PagesRead = chapter.Pages; - userProgress.SeriesId = seriesId; - userProgress.VolumeId = chapter.VolumeId; - } - } - } - - /// - /// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit. - /// - /// - /// - /// - public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters) - { - foreach (var chapter in chapters) - { - var userProgress = GetUserProgressForChapter(user, chapter); - - if (userProgress == null) - { - user.Progresses.Add(new AppUserProgress - { - PagesRead = 0, - VolumeId = chapter.VolumeId, - SeriesId = seriesId, - ChapterId = chapter.Id - }); - } - else - { - userProgress.PagesRead = 0; - userProgress.SeriesId = seriesId; - userProgress.VolumeId = chapter.VolumeId; - } - } - } - - /// - /// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit. - /// - /// - /// - /// - public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) - { - AppUserProgress userProgress = null; - try - { - userProgress = - user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id); - } - catch (Exception) - { - // There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages - var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); - if (progresses.Count > 1) - { - user.Progresses = new List() - { - user.Progresses.First() - }; - userProgress = user.Progresses.First(); - } - } - - return userProgress; - } - - /// - /// Saves progress to DB - /// - /// - /// - /// - public async Task SaveReadingProgress(ProgressDto progressDto, int userId) - { - // Don't let user save past total pages. - progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum); - - try - { - var userProgress = - await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId); - - if (userProgress == null) - { - // Create a user object - var userWithProgress = - await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress); - userWithProgress.Progresses ??= new List(); - userWithProgress.Progresses.Add(new AppUserProgress - { - PagesRead = progressDto.PageNum, - VolumeId = progressDto.VolumeId, - SeriesId = progressDto.SeriesId, - ChapterId = progressDto.ChapterId, - BookScrollId = progressDto.BookScrollId, - LastModified = DateTime.Now - }); - _unitOfWork.UserRepository.Update(userWithProgress); - } - else - { - userProgress.PagesRead = progressDto.PageNum; - userProgress.SeriesId = progressDto.SeriesId; - userProgress.VolumeId = progressDto.VolumeId; - userProgress.BookScrollId = progressDto.BookScrollId; - userProgress.LastModified = DateTime.Now; - _unitOfWork.AppUserProgressRepository.Update(userProgress); - } - - if (await _unitOfWork.CommitAsync()) - { - return true; - } - } - catch (Exception exception) - { - _logger.LogError(exception, "Could not save progress"); - await _unitOfWork.RollbackAsync(); - } - - return false; - } - - /// - /// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call. - /// - /// - /// - /// - public async Task CapPageToChapter(int chapterId, int page) - { - var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId); - if (page > totalPages) - { - page = totalPages; - } - - if (page < 0) - { - page = 0; - } - - return page; - } - - /// - /// Tries to find the next logical Chapter - /// - /// - /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02 - /// - /// - /// - /// - /// - /// -1 if nothing can be found - public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) - { - var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); - var currentVolume = volumes.Single(v => v.Id == volumeId); - var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); - - if (currentVolume.Number == 0) - { - // Handle specials by sorting on their Filename aka Range - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()), currentChapter.Number); - if (chapterId > 0) return chapterId; - } - - foreach (var volume in volumes) - { - if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1) - { - // Handle Chapters within current Volume - // In this case, i need 0 first because 0 represents a full volume file. - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number); - if (chapterId > 0) return chapterId; - } - - if (volume.Number != currentVolume.Number + 1) continue; - - // Handle Chapters within next Volume - // ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+ - var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList(); - if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0")) - { - return chapters.Last().Id; - } - - var firstChapter = chapters.FirstOrDefault(); - if (firstChapter == null) return -1; - return firstChapter.Id; - - } - - return -1; - } - /// - /// Tries to find the prev logical Chapter - /// - /// - /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02 - /// - /// - /// - /// - /// - /// -1 if nothing can be found - public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) - { - var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList(); - var currentVolume = volumes.Single(v => v.Id == volumeId); - var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); - - if (currentVolume.Number == 0) - { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Range, new NaturalSortComparer()).Reverse(), currentChapter.Number); - if (chapterId > 0) return chapterId; - } - - foreach (var volume in volumes) - { - if (volume.Number == currentVolume.Number) - { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number); - if (chapterId > 0) return chapterId; - } - if (volume.Number == currentVolume.Number - 1) - { - var lastChapter = volume.Chapters - .OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault(); - if (lastChapter == null) return -1; - return lastChapter.Id; - } - } - return -1; - } - - private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) - { - var next = false; - var chaptersList = chapters.ToList(); - foreach (var chapter in chaptersList) - { - if (next) - { - return chapter.Id; - } - if (currentChapterNumber.Equals(chapter.Number)) next = true; - } - - return -1; - } - - - } -} diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index 8badfeb96..0712eaea1 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -14,7 +14,7 @@ namespace API.Middleware private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IHostEnvironment _env; - + public ExceptionMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment env) { @@ -34,14 +34,14 @@ namespace API.Middleware _logger.LogError(ex, "There was an exception"); context.Response.ContentType = "application/json"; context.Response.StatusCode = (int) HttpStatusCode.InternalServerError; - - var response = _env.IsDevelopment() + + var response = _env.IsDevelopment() ? new ApiException(context.Response.StatusCode, ex.Message, ex.StackTrace) - : new ApiException(context.Response.StatusCode, "Internal Server Error"); - + : new ApiException(context.Response.StatusCode, "Internal Server Error", ex.StackTrace); + var options = new JsonSerializerOptions { - PropertyNamingPolicy = + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -52,4 +52,4 @@ namespace API.Middleware } } } -} \ No newline at end of file +} diff --git a/API/Parser/DefaultParser.cs b/API/Parser/DefaultParser.cs new file mode 100644 index 000000000..23b5c1d58 --- /dev/null +++ b/API/Parser/DefaultParser.cs @@ -0,0 +1,160 @@ +using System.IO; +using System.Linq; +using API.Entities.Enums; +using API.Services; + +namespace API.Parser; + +/// +/// This is an implementation of the Parser that is the basis for everything +/// +public class DefaultParser +{ + private readonly IDirectoryService _directoryService; + + public DefaultParser(IDirectoryService directoryService) + { + _directoryService = directoryService; + } + + /// + /// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed + /// from filename. + /// + /// + /// Root folder + /// Defaults to Manga. Allows different Regex to be used for parsing. + /// or null if Series was empty + public ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + ParserInfo ret; + + if (Parser.IsEpub(filePath)) + { + ret = new ParserInfo() + { + Chapters = Parser.ParseChapter(fileName) ?? Parser.ParseComicChapter(fileName), + Series = Parser.ParseSeries(fileName) ?? Parser.ParseComicSeries(fileName), + Volumes = Parser.ParseVolume(fileName) ?? Parser.ParseComicVolume(fileName), + Filename = Path.GetFileName(filePath), + Format = Parser.ParseFormat(filePath), + FullFilePath = filePath + }; + } + else + { + ret = new ParserInfo() + { + Chapters = type == LibraryType.Manga ? Parser.ParseChapter(fileName) : Parser.ParseComicChapter(fileName), + Series = type == LibraryType.Manga ? Parser.ParseSeries(fileName) : Parser.ParseComicSeries(fileName), + Volumes = type == LibraryType.Manga ? Parser.ParseVolume(fileName) : Parser.ParseComicVolume(fileName), + Filename = Path.GetFileName(filePath), + Format = Parser.ParseFormat(filePath), + Title = Path.GetFileNameWithoutExtension(fileName), + FullFilePath = filePath + }; + } + + if (Parser.IsImage(filePath) && Parser.IsCoverImage(filePath)) return null; + + if (Parser.IsImage(filePath)) + { + // Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders. + ret.Volumes = Parser.DefaultVolume; + ret.Chapters = Parser.DefaultChapter; + ret.Series = string.Empty; + } + + if (ret.Series == string.Empty || Parser.IsImage(filePath)) + { + // Try to parse information out of each folder all the way to rootPath + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + var edition = Parser.ParseEdition(fileName); + if (!string.IsNullOrEmpty(edition)) + { + ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic); + ret.Edition = edition; + } + + var isSpecial = type == LibraryType.Comic ? Parser.ParseComicSpecial(fileName) : Parser.ParseMangaSpecial(fileName); + // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that + // could cause a problem as Omake is a special term, but there is valid volume/chapter information. + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.DefaultVolume && !string.IsNullOrEmpty(isSpecial)) + { + ret.IsSpecial = true; + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name + if (Parser.HasSpecialMarker(fileName)) + { + ret.IsSpecial = true; + ret.Chapters = Parser.DefaultChapter; + ret.Volumes = Parser.DefaultVolume; + + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + if (string.IsNullOrEmpty(ret.Series)) + { + ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); + } + + // Pdfs may have .pdf in the series name, remove that + if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) + { + ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); + } + + return ret.Series == string.Empty ? null : ret; + } + + /// + /// Fills out by trying to parse volume, chapters, and series from folders + /// + /// + /// + /// + /// Expects a non-null ParserInfo which this method will populate + public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret) + { + var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath).ToList(); + for (var i = 0; i < fallbackFolders.Count; i++) + { + var folder = fallbackFolders[i]; + if (!string.IsNullOrEmpty(Parser.ParseMangaSpecial(folder))) continue; + + var parsedVolume = type is LibraryType.Manga ? Parser.ParseVolume(folder) : Parser.ParseComicVolume(folder); + var parsedChapter = type is LibraryType.Manga ? Parser.ParseChapter(folder) : Parser.ParseComicChapter(folder); + + if (!parsedVolume.Equals(Parser.DefaultVolume) || !parsedChapter.Equals(Parser.DefaultChapter)) + { + if ((ret.Volumes.Equals(Parser.DefaultVolume) || string.IsNullOrEmpty(ret.Volumes)) && !parsedVolume.Equals(Parser.DefaultVolume)) + { + ret.Volumes = parsedVolume; + } + if ((ret.Chapters.Equals(Parser.DefaultChapter) || string.IsNullOrEmpty(ret.Chapters)) && !parsedChapter.Equals(Parser.DefaultChapter)) + { + ret.Chapters = parsedChapter; + } + } + + var series = Parser.ParseSeries(folder); + + if ((string.IsNullOrEmpty(series) && i == fallbackFolders.Count - 1)) + { + ret.Series = Parser.CleanTitle(folder, type is LibraryType.Comic); + break; + } + + if (!string.IsNullOrEmpty(series)) + { + ret.Series = series; + break; + } + } + } +} diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 02dc6894c..c17290c5b 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -28,7 +28,8 @@ namespace API.Parser /// 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\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(?.{1}\))", + 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 @@ -48,11 +49,13 @@ namespace API.Parser 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(@"(?.*) (?\d+) (?:\(\d{4}\)) ", + @"(?.*)\s+(?\d+)\s+(?:\(\d{4}\))\s", MatchOptions, RegexTimeout), // Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire) new Regex( @@ -207,7 +210,6 @@ namespace API.Parser new Regex( @"^(?!Vol\.?)(?.*)( |_|-)(?.*)ch\d+-?\d?", @@ -236,9 +238,13 @@ namespace API.Parser 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\.?)( |_)(?\d+(-\d+)?)", + @"(?.+?)(\b|_)((vol|tome|t)\.?)(\s|_)(?\d+(-\d+)?)", MatchOptions, RegexTimeout), // Batman Beyond 2.0 001 (2013) new Regex( @@ -256,9 +262,9 @@ namespace API.Parser new Regex( @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", MatchOptions, RegexTimeout), - // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus), Aldebaran-Antares-t6 new Regex( - @"^(?.+?)(?: |_)v\d+", + @"^(?.+?)(?: |_|-)(v|t)\d+", MatchOptions, RegexTimeout), // Amazing Man Comics chapter 25 new Regex( @@ -306,11 +312,11 @@ namespace API.Parser { // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( - @"^(?.*)(?: |_)v(?\d+)", + @"^(?.*)(?: |_)(t|v)(?\d+)", MatchOptions, RegexTimeout), // Batgirl Vol.2000 #57 (December, 2004) new Regex( - @"^(?.+?)(?:\s|_)vol\.?\s?(?\d+)", + @"^(?.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?\d+)", MatchOptions, RegexTimeout), }; @@ -407,7 +413,7 @@ namespace API.Parser 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|_|-)", + @"^(?!Vol)(?.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", MatchOptions, RegexTimeout), // Tower Of God S01 014 (CBT) (digital).cbz new Regex( @@ -470,7 +476,7 @@ namespace API.Parser { // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. new Regex( - @"(?Specials?|OneShot|One\-Shot|Omake|Extra( Chapter)?|Art Collection|Side( |_)Stories|Bonus)", + @"(?Specials?|OneShot|One\-Shot|Omake|Extra(?:(\sChapter)?[^\S])|Art Collection|Side( |_)Stories|Bonus)", MatchOptions, RegexTimeout), }; @@ -478,7 +484,15 @@ namespace API.Parser { // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. new Regex( - @"(?Specials?|OneShot|One\-Shot|Extra( Chapter)?|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side( |_)Stories|Bonus)", + @"(?Specials?|OneShot|One\-Shot|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), }; @@ -488,147 +502,10 @@ namespace API.Parser MatchOptions, RegexTimeout ); - - /// - /// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed - /// from filename. - /// - /// - /// Root folder - /// Defaults to Manga. Allows different Regex to be used for parsing. - /// or null if Series was empty - public static ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga) - { - var fileName = Path.GetFileNameWithoutExtension(filePath); - ParserInfo ret; - - if (IsEpub(filePath)) - { - ret = new ParserInfo() - { - Chapters = ParseChapter(fileName) ?? ParseComicChapter(fileName), - Series = ParseSeries(fileName) ?? ParseComicSeries(fileName), - Volumes = ParseVolume(fileName) ?? ParseComicVolume(fileName), - Filename = Path.GetFileName(filePath), - Format = ParseFormat(filePath), - FullFilePath = filePath - }; - } - else - { - ret = new ParserInfo() - { - Chapters = type == LibraryType.Manga ? ParseChapter(fileName) : ParseComicChapter(fileName), - Series = type == LibraryType.Manga ? ParseSeries(fileName) : ParseComicSeries(fileName), - Volumes = type == LibraryType.Manga ? ParseVolume(fileName) : ParseComicVolume(fileName), - Filename = Path.GetFileName(filePath), - Format = ParseFormat(filePath), - Title = Path.GetFileNameWithoutExtension(fileName), - FullFilePath = filePath - }; - } - - if (IsImage(filePath) && IsCoverImage(filePath)) return null; - - if (IsImage(filePath)) - { - // Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders. - ret.Volumes = DefaultVolume; - ret.Chapters = DefaultChapter; - ret.Series = string.Empty; - } - - if (ret.Series == string.Empty || IsImage(filePath)) - { - // Try to parse information out of each folder all the way to rootPath - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); - } - - var edition = ParseEdition(fileName); - if (!string.IsNullOrEmpty(edition)) - { - ret.Series = CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic); - ret.Edition = edition; - } - - var isSpecial = type == LibraryType.Comic ? ParseComicSpecial(fileName) : ParseMangaSpecial(fileName); - // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that - // could cause a problem as Omake is a special term, but there is valid volume/chapter information. - if (ret.Chapters == DefaultChapter && ret.Volumes == DefaultVolume && !string.IsNullOrEmpty(isSpecial)) - { - ret.IsSpecial = true; - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); - } - - // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name - if (HasSpecialMarker(fileName)) - { - ret.IsSpecial = true; - ret.Chapters = DefaultChapter; - ret.Volumes = DefaultVolume; - - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); - } - - if (string.IsNullOrEmpty(ret.Series)) - { - ret.Series = CleanTitle(fileName, type is LibraryType.Comic); - } - - // Pdfs may have .pdf in the series name, remove that - if (IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) - { - ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); - } - - return ret.Series == string.Empty ? null : ret; - } - - /// - /// - /// - /// - /// - /// - /// Expects a non-null ParserInfo which this method will populate - public static void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret) - { - var fallbackFolders = DirectoryService.GetFoldersTillRoot(rootPath, filePath).ToList(); - for (var i = 0; i < fallbackFolders.Count; i++) - { - var folder = fallbackFolders[i]; - if (!string.IsNullOrEmpty(ParseMangaSpecial(folder))) continue; - - var parsedVolume = type is LibraryType.Manga ? ParseVolume(folder) : ParseComicVolume(folder); - var parsedChapter = type is LibraryType.Manga ? ParseChapter(folder) : ParseComicChapter(folder); - - if (!parsedVolume.Equals(DefaultVolume) || !parsedChapter.Equals(DefaultChapter)) - { - if ((ret.Volumes.Equals(DefaultVolume) || string.IsNullOrEmpty(ret.Volumes)) && !parsedVolume.Equals(DefaultVolume)) - { - ret.Volumes = parsedVolume; - } - if ((ret.Chapters.Equals(DefaultChapter) || string.IsNullOrEmpty(ret.Chapters)) && !parsedChapter.Equals(DefaultChapter)) - { - ret.Chapters = parsedChapter; - } - } - - var series = ParseSeries(folder); - - if ((string.IsNullOrEmpty(series) && i == fallbackFolders.Count - 1)) - { - ret.Series = CleanTitle(folder, type is LibraryType.Comic); - break; - } - - if (!string.IsNullOrEmpty(series)) - { - ret.Series = series; - break; - } - } - } + private static readonly Regex EmptySpaceRegex = new Regex( + @"(?!=.+)(\s{2,})(?!=.+)", + MatchOptions, RegexTimeout + ); public static MangaFormat ParseFormat(string filePath) { @@ -862,7 +739,6 @@ namespace API.Parser } } - // TODO: Since we have loops like this, think about using a method foreach (var regex in MangaEditionRegex) { var matches = regex.Matches(title); @@ -895,6 +771,23 @@ namespace API.Parser return title; } + private static string RemoveEuropeanTags(string title) + { + foreach (var regex in EuropeanComicRegex) + { + 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 RemoveComicSpecialTags(string title) { foreach (var regex in ComicSpecialRegex) @@ -931,6 +824,16 @@ namespace API.Parser title = isComic ? RemoveComicSpecialTags(title) : RemoveMangaSpecialTags(title); + if (isComic) + { + title = RemoveComicSpecialTags(title); + title = RemoveEuropeanTags(title); + } + else + { + title = RemoveMangaSpecialTags(title); + } + title = title.Replace("_", " ").Trim(); if (title.EndsWith("-") || title.EndsWith(",")) @@ -938,6 +841,13 @@ namespace API.Parser title = title.Substring(0, title.Length - 1); } + if (title.StartsWith("-") || title.StartsWith(",")) + { + title = title.Substring(1); + } + + title = EmptySpaceRegex.Replace(title, " "); + return title.Trim(); } @@ -997,15 +907,18 @@ namespace API.Parser { 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, bool suppressExtraChecks = false) + public static bool IsImage(string filePath) { - if (filePath.StartsWith(".") || (!suppressExtraChecks && filePath.StartsWith("!"))) return false; - return ImageRegex.IsMatch(Path.GetExtension(filePath)); + return !filePath.StartsWith(".") && ImageRegex.IsMatch(Path.GetExtension(filePath)); } public static bool IsXml(string filePath) @@ -1040,16 +953,17 @@ namespace API.Parser /// /// 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 name) + public static bool IsCoverImage(string filename) { - return IsImage(name, true) && (CoverImageRegex.IsMatch(name)); + return IsImage(filename) && CoverImageRegex.IsMatch(filename); } public static bool HasBlacklistedFolderInPath(string path) { - return path.Contains("__MACOSX"); + return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("._"); } @@ -1062,5 +976,28 @@ namespace API.Parser { return Path.GetExtension(filePath).ToLower() == ".pdf"; } + + /// + /// Cleans an author's name + /// + /// If the author is Last, First, this will not reverse + /// + /// + public static string CleanAuthor(string author) + { + if (string.IsNullOrEmpty(author)) return string.Empty; + return author.Trim(); + } + + /// + /// Normalizes the slashes in a path to be + /// + /// /manga/1\1 -> /manga/1/1 + /// + /// + public static string NormalizePath(string path) + { + return path.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } } } diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index a2c4a9c51..cb55bd18e 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using API.Data.Metadata; +using API.Entities.Enums; namespace API.Parser { @@ -15,7 +16,11 @@ namespace API.Parser /// /// Represents the parsed series from the file or folder /// - public string Series { get; set; } = ""; + 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; /// /// 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. @@ -43,6 +48,7 @@ namespace API.Parser /// /// This can potentially story things like "Omnibus, Color, Full Contact Edition, Extra, Final, etc" /// + /// Not Used in Database public string Edition { get; set; } = ""; /// @@ -55,16 +61,22 @@ namespace API.Parser /// 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")); } + /// + /// 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. /// @@ -80,4 +92,4 @@ namespace API.Parser IsSpecial = IsSpecial || info2.IsSpecial; } } -} \ No newline at end of file +} diff --git a/API/Program.cs b/API/Program.cs index f35bf8bd3..4ed8ce56a 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,12 +1,14 @@ using System; -using System.Collections.Generic; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; using API.Data; using API.Entities; +using API.Entities.Enums; using API.Services; +using API.Services.Tasks; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Hosting; @@ -33,7 +35,9 @@ namespace API Console.OutputEncoding = System.Text.Encoding.UTF8; var isDocker = new OsInfo(Array.Empty()).IsDocker; - MigrateConfigFiles.Migrate(isDocker); + + var directoryService = new DirectoryService(null, new FileSystem()); + MigrateConfigFiles.Migrate(isDocker, directoryService); // Before anything, check if JWT has been generated properly or if user still has default if (!Configuration.CheckIfJwtTokenSet() && @@ -41,7 +45,7 @@ namespace API { Console.WriteLine("Generating JWT TokenKey for encrypting user sessions..."); var rBytes = new byte[128]; - using (var crypto = new RNGCryptoServiceProvider()) crypto.GetBytes(rBytes); + RandomNumberGenerator.Create().GetBytes(rBytes); Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); } @@ -52,47 +56,33 @@ namespace API try { + var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); + var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); + if (pendingMigrations.Any()) + { + logger.LogInformation("Performing backup as migrations are needed. Backup will be kavita.db in temp folder"); + directoryService.CopyFileToDirectory(directoryService.FileSystem.Path.Join(directoryService.ConfigDirectory, "kavita.db"), directoryService.TempDirectory); + } + + await context.Database.MigrateAsync(); var roleManager = services.GetRequiredService>(); + await Seed.SeedRoles(roleManager); + await Seed.SeedSettings(context, directoryService); + await Seed.SeedUserApiKeys(context); + + if (isDocker && new FileInfo("data/appsettings.json").Exists) { - var logger = services.GetRequiredService>(); logger.LogCritical("WARNING! Mount point is incorrect, nothing here will persist. Please change your container mount from /kavita/data to /kavita/config"); return; } - - - var requiresCoverImageMigration = !Directory.Exists(DirectoryService.CoverImageDirectory); - try - { - // If this is a new install, tables wont exist yet - if (requiresCoverImageMigration) - { - MigrateCoverImages.ExtractToImages(context); - } - } - catch (Exception) - { - requiresCoverImageMigration = false; - } - - // Apply all migrations on startup - await context.Database.MigrateAsync(); - - if (requiresCoverImageMigration) - { - await MigrateCoverImages.UpdateDatabaseWithImages(context); - } - - await Seed.SeedRoles(roleManager); - await Seed.SeedSettings(context); - await Seed.SeedUserApiKeys(context); } catch (Exception ex) { var logger = services.GetRequiredService>(); - logger.LogError(ex, "An error occurred during migration"); + logger.LogCritical(ex, "An error occurred during migration"); } await host.RunAsync(); diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 0cc720bb6..0591770ec 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -3,12 +3,16 @@ using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Errors; -using API.Interfaces.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; namespace API.Services { + public interface IAccountService + { + Task> ChangeUserPassword(AppUser user, string newPassword); + } + public class AccountService : IAccountService { private readonly UserManager _userManager; diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 2eb59a9fc..11042ed34 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -10,7 +10,6 @@ using API.Archive; using API.Comparators; using API.Data.Metadata; using API.Extensions; -using API.Interfaces.Services; using API.Services.Tasks; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -19,6 +18,19 @@ using SharpCompress.Common; 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); + Task> CreateZipForDownload(IEnumerable files, string tempFolder); + string FindCoverImageFilename(string archivePath, IList entryNames); + } + /// /// Responsible for manipulating Archive files. Used by and /// @@ -27,12 +39,14 @@ namespace API.Services { private readonly ILogger _logger; private readonly IDirectoryService _directoryService; + private readonly IImageService _imageService; private const string ComicInfoFilename = "comicinfo"; - public ArchiveService(ILogger logger, IDirectoryService directoryService) + public ArchiveService(ILogger logger, IDirectoryService directoryService, IImageService imageService) { _logger = logger; _directoryService = directoryService; + _imageService = imageService; } /// @@ -42,7 +56,10 @@ namespace API.Services /// public virtual ArchiveLibrary CanOpen(string archivePath) { - if (!(File.Exists(archivePath) && Parser.Parser.IsArchive(archivePath) || Parser.Parser.IsEpub(archivePath))) return ArchiveLibrary.NotSupported; + if (string.IsNullOrEmpty(archivePath) || !(File.Exists(archivePath) && Parser.Parser.IsArchive(archivePath) || 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 { @@ -108,46 +125,47 @@ namespace API.Services /// /// /// Entry name of match, null if no match - public string FindFolderEntry(IEnumerable entryFullNames) + public static string FindFolderEntry(IEnumerable entryFullNames) { var result = entryFullNames - .FirstOrDefault(x => !Path.EndsInDirectorySeparator(x) && !Parser.Parser.HasBlacklistedFolderInPath(x) - && Parser.Parser.IsCoverImage(x) - && !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); + .OrderByNatural(Path.GetFileNameWithoutExtension) + .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith))) + .FirstOrDefault(Parser.Parser.IsCoverImage); return string.IsNullOrEmpty(result) ? null : result; } /// - /// Returns first entry that is an image and is not in a blacklisted folder path. Uses for ordering files + /// Returns first entry that is an image and is not in a blacklisted folder path. Uses for ordering files /// /// /// Entry name of match, null if no match - public static string FirstFileEntry(IEnumerable entryFullNames, string archiveName) + public static string? FirstFileEntry(IEnumerable entryFullNames, string archiveName) { // First check if there are any files that are not in a nested folder before just comparing by filename. This is needed // because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg. - var fullNames = entryFullNames.Where(x =>!Parser.Parser.HasBlacklistedFolderInPath(x) - && Parser.Parser.IsImage(x) - && !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)).ToList(); + var fullNames = entryFullNames + .OrderByNatural(c => c.GetFullPathWithoutExtension()) + .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)) && Parser.Parser.IsImage(path)) + .ToList(); if (fullNames.Count == 0) return null; var nonNestedFile = fullNames.Where(entry => (Path.GetDirectoryName(entry) ?? string.Empty).Equals(archiveName)) - .OrderBy(Path.GetFullPath, new NaturalSortComparer()) + .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.OrderBy(Path.GetDirectoryName, new NaturalSortComparer()).FirstOrDefault(); + var firstDirectoryFile = fullNames.OrderByNatural(Path.GetDirectoryName).FirstOrDefault(); if (!string.IsNullOrEmpty(firstDirectoryFile)) { var firstDirectory = Path.GetDirectoryName(firstDirectoryFile); if (!string.IsNullOrEmpty(firstDirectory)) { var firstDirectoryResult = fullNames.Where(f => firstDirectory.Equals(Path.GetDirectoryName(f))) - .OrderBy(Path.GetFileName, new NaturalSortComparer()) + .OrderByNatural(Path.GetFileNameWithoutExtension) .FirstOrDefault(); if (!string.IsNullOrEmpty(firstDirectoryResult)) return firstDirectoryResult; @@ -155,7 +173,7 @@ namespace API.Services } var result = fullNames - .OrderBy(Path.GetFileName, new NaturalSortComparer()) + .OrderByNatural(Path.GetFileNameWithoutExtension) .FirstOrDefault(); return string.IsNullOrEmpty(result) ? null : result; @@ -173,7 +191,7 @@ namespace API.Services /// /// File name to use based on context of entity. /// - public string GetCoverImage(string archivePath, string fileName) + public string GetCoverImage(string archivePath, string fileName, string outputDirectory) { if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; try @@ -184,25 +202,24 @@ namespace API.Services case ArchiveLibrary.Default: { using var archive = ZipFile.OpenRead(archivePath); - var entryNames = archive.Entries.Select(e => e.FullName).ToArray(); + var entryNames = archive.Entries.Select(e => e.FullName).ToList(); - var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); + var entryName = FindCoverImageFilename(archivePath, entryNames); var entry = archive.Entries.Single(e => e.FullName == entryName); - using var stream = entry.Open(); - return CreateThumbnail(archivePath + " - " + entry.FullName, stream, fileName); + using var stream = entry.Open(); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); } case ArchiveLibrary.SharpCompress: { using var archive = ArchiveFactory.Open(archivePath); var entryNames = archive.Entries.Where(archiveEntry => !archiveEntry.IsDirectory).Select(e => e.Key).ToList(); - var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); + var entryName = FindCoverImageFilename(archivePath, entryNames); var entry = archive.Entries.Single(e => e.Key == entryName); using var stream = entry.OpenEntryStream(); - - return CreateThumbnail(archivePath + " - " + entry.Key, stream, fileName); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetCoverImage] This archive cannot be read: {ArchivePath}. Defaulting to no cover image", archivePath); @@ -220,6 +237,18 @@ namespace API.Services return string.Empty; } + /// + /// Given a list of image paths (assume within an archive), find the filename that corresponds to the cover + /// + /// + /// + /// + public string FindCoverImageFilename(string archivePath, IList entryNames) + { + var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); + return entryName; + } + /// /// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly /// under extract path and not nested in subfolders. See Flatten method. @@ -235,18 +264,25 @@ namespace API.Services } // TODO: Refactor CreateZipForDownload to return the temp file so we can stream it from temp + /// + /// + /// + /// + /// Temp folder name to use for preparing the files. Will be created and deleted + /// + /// public async Task> CreateZipForDownload(IEnumerable files, string tempFolder) { var dateString = DateTime.Now.ToShortDateString().Replace("/", "_"); - var tempLocation = Path.Join(DirectoryService.TempDirectory, $"{tempFolder}_{dateString}"); - DirectoryService.ExistOrCreate(tempLocation); + var tempLocation = Path.Join(_directoryService.TempDirectory, $"{tempFolder}_{dateString}"); + _directoryService.ExistOrCreate(tempLocation); if (!_directoryService.CopyFilesToDirectory(files, tempLocation)) { throw new KavitaException("Unable to copy files to temp directory archive download."); } - var zipPath = Path.Join(DirectoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"); + var zipPath = Path.Join(_directoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip"); try { ZipFile.CreateFromDirectory(tempLocation, zipPath); @@ -260,25 +296,12 @@ namespace API.Services var fileBytes = await _directoryService.ReadFileAsync(zipPath); - DirectoryService.ClearAndDeleteDirectory(tempLocation); + _directoryService.ClearAndDeleteDirectory(tempLocation); // NOTE: For sending back just zip, just schedule this to be called after the file is returned or let next temp storage cleanup take care of it (new FileInfo(zipPath)).Delete(); return Tuple.Create(fileBytes, zipPath); } - private string CreateThumbnail(string entryName, Stream stream, string fileName) - { - try - { - return ImageService.WriteCoverThumbnail(stream, fileName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {EntryName}. Defaulting to no cover image", entryName); - } - - return string.Empty; - } /// /// Test if the archive path exists and an archive @@ -322,7 +345,12 @@ namespace API.Services return null; } - public ComicInfo GetComicInfo(string archivePath) + /// + /// This can be null if nothing is found or any errors occur during access + /// + /// + /// + public ComicInfo? GetComicInfo(string archivePath) { if (!IsValidArchive(archivePath)) return null; @@ -336,7 +364,7 @@ namespace API.Services case ArchiveLibrary.Default: { using var archive = ZipFile.OpenRead(archivePath); - var entry = archive.Entries.SingleOrDefault(x => + var entry = archive.Entries.FirstOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName) && Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename && !Path.GetFileNameWithoutExtension(x.Name) @@ -346,7 +374,9 @@ namespace API.Services { using var stream = entry.Open(); var serializer = new XmlSerializer(typeof(ComicInfo)); - return (ComicInfo) serializer.Deserialize(stream); + var info = (ComicInfo) serializer.Deserialize(stream); + ComicInfo.CleanComicInfo(info); + return info; } break; @@ -354,7 +384,7 @@ namespace API.Services case ArchiveLibrary.SharpCompress: { using var archive = ArchiveFactory.Open(archivePath); - return FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory + var info = FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory && !Parser.Parser .HasBlacklistedFolderInPath( Path.GetDirectoryName( @@ -365,6 +395,9 @@ namespace API.Services .Parser .MacOsMetadataFileStartsWith) && Parser.Parser.IsXml(entry.Key))); + ComicInfo.CleanComicInfo(info); + + return info; } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath); @@ -385,9 +418,9 @@ namespace API.Services } - private static void ExtractArchiveEntities(IEnumerable entries, string extractPath) + private void ExtractArchiveEntities(IEnumerable entries, string extractPath) { - DirectoryService.ExistOrCreate(extractPath); + _directoryService.ExistOrCreate(extractPath); foreach (var entry in entries) { entry.WriteToDirectory(extractPath, new ExtractionOptions() @@ -400,7 +433,7 @@ namespace API.Services private void ExtractArchiveEntries(ZipArchive archive, string extractPath) { - // NOTE: In cases where we try to extract, but there are InvalidPathChars, we need to inform the user + // TODO: In cases where we try to extract, but there are InvalidPathChars, we need to inform the user (throw exception, let middleware inform user) var needsFlattening = ArchiveNeedsFlattening(archive); if (!archive.HasFiles() && !needsFlattening) return; @@ -408,7 +441,7 @@ namespace API.Services if (!needsFlattening) return; _logger.LogDebug("Extracted archive is nested in root folder, flattening..."); - new DirectoryInfo(extractPath).Flatten(); + _directoryService.Flatten(extractPath); } /// @@ -447,10 +480,10 @@ namespace API.Services break; } case ArchiveLibrary.NotSupported: - _logger.LogWarning("[ExtractArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning("[ExtractArchive] This archive cannot be read: {ArchivePath}", archivePath); return; default: - _logger.LogWarning("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _logger.LogWarning("[ExtractArchive] There was an exception when reading archive stream: {ArchivePath}", archivePath); return; } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 137f21dca..871b7dd32 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -1,17 +1,13 @@ using System; using System.Collections.Generic; -using System.Drawing; -using System.Drawing.Imaging; using System.IO; using System.Linq; -using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Web; using API.Data.Metadata; using API.Entities.Enums; -using API.Interfaces.Services; using API.Parser; using Docnet.Core; using Docnet.Core.Converters; @@ -21,21 +17,54 @@ using ExCSS; using HtmlAgilityPack; using Microsoft.Extensions.Logging; using Microsoft.IO; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; using VersOne.Epub; +using Image = SixLabors.ImageSharp.Image; 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. + void ExtractPdfImages(string fileFilePath, string targetDirectory); + + Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page); + } + public class BookService : 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"; - public BookService(ILogger logger) + public BookService(ILogger logger, IDirectoryService directoryService, IImageService imageService) { _logger = logger; - + _directoryService = directoryService; + _imageService = imageService; } private static bool HasClickableHrefPart(HtmlNode anchor) @@ -141,13 +170,10 @@ namespace API.Services } stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); - var importMatches = Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml); - foreach (Match match in importMatches) - { - if (!match.Success) continue; - var importFile = match.Groups["Filename"].Value; - stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); - } + + EscapeCSSImportReferences(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); @@ -174,6 +200,26 @@ namespace API.Services return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); } + private static void EscapeCSSImportReferences(ref string stylesheetHtml, string apiBase, string prepend) + { + foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) + { + 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) + { + foreach (Match match in Parser.Parser.FontSrcUrlRegex.Matches(stylesheetHtml)) + { + 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) { var matches = Parser.Parser.CssImageUrlRegex.Matches(stylesheetHtml); @@ -189,6 +235,139 @@ namespace API.Services } } + private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) + { + var images = doc.DocumentNode.SelectNodes("//img"); + if (images != null) + { + foreach (var image in images) + { + if (image.Name != "img") continue; + + // Need to do for xlink:href + if (image.Attributes["src"] != null) + { + var imageFile = image.Attributes["src"].Value; + if (!book.Content.Images.ContainsKey(imageFile)) + { + // TODO: Refactor the Key code to a method to allow the hacks to be tested + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) + { + imageFile = correctedKey; + } else if (imageFile.StartsWith("..")) + { + // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg + correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty))); + if (correctedKey != null) + { + imageFile = correctedKey; + } + } + + + + } + + image.Attributes.Remove("src"); + image.Attributes.Add("src", $"{apiBase}" + imageFile); + } + } + } + + images = doc.DocumentNode.SelectNodes("//image"); + if (images != null) + { + foreach (var image in images) + { + if (image.Name != "image") continue; + + if (image.Attributes["xlink:href"] != null) + { + var imageFile = image.Attributes["xlink:href"].Value; + if (!book.Content.Images.ContainsKey(imageFile)) + { + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) + { + imageFile = correctedKey; + } + } + + image.Attributes.Remove("xlink:href"); + image.Attributes.Add("xlink:href", $"{apiBase}" + 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; + + 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) + { + var anchors = doc.DocumentNode.SelectNodes("//a"); + if (anchors != null) + { + foreach (var anchor in anchors) + { + BookService.UpdateLinks(anchor, mappings, page); + } + } + } + + 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) + { + var styleContent = await ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); + body.PrependChild(HtmlNode.CreateNode($"")); + } + } + + var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); + if (styleNodes != null) + { + foreach (var styleLinks in styleNodes) + { + var key = BookService.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 styleContent = await ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase, + book.Content.Css[key].FileName, book); + if (styleContent != null) + { + body.PrependChild(HtmlNode.CreateNode($"")); + } + } + } + } + public ComicInfo GetComicInfo(string filePath) { if (!IsValidFile(filePath) || Parser.Parser.IsPdf(filePath)) return null; @@ -202,10 +381,13 @@ namespace API.Services var info = new ComicInfo() { Summary = epubBook.Schema.Package.Metadata.Description, - Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators), + Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.Parser.CleanAuthor(c.Creator))), Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), Month = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Month : 0, Year = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Year : 0, + Title = epubBook.Title, + Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), + }; // Parse tags not exposed via Library foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) @@ -215,6 +397,9 @@ namespace API.Services case "calibre:rating": info.UserRating = float.Parse(metadataItem.Content); break; + case "calibre:title_sort": + info.TitleSort = metadataItem.Content; + break; } } @@ -305,8 +490,6 @@ namespace API.Services { using var epubBook = EpubReader.OpenBook(filePath); - // If the epub has the following tags, we can group the books as Volumes - // // // // If all three are present, we can take that over dc:title and format as: @@ -323,6 +506,7 @@ namespace API.Services 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) @@ -338,6 +522,7 @@ namespace API.Services break; case "calibre:title_sort": specialName = metadataItem.Content; + titleSort = metadataItem.Content; break; } @@ -363,18 +548,26 @@ namespace API.Services { specialName = epubBook.Title; } - return new ParserInfo() + var info = new ParserInfo() { Chapters = Parser.Parser.DefaultChapter, Edition = string.Empty, Format = MangaFormat.Epub, Filename = Path.GetFileName(filePath), - Title = specialName.Trim(), + 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)) + { + info.SeriesSort = titleSort; + } + + return info; } } catch (Exception) @@ -392,7 +585,7 @@ namespace API.Services FullFilePath = filePath, IsSpecial = false, Series = epubBook.Title.Trim(), - Volumes = Parser.Parser.DefaultVolume + Volumes = Parser.Parser.DefaultVolume, }; } catch (Exception ex) @@ -403,31 +596,46 @@ namespace API.Services return null; } - private static void AddBytesToBitmap(Bitmap bmp, byte[] rawBytes) - { - var rect = new Rectangle(0, 0, bmp.Width, bmp.Height); - - var bmpData = bmp.LockBits(rect, ImageLockMode.WriteOnly, bmp.PixelFormat); - var pNative = bmpData.Scan0; - - Marshal.Copy(rawBytes, 0, pNative, rawBytes.Length); - bmp.UnlockBits(bmpData); - } - + /// + /// 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); + _directoryService.ExistOrCreate(targetDirectory); using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920)); var pages = docReader.GetPageCount(); - using var stream = StreamManager.GetStream("BookService.GetPdfPage"); - for (var pageNumber = 0; pageNumber < pages; pageNumber++) + Parallel.For(0, pages, pageNumber => { + 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); - } + }); + } + + /// + /// 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); + + RewriteAnchors(page, doc, mappings); + + ScopeImages(doc, book, apiBase); + + return PrepareFinalHtml(doc, body); } /// @@ -436,13 +644,13 @@ namespace API.Services /// /// Name of the new file. /// - public string GetCoverImage(string fileFilePath, string fileName) + public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory) { if (!IsValidFile(fileFilePath)) return string.Empty; if (Parser.Parser.IsPdf(fileFilePath)) { - return GetPdfCoverImage(fileFilePath, fileName); + return GetPdfCoverImage(fileFilePath, fileName, outputDirectory); } using var epubBook = EpubReader.OpenBook(fileFilePath); @@ -458,7 +666,7 @@ namespace API.Services if (coverImageContent == null) return string.Empty; using var stream = coverImageContent.GetContentStream(); - return ImageService.WriteCoverThumbnail(stream, fileName); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); } catch (Exception ex) { @@ -469,7 +677,7 @@ namespace API.Services } - private string GetPdfCoverImage(string fileFilePath, string fileName) + private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory) { try { @@ -479,7 +687,7 @@ namespace API.Services using var stream = StreamManager.GetStream("BookService.GetPdfPage"); GetPdfPage(docReader, 0, stream); - return ImageService.WriteCoverThumbnail(stream, fileName); + return _imageService.WriteCoverThumbnail(stream, fileName, outputDirectory); } catch (Exception ex) @@ -498,15 +706,10 @@ namespace API.Services var rawBytes = pageReader.GetImage(new NaiveTransparencyRemover()); var width = pageReader.GetPageWidth(); var height = pageReader.GetPageHeight(); - using var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb); - AddBytesToBitmap(bmp, rawBytes); - // Removes 1px margin on left/right side after bitmap is copied out - for (var y = 0; y < bmp.Height; y++) - { - bmp.SetPixel(bmp.Width - 1, y, bmp.GetPixel(bmp.Width - 2, y)); - } + var image = Image.LoadPixelData(rawBytes, width, height); + stream.Seek(0, SeekOrigin.Begin); - bmp.Save(stream, ImageFormat.Jpeg); + image.SaveAsPng(stream); stream.Seek(0, SeekOrigin.Begin); } diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index a64bde675..0b6c4aa0d 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,46 +1,54 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data; using API.Entities; using API.Entities.Enums; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; using Microsoft.Extensions.Logging; 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); + string GetCachedPagePath(Chapter chapter, int page); + string GetCachedEpubFile(int chapterId, Chapter chapter); + public void ExtractChapterFiles(string extractPath, IReadOnlyList files); + } public class CacheService : ICacheService { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; - private readonly IArchiveService _archiveService; private readonly IDirectoryService _directoryService; - private readonly IBookService _bookService; + private readonly IReadingItemService _readingItemService; private readonly NumericComparer _numericComparer; - public CacheService(ILogger logger, IUnitOfWork unitOfWork, IArchiveService archiveService, - IDirectoryService directoryService, IBookService bookService) + public CacheService(ILogger logger, IUnitOfWork unitOfWork, + IDirectoryService directoryService, IReadingItemService readingItemService) { _logger = logger; _unitOfWork = unitOfWork; - _archiveService = archiveService; _directoryService = directoryService; - _bookService = bookService; + _readingItemService = readingItemService; _numericComparer = new NumericComparer(); } - public void EnsureCacheDirectory() - { - if (!DirectoryService.ExistOrCreate(DirectoryService.CacheDirectory)) - { - _logger.LogError("Cache directory {CacheDirectory} is not accessible or does not exist. Creating...", DirectoryService.CacheDirectory); - } - } - /// /// Returns the full path to the cached epub file. If the file does not exist, will fallback to the original. /// @@ -50,8 +58,8 @@ namespace API.Services public string GetCachedEpubFile(int chapterId, Chapter chapter) { var extractPath = GetCachePath(chapterId); - var path = Path.Join(extractPath, Path.GetFileName(chapter.Files.First().FilePath)); - if (!(new FileInfo(path).Exists)) + 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; } @@ -62,14 +70,14 @@ namespace API.Services /// Caches the files for the given chapter to CacheDirectory /// /// - /// This will always return the Chapter for the chpaterId + /// This will always return the Chapter for the chapterId public async Task Ensure(int chapterId) { - EnsureCacheDirectory(); + _directoryService.ExistOrCreate(_directoryService.CacheDirectory); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var extractPath = GetCachePath(chapterId); - if (!Directory.Exists(extractPath)) + if (!_directoryService.Exists(extractPath)) { var files = chapter.Files.ToList(); ExtractChapterFiles(extractPath, files); @@ -90,22 +98,12 @@ namespace API.Services var removeNonImages = true; var fileCount = files.Count; var extraPath = ""; - var extractDi = new DirectoryInfo(extractPath); + var extractDi = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(extractPath); if (files.Count > 0 && files[0].Format == MangaFormat.Image) { - DirectoryService.ExistOrCreate(extractPath); - if (files.Count == 1) - { - _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath); - } - else - { - DirectoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath, - Parser.Parser.ImageFileExtensions); - } - - extractDi.Flatten(); + _readingItemService.Extract(files[0].FilePath, extractPath, MangaFormat.Image, files.Count); + _directoryService.Flatten(extractDi.FullName); } foreach (var file in files) @@ -117,63 +115,37 @@ namespace API.Services if (file.Format == MangaFormat.Archive) { - _archiveService.ExtractArchive(file.FilePath, Path.Join(extractPath, extraPath)); + _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format); } else if (file.Format == MangaFormat.Pdf) { - _bookService.ExtractPdfImages(file.FilePath, Path.Join(extractPath, extraPath)); + _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format); } else if (file.Format == MangaFormat.Epub) { removeNonImages = false; - DirectoryService.ExistOrCreate(extractPath); + _directoryService.ExistOrCreate(extractPath); _directoryService.CopyFileToDirectory(files[0].FilePath, extractPath); } } - extractDi.Flatten(); + _directoryService.Flatten(extractDi.FullName); if (removeNonImages) { - extractDi.RemoveNonImages(); + _directoryService.RemoveNonImages(extractDi.FullName); } } - - public void Cleanup() - { - _logger.LogInformation("Performing cleanup of Cache directory"); - EnsureCacheDirectory(); - - try - { - DirectoryService.ClearDirectory(DirectoryService.CacheDirectory); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); - } - - _logger.LogInformation("Cache directory purged"); - } - /// /// Removes the cached files and folders for a set of chapterIds /// /// public void CleanupChapters(IEnumerable chapterIds) { - _logger.LogInformation("Running Cache cleanup on Chapters"); - foreach (var chapter in chapterIds) { - var di = new DirectoryInfo(GetCachePath(chapter)); - if (di.Exists) - { - di.Delete(true); - } - + _directoryService.ClearDirectory(GetCachePath(chapter)); } - _logger.LogInformation("Cache directory purged"); } @@ -184,46 +156,32 @@ namespace API.Services /// private string GetCachePath(int chapterId) { - return Path.GetFullPath(Path.Join(DirectoryService.CacheDirectory, $"{chapterId}/")); + return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{chapterId}/")); } - public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page) + /// + /// 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 pagesSoFar = 0; - var chapterFiles = chapter.Files ?? await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); - foreach (var mangaFile in chapterFiles) + var path = GetCachePath(chapter.Id); + var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions); + files = files + .AsEnumerable() + .OrderByNatural(Path.GetFileNameWithoutExtension) + .ToArray(); + + if (files.Length == 0) { - if (page <= (mangaFile.Pages + pagesSoFar)) - { - var path = GetCachePath(chapter.Id); - var files = DirectoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions); - Array.Sort(files, _numericComparer); - - if (files.Length == 0) - { - return (files.ElementAt(0), mangaFile); - } - - // Since array is 0 based, we need to keep that in account (only affects last image) - if (page == files.Length) - { - return (files.ElementAt(page - 1 - pagesSoFar), mangaFile); - } - - if (mangaFile.Format == MangaFormat.Image && mangaFile.Pages == 1) - { - // Each file is one page, meaning we should just get element at page - return (files.ElementAt(page), mangaFile); - } - - return (files.ElementAt(page - pagesSoFar), mangaFile); - } - - pagesSoFar += mangaFile.Pages; + return string.Empty; } - return (string.Empty, null); + // 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); } } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 04240245a..bf3c01d25 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -1,31 +1,88 @@ -using System; +using System; +using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using API.Interfaces.Services; +using API.Comparators; +using API.Extensions; using Microsoft.Extensions.Logging; namespace API.Services { + public interface IDirectoryService + { + IFileSystem FileSystem { get; } + string CacheDirectory { get; } + string CoverImageDirectory { get; } + string LogDirectory { get; } + string TempDirectory { get; } + string ConfigDirectory { 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); + } public class DirectoryService : IDirectoryService { - private readonly ILogger _logger; - private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "temp"); - public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "logs"); - public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "cache"); - public static readonly string CoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "covers"); - public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); - public static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config"); + 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; } + private readonly ILogger _logger; - public DirectoryService(ILogger logger) + private static readonly Regex ExcludeDirectories = new Regex( + @"@eaDir|\.DS_Store|\.qpkg", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); + + public DirectoryService(ILogger logger, IFileSystem fileSystem) { - _logger = logger; + _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"); } /// @@ -36,16 +93,16 @@ namespace API.Services /// Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files. /// SearchOption to use, defaults to TopDirectoryOnly /// List of file paths - private static IEnumerable GetFilesWithCertainExtensions(string path, + private IEnumerable GetFilesWithCertainExtensions(string path, string searchPatternExpression = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) { - if (!Directory.Exists(path)) return ImmutableList.Empty; + if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); - return Directory.EnumerateFiles(path, "*", searchOption) + return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) .Where(file => - reSearchPattern.IsMatch(Path.GetExtension(file)) && !Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); + reSearchPattern.IsMatch(FileSystem.Path.GetExtension(file)) && !FileSystem.Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); } @@ -57,17 +114,17 @@ namespace API.Services /// /// /// - public static IEnumerable GetFoldersTillRoot(string rootPath, string fullPath) + public IEnumerable GetFoldersTillRoot(string rootPath, string fullPath) { - var separator = Path.AltDirectorySeparatorChar; - if (fullPath.Contains(Path.DirectorySeparatorChar)) + var separator = FileSystem.Path.AltDirectorySeparatorChar; + if (fullPath.Contains(FileSystem.Path.DirectorySeparatorChar)) { - fullPath = fullPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + fullPath = fullPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); } if (rootPath.Contains(Path.DirectorySeparatorChar)) { - rootPath = rootPath.Replace(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + rootPath = rootPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); } @@ -76,14 +133,14 @@ namespace API.Services 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 (Path.GetExtension(path) != string.Empty) + if (FileSystem.Path.GetExtension(path) != string.Empty) { path = path.Substring(0, path.LastIndexOf(separator)); } - while (Path.GetDirectoryName(path) != Path.GetDirectoryName(root)) + while (FileSystem.Path.GetDirectoryName(path) != Path.GetDirectoryName(root)) { - var folder = new DirectoryInfo(path).Name; + var folder = FileSystem.DirectoryInfo.FromDirectoryName(path).Name; paths.Add(folder); path = path.Substring(0, path.LastIndexOf(separator)); } @@ -91,35 +148,60 @@ namespace API.Services return paths; } + /// + /// Does Directory Exist + /// + /// + /// public bool Exists(string directory) { - var di = new DirectoryInfo(directory); - return di.Exists; + var di = FileSystem.DirectoryInfo.FromDirectoryName(directory); + return di.Exists; } - public static IEnumerable GetFiles(string path, string searchPatternExpression = "", - SearchOption searchOption = SearchOption.TopDirectoryOnly) + /// + /// 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 (searchPatternExpression != string.Empty) + if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; + + if (fileNameRegex != string.Empty) { - if (!Directory.Exists(path)) return ImmutableList.Empty; - var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); - return Directory.EnumerateFiles(path, "*", searchOption) + var reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase); + return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) .Where(file => - reSearchPattern.IsMatch(file) && !file.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); + { + var fileName = FileSystem.Path.GetFileName(file); + return reSearchPattern.IsMatch(fileName) && + !fileName.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith); + }); } - return !Directory.Exists(path) ? Array.Empty() : Directory.GetFiles(path); + return FileSystem.Directory.EnumerateFiles(path, "*", searchOption).Where(file => + !FileSystem.Path.GetFileName(file).StartsWith(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 = new FileInfo(fullFilePath); + var fileInfo = FileSystem.FileInfo.FromFileName(fullFilePath); if (fileInfo.Exists) { - fileInfo.CopyTo(Path.Join(targetDirectory, fileInfo.Name), true); + ExistOrCreate(targetDirectory); + fileInfo.CopyTo(FileSystem.Path.Join(targetDirectory, fileInfo.Name), true); } } catch (Exception ex) @@ -129,19 +211,19 @@ namespace API.Services } /// - /// Copies a Directory with all files and subdirectories to a target location + /// Copies all files and subdirectories within a directory to a target location /// - /// - /// - /// Defaults to *, meaning all files - /// - /// - public static bool CopyDirectoryToDirectory(string sourceDirName, string destDirName, string searchPattern = "") + /// 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 = new DirectoryInfo(sourceDirName); + var dir = FileSystem.DirectoryInfo.FromDirectoryName(sourceDirName); if (!dir.Exists) { @@ -156,17 +238,17 @@ namespace API.Services ExistOrCreate(destDirName); // Get the files in the directory and copy them to the new location. - var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => new FileInfo(n)); + var files = GetFilesWithExtension(dir.FullName, searchPattern).Select(n => FileSystem.FileInfo.FromFileName(n)); foreach (var file in files) { - var tempPath = Path.Combine(destDirName, file.Name); + 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 = Path.Combine(destDirName, subDir.Name); + var tempPath = FileSystem.Path.Combine(destDirName, subDir.Name); CopyDirectoryToDirectory(subDir.FullName, tempPath); } @@ -178,19 +260,30 @@ namespace API.Services /// /// /// - public static bool IsDriveMounted(string path) + public bool IsDriveMounted(string path) { - return new DirectoryInfo(Path.GetPathRoot(path) ?? string.Empty).Exists; + return FileSystem.DirectoryInfo.FromDirectoryName(FileSystem.Path.GetPathRoot(path) ?? string.Empty).Exists; } - public static string[] GetFilesWithExtension(string path, string searchPatternExpression = "") - { - if (searchPatternExpression != string.Empty) - { - return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray(); - } - return !Directory.Exists(path) ? Array.Empty() : Directory.GetFiles(path); + /// + /// 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); } /// @@ -198,9 +291,9 @@ namespace API.Services /// /// /// Total bytes - public static long GetTotalSize(IEnumerable paths) + public long GetTotalSize(IEnumerable paths) { - return paths.Sum(path => new FileInfo(path).Length); + return paths.Sum(path => FileSystem.FileInfo.FromFileName(path).Length); } /// @@ -208,13 +301,13 @@ namespace API.Services /// /// /// - public static bool ExistOrCreate(string directoryPath) + public bool ExistOrCreate(string directoryPath) { - var di = new DirectoryInfo(directoryPath); + var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); if (di.Exists) return true; try { - Directory.CreateDirectory(directoryPath); + FileSystem.Directory.CreateDirectory(directoryPath); } catch (Exception) { @@ -227,11 +320,11 @@ namespace API.Services /// Deletes all files within the directory, then the directory itself. ///
/// - public static void ClearAndDeleteDirectory(string directoryPath) + public void ClearAndDeleteDirectory(string directoryPath) { - if (!Directory.Exists(directoryPath)) return; + if (!FileSystem.Directory.Exists(directoryPath)) return; - DirectoryInfo di = new DirectoryInfo(directoryPath); + var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); ClearDirectory(directoryPath); @@ -239,13 +332,13 @@ namespace API.Services } /// - /// Deletes all files within the directory. + /// Deletes all files and folders within the directory path /// /// /// - public static void ClearDirectory(string directoryPath) + public void ClearDirectory(string directoryPath) { - var di = new DirectoryInfo(directoryPath); + var di = FileSystem.DirectoryInfo.FromDirectoryName(directoryPath); if (!di.Exists) return; foreach (var file in di.EnumerateFiles()) @@ -265,7 +358,7 @@ namespace API.Services /// /// An optional string to prepend to the target file's name /// - public static bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "", ILogger logger = null) + public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "") { ExistOrCreate(directoryPath); string currentFile = null; @@ -274,36 +367,36 @@ namespace API.Services foreach (var file in filePaths) { currentFile = file; - var fileInfo = new FileInfo(file); + var fileInfo = FileSystem.FileInfo.FromFileName(file); if (fileInfo.Exists) { - fileInfo.CopyTo(Path.Join(directoryPath, prepend + fileInfo.Name)); + fileInfo.CopyTo(FileSystem.Path.Join(directoryPath, prepend + fileInfo.Name)); } else { - logger?.LogWarning("Tried to copy {File} but it doesn't exist", file); + _logger.LogWarning("Tried to copy {File} but it doesn't exist", file); } } } catch (Exception ex) { - logger?.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); + _logger.LogError(ex, "Unable to copy {File} to {DirectoryPath}", currentFile, directoryPath); return false; } return true; } - public bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = "") - { - return CopyFilesToDirectory(filePaths, directoryPath, prepend, _logger); - } - + /// + /// Lists all directories in a root path. Will exclude Hidden or System directories. + /// + /// + /// public IEnumerable ListDirectory(string rootPath) { - if (!Directory.Exists(rootPath)) return ImmutableList.Empty; + if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList.Empty; - var di = new DirectoryInfo(rootPath); + var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath); var dirs = di.GetDirectories() .Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System))) .Select(d => d.Name).ToImmutableList(); @@ -311,20 +404,26 @@ namespace API.Services return dirs; } + /// + /// Reads a file's into byte[]. Returns empty array if file doesn't exist. + /// + /// + /// public async Task ReadFileAsync(string path) { - if (!File.Exists(path)) return Array.Empty(); - return await File.ReadAllBytesAsync(path); + if (!FileSystem.File.Exists(path)) return Array.Empty(); + return await FileSystem.File.ReadAllBytesAsync(path); } /// - /// Finds the highest directories from a set of MangaFiles + /// 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 static Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths) + public Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths) { var stopLookingForDirectories = false; var dirs = new Dictionary(); @@ -365,20 +464,19 @@ namespace API.Services /// Regex pattern to search against /// /// - public static int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger) + public int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger) { - //Count of files traversed and timer for diagnostic output + //Count of files traversed and timer for diagnostic output var fileCount = 0; - // Determine whether to parallelize file processing on each folder based on processor count. - //var procCount = Environment.ProcessorCount; // Data structure to hold names of subfolders to be examined for files. var dirs = new Stack(); - if (!Directory.Exists(root)) { - throw new ArgumentException("The directory doesn't exist"); + if (!FileSystem.Directory.Exists(root)) { + throw new ArgumentException("The directory doesn't exist"); } + dirs.Push(root); while (dirs.Count > 0) { @@ -387,7 +485,7 @@ namespace API.Services string[] files; try { - subDirs = Directory.GetDirectories(currentDir).Where(path => ExcludeDirectories.Matches(path).Count == 0); + subDirs = FileSystem.Directory.GetDirectories(currentDir).Where(path => ExcludeDirectories.Matches(path).Count == 0); } // Thrown if we do not have discovery permission on the directory. catch (UnauthorizedAccessException e) { @@ -403,7 +501,7 @@ namespace API.Services } try { - files = GetFilesWithCertainExtensions(currentDir, searchPattern) + files = GetFilesWithCertainExtensions(currentDir, searchPattern) .ToArray(); } catch (UnauthorizedAccessException e) { @@ -423,22 +521,7 @@ namespace API.Services // Otherwise, execute sequentially. Files are opened and processed // synchronously but this could be modified to perform async I/O. try { - // if (files.Length < procCount) { - // foreach (var file in files) { - // action(file); - // fileCount++; - // } - // } - // else { - // Parallel.ForEach(files, () => 0, (file, _, localCount) => - // { action(file); - // return ++localCount; - // }, - // (c) => { - // Interlocked.Add(ref fileCount, c); - // }); - // } - foreach (var file in files) { + foreach (var file in files) { action(file); fileCount++; } @@ -448,6 +531,7 @@ namespace API.Services if (ex is UnauthorizedAccessException) { // Here we just output a message and go on. Console.WriteLine(ex.Message); + _logger.LogError(ex, "Unauthorized access on file"); return true; } // Handle other exceptions here if necessary... @@ -469,13 +553,13 @@ namespace API.Services /// Attempts to delete the files passed to it. Swallows exceptions. ///
/// Full path of files to delete - public static void DeleteFiles(IEnumerable files) + public void DeleteFiles(IEnumerable files) { foreach (var file in files) { try { - new FileInfo(file).Delete(); + FileSystem.FileInfo.FromFileName(file).Delete(); } catch (Exception) { @@ -538,5 +622,101 @@ namespace API.Services // 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 => !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) + { + try + { + ExistOrCreate(directoryName); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(directoryName, "test.txt"), + string.Empty); + } + catch (Exception ex) + { + ClearAndDeleteDirectory(directoryName); + return false; + } + + ClearAndDeleteDirectory(directoryName); + return true; + } + + + private void FlattenDirectory(IDirectoryInfo root, IDirectoryInfo directory, ref int directoryIndex) + { + if (!root.FullName.Equals(directory.FullName)) + { + var fileIndex = 1; + + foreach (var file in directory.EnumerateFiles().OrderByNatural(file => file.FullName)) + { + if (file.Directory == null) continue; + var paddedIndex = 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}_{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)) + { + FlattenDirectory(root, subDirectory, ref directoryIndex); + } + } } } diff --git a/API/Services/DownloadService.cs b/API/Services/DownloadService.cs index 7d0f56b3d..51830f0ab 100644 --- a/API/Services/DownloadService.cs +++ b/API/Services/DownloadService.cs @@ -3,56 +3,54 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; -using API.Interfaces.Services; using Microsoft.AspNetCore.StaticFiles; -namespace API.Services +namespace API.Services; + +public interface IDownloadService { - public interface IDownloadService + Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable files); + string GetContentTypeFromFile(string filepath); +} +public class DownloadService : IDownloadService +{ + private readonly IDirectoryService _directoryService; + private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider(); + + public DownloadService(IDirectoryService directoryService) { - Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable files); - string GetContentTypeFromFile(string filepath); + _directoryService = directoryService; } - public class DownloadService : IDownloadService + + /// + /// Downloads the first file in the file enumerable for download + /// + /// + /// + public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable files) { - private readonly IDirectoryService _directoryService; - private readonly FileExtensionContentTypeProvider _fileTypeProvider = new FileExtensionContentTypeProvider(); + var firstFile = files.Select(c => c.FilePath).First(); + return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile)); + } - public DownloadService(IDirectoryService directoryService) + public string GetContentTypeFromFile(string filepath) + { + // Figures out what the content type should be based on the file name. + if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType)) { - _directoryService = directoryService; - } - - /// - /// Downloads the first file in the file enumerable for download - /// - /// - /// - public async Task<(byte[], string, string)> GetFirstFileDownload(IEnumerable files) - { - var firstFile = files.Select(c => c.FilePath).First(); - return (await _directoryService.ReadFileAsync(firstFile), GetContentTypeFromFile(firstFile), Path.GetFileName(firstFile)); - } - - public string GetContentTypeFromFile(string filepath) - { - // Figures out what the content type should be based on the file name. - if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType)) + contentType = Path.GetExtension(filepath).ToLowerInvariant() switch { - contentType = Path.GetExtension(filepath).ToLowerInvariant() switch - { - ".cbz" => "application/zip", - ".cbr" => "application/vnd.rar", - ".cb7" => "application/x-compressed", - ".epub" => "application/epub+zip", - ".7z" => "application/x-7z-compressed", - ".7zip" => "application/x-7z-compressed", - ".pdf" => "application/pdf", - _ => contentType - }; - } - - return contentType; + ".cbz" => "application/zip", + ".cbr" => "application/vnd.rar", + ".cb7" => "application/x-compressed", + ".epub" => "application/epub+zip", + ".7z" => "application/x-7z-compressed", + ".7zip" => "application/x-7z-compressed", + ".pdf" => "application/pdf", + _ => contentType + }; } + + return contentType; } } diff --git a/API/Services/FileService.cs b/API/Services/FileService.cs new file mode 100644 index 000000000..a4194b820 --- /dev/null +++ b/API/Services/FileService.cs @@ -0,0 +1,46 @@ +using System; +using System.IO.Abstractions; +using API.Extensions; + +namespace API.Services; + +public interface IFileService +{ + IFileSystem GetFileSystem(); + bool HasFileBeenModifiedSince(string filePath, DateTime time); + bool Exists(string filePath); +} + +public class FileService : IFileService +{ + private readonly IFileSystem _fileSystem; + + public FileService(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public FileService() : this(fileSystem: new FileSystem()) { } + + public IFileSystem GetFileSystem() + { + return _fileSystem; + } + + /// + /// If the File on disk's last modified time is after passed time + /// + /// This has a resolution to the minute. Will ignore seconds and milliseconds + /// Full qualified path of file + /// + /// + public bool HasFileBeenModifiedSince(string filePath, DateTime time) + { + return !string.IsNullOrEmpty(filePath) && _fileSystem.File.GetLastWriteTime(filePath).Truncate(TimeSpan.TicksPerMinute) > time.Truncate(TimeSpan.TicksPerMinute); + } + + public bool Exists(string filePath) + { + return _fileSystem.File.Exists(filePath); + } +} diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index 486b45513..099c44cc8 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -1,7 +1,6 @@ using System; using System.Threading; using System.Threading.Tasks; -using API.Interfaces; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -21,7 +20,7 @@ namespace API.Services.HostedServices using var scope = _provider.CreateScope(); var taskScheduler = scope.ServiceProvider.GetRequiredService(); - taskScheduler.ScheduleTasks(); + await taskScheduler.ScheduleTasks(); taskScheduler.ScheduleUpdaterTasks(); try diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 7f663c37d..29a528e71 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -1,21 +1,32 @@ using System; using System.IO; -using System.Linq; -using API.Comparators; -using API.Entities; -using API.Interfaces.Services; using Microsoft.Extensions.Logging; using NetVips; -namespace API.Services -{ +namespace API.Services; - public class ImageService : IImageService - { +public interface IImageService +{ + void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1); + string GetCoverImage(string path, string fileName, string outputDirectory); + + /// + /// Creates a Thumbnail version of a base64 image + /// + /// base64 encoded image + /// File name with extension of the file. This will always write to + string CreateThumbnailFromBase64(string encodedImage, string fileName); + + string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory); +} + +public class ImageService : IImageService +{ private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; public const string ChapterCoverImageRegex = @"v\d+_c\d+"; - public const string SeriesCoverImageRegex = @"seres\d+"; - public const string CollectionTagCoverImageRegex = @"tag\d+"; + public const string SeriesCoverImageRegex = @"series_\d+"; + public const string CollectionTagCoverImageRegex = @"tag_\d+"; /// @@ -23,60 +34,40 @@ namespace API.Services /// private const int ThumbnailWidth = 320; - public ImageService(ILogger logger) + public ImageService(ILogger logger, IDirectoryService directoryService) { - _logger = logger; + _logger = logger; + _directoryService = directoryService; } - /// - /// Finds the first image in the directory of the first file. Does not check for "cover/folder".ext files to override. - /// - /// - /// - public string GetCoverFile(MangaFile file) + public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount) { - var directory = Path.GetDirectoryName(file.FilePath); - if (string.IsNullOrEmpty(directory)) - { - _logger.LogError("Could not find Directory for {File}", file.FilePath); - return null; - } - - var firstImage = DirectoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions) - .OrderBy(f => f, new NaturalSortComparer()).FirstOrDefault(); - - return firstImage; + _directoryService.ExistOrCreate(targetDirectory); + if (fileCount == 1) + { + _directoryService.CopyFileToDirectory(fileFilePath, targetDirectory); + } + else + { + _directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(fileFilePath), targetDirectory, + Parser.Parser.ImageFileExtensions); + } } - public string GetCoverImage(string path, string fileName) + public string GetCoverImage(string path, string fileName, string outputDirectory) { - if (string.IsNullOrEmpty(path)) return string.Empty; + if (string.IsNullOrEmpty(path)) return string.Empty; - try - { - return CreateThumbnail(path, fileName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {ImageFile}. Defaulting to no cover image", path); - } - - return string.Empty; - } - - /// - public string CreateThumbnail(string path, string fileName) - { try { using var thumbnail = Image.Thumbnail(path, ThumbnailWidth); var filename = fileName + ".png"; - thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, filename)); + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; } - catch (Exception e) + catch (Exception ex) { - _logger.LogError(e, "Error creating thumbnail from url"); + _logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {ImageFile}. Defaulting to no cover image", path); } return string.Empty; @@ -89,11 +80,16 @@ namespace API.Services /// Stream to write to disk. Ensure this is rewinded. /// filename to save as without extension /// File name with extension of the file. This will always write to - public static string WriteCoverThumbnail(Stream stream, string fileName) + public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory) { using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); var filename = fileName + ".png"; - thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, fileName + ".png")); + _directoryService.ExistOrCreate(outputDirectory); + try + { + _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + } catch (Exception ex) {/* Swallow exception */} + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; } @@ -105,7 +101,7 @@ namespace API.Services { using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), ThumbnailWidth); var filename = fileName + ".png"; - thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, fileName + ".png")); + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName + ".png")); return filename; } catch (Exception e) @@ -146,5 +142,4 @@ namespace API.Services { return $"tag{tagId}"; } - } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 09161f42a..b6d45c77e 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -5,329 +5,364 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data; using API.Data.Metadata; using API.Data.Repositories; +using API.Data.Scanner; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; -using API.Interfaces; -using API.Interfaces.Services; using API.SignalR; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace API.Services +namespace API.Services; + +public interface IMetadataService { - public class MetadataService : IMetadataService + /// + /// Recalculates metadata for all entities in a library. + /// + /// + /// + Task RefreshMetadata(int libraryId, bool forceUpdate = false); + /// + /// Performs a forced refresh of metadata just for a series and it's nested entities + /// + /// + /// + Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false); +} + +public class MetadataService : IMetadataService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IHubContext _messageHub; + private readonly ICacheHelper _cacheHelper; + private readonly IReadingItemService _readingItemService; + private readonly IDirectoryService _directoryService; + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + public MetadataService(IUnitOfWork unitOfWork, ILogger logger, + IHubContext messageHub, ICacheHelper cacheHelper, + IReadingItemService readingItemService, IDirectoryService directoryService) { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IArchiveService _archiveService; - private readonly IBookService _bookService; - private readonly IImageService _imageService; - private readonly IHubContext _messageHub; - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + _unitOfWork = unitOfWork; + _logger = logger; + _messageHub = messageHub; + _cacheHelper = cacheHelper; + _readingItemService = readingItemService; + _directoryService = directoryService; + } - public MetadataService(IUnitOfWork unitOfWork, ILogger logger, - IArchiveService archiveService, IBookService bookService, IImageService imageService, IHubContext messageHub) - { - _unitOfWork = unitOfWork; - _logger = logger; - _archiveService = archiveService; - _bookService = bookService; - _imageService = imageService; - _messageHub = messageHub; - } - - /// - /// Determines whether an entity should regenerate cover image. - /// - /// If a cover image is locked but the underlying file has been deleted, this will allow regenerating. - /// - /// - /// - /// - /// Directory where cover images are. Defaults to - /// - public static bool ShouldUpdateCoverImage(string coverImage, MangaFile firstFile, bool forceUpdate = false, - bool isCoverLocked = false, string coverImageDirectory = null) - { - if (string.IsNullOrEmpty(coverImageDirectory)) - { - coverImageDirectory = DirectoryService.CoverImageDirectory; - } - - var fileExists = File.Exists(Path.Join(coverImageDirectory, coverImage)); - if (isCoverLocked && fileExists) return false; - if (forceUpdate) return true; - return (firstFile != null && firstFile.HasFileBeenModified()) || !HasCoverImage(coverImage, fileExists); - } - - - private static bool HasCoverImage(string coverImage) - { - return HasCoverImage(coverImage, File.Exists(coverImage)); - } - - private static bool HasCoverImage(string coverImage, bool fileExists) - { - return !string.IsNullOrEmpty(coverImage) && fileExists; - } - - private string GetCoverImage(MangaFile file, int volumeId, int chapterId) - { - file.UpdateLastModified(); - switch (file.Format) - { - case MangaFormat.Pdf: - case MangaFormat.Epub: - return _bookService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); - case MangaFormat.Image: - var coverImage = _imageService.GetCoverFile(file); - return _imageService.GetCoverImage(coverImage, ImageService.GetChapterFormat(chapterId, volumeId)); - case MangaFormat.Archive: - return _archiveService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); - default: - return string.Empty; - } - - } - - /// - /// Updates the metadata for a Chapter - /// - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public bool UpdateMetadata(Chapter chapter, bool forceUpdate) - { - var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - - if (ShouldUpdateCoverImage(chapter.CoverImage, firstFile, forceUpdate, chapter.CoverImageLocked)) - { - _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); - chapter.CoverImage = GetCoverImage(firstFile, chapter.VolumeId, chapter.Id); - return true; - } + /// + /// Updates the metadata for a Chapter + /// + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + private bool UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) + { + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) return false; - } - /// - /// Updates the metadata for a Volume - /// - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public bool UpdateMetadata(Volume volume, bool forceUpdate) + if (firstFile == null) return false; + + _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); + chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format); + + return true; + } + + private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) + { + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; + + firstFile.UpdateLastModified(); + } + + /// + /// Updates the cover image for a Volume + /// + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate) + { + // We need to check if Volume coverImage matches first chapters if forceUpdate is false + if (volume == null || !_cacheHelper.ShouldUpdateCoverImage( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), + null, volume.Created, forceUpdate)) return false; + + volume.Chapters ??= new List(); + var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); + if (firstChapter == null) return false; + + volume.CoverImage = firstChapter.CoverImage; + return true; + } + + /// + /// Updates cover image for Series + /// + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + private void UpdateSeriesCoverImage(Series series, bool forceUpdate) + { + if (series == null) return; + + if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), + null, series.Created, forceUpdate, series.CoverImageLocked)) + return; + + series.Volumes ??= new List(); + var firstCover = series.Volumes.GetCoverImage(series.Format); + string coverImage = null; + if (firstCover == null && series.Volumes.Any()) { - // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null || !ShouldUpdateCoverImage(volume.CoverImage, null, forceUpdate)) return false; - - volume.Chapters ??= new List(); - var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); - if (firstChapter == null) return false; - - volume.CoverImage = firstChapter.CoverImage; - return true; - } - - /// - /// Updates metadata for Series - /// - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public bool UpdateMetadata(Series series, bool forceUpdate) - { - var madeUpdate = false; - if (series == null) return false; - - // NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image - if (ShouldUpdateCoverImage(series.CoverImage, null, forceUpdate, series.CoverImageLocked)) + // If firstCover is null and one volume, the whole series is Chapters under Vol 0. + if (series.Volumes.Count == 1) { - series.Volumes ??= new List(); - var firstCover = series.Volumes.GetCoverImage(series.Format); - string coverImage = null; - if (firstCover == null && series.Volumes.Any()) - { - // If firstCover is null and one volume, the whole series is Chapters under Vol 0. - if (series.Volumes.Count == 1) - { - coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) - .FirstOrDefault(c => !c.IsSpecial)?.CoverImage; - madeUpdate = true; - } - - if (!HasCoverImage(coverImage)) - { - coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) - .FirstOrDefault()?.CoverImage; - madeUpdate = true; - } - } - series.CoverImage = firstCover?.CoverImage ?? coverImage; + coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) + .FirstOrDefault(c => !c.IsSpecial)?.CoverImage; } - return UpdateSeriesSummary(series, forceUpdate) || madeUpdate ; - } - - private bool UpdateSeriesSummary(Series series, bool forceUpdate) - { - // NOTE: This can be problematic when the file changes and a summary already exists, but it is likely - // better to let the user kick off a refresh metadata on an individual Series than having overhead of - // checking File last write time. - if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return false; - - var isBook = series.Library.Type == LibraryType.Book; - var firstVolume = series.Volumes.FirstWithChapters(isBook); - var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); - - var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null || (!forceUpdate && !firstFile.HasFileBeenModified())) return false; - if (Parser.Parser.IsPdf(firstFile.FilePath)) return false; - - var comicInfo = GetComicInfo(series.Format, firstFile); - if (string.IsNullOrEmpty(comicInfo?.Summary)) return false; - - series.Summary = comicInfo.Summary; - return true; - } - - private ComicInfo GetComicInfo(MangaFormat format, MangaFile firstFile) - { - if (format is MangaFormat.Archive or MangaFormat.Epub) + if (!_cacheHelper.CoverImageExists(coverImage)) { - return Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetComicInfo(firstFile.FilePath) : _archiveService.GetComicInfo(firstFile.FilePath); + coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) + .FirstOrDefault()?.CoverImage; } - - return null; } + series.CoverImage = firstCover?.CoverImage ?? coverImage; + } - /// - /// Refreshes Metadata for a whole library - /// - /// This can be heavy on memory first run - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) + /// + /// + /// + /// + /// + private void ProcessSeriesMetadataUpdate(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) + { + _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); + try { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); - _logger.LogInformation("[MetadataService] Beginning metadata refresh of {LibraryName}", library.Name); - - var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); - var stopwatch = Stopwatch.StartNew(); - var totalTime = 0L; - _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); - - var i = 0; - for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++, i++) - { - if (chunkInfo.TotalChunks == 0) continue; - totalTime += stopwatch.ElapsedMilliseconds; - stopwatch.Restart(); - _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", - chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); - - var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, - new UserParams() - { - PageNumber = chunk, - PageSize = chunkInfo.ChunkSize - }); - _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); - - Parallel.ForEach(nonLibrarySeries, series => - { - try - { - _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); - var volumeUpdated = false; - foreach (var volume in series.Volumes) - { - var chapterUpdated = false; - foreach (var chapter in volume.Chapters) - { - chapterUpdated = UpdateMetadata(chapter, forceUpdate); - } - - volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); - } - - UpdateMetadata(series, volumeUpdated || forceUpdate); - } - catch (Exception) - { - /* Swallow exception */ - } - }); - - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - _logger.LogInformation( - "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); - - foreach (var series in nonLibrarySeries) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); - } - } - else - { - _logger.LogInformation( - "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); - } - var progress = Math.Max(0F, Math.Min(1F, i * 1F / chunkInfo.TotalChunks)); - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); - } - - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); - - _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); - } - - - /// - /// Refreshes Metadata for a Series. Will always force updates. - /// - /// - /// - public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false) - { - var sw = Stopwatch.StartNew(); - var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); - if (series == null) - { - _logger.LogError("[MetadataService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId); - return; - } - _logger.LogInformation("[MetadataService] Beginning metadata refresh of {SeriesName}", series.Name); - var volumeUpdated = false; + var volumeIndex = 0; + var firstVolumeUpdated = false; foreach (var volume in series.Volumes) { - var chapterUpdated = false; + var firstChapterUpdated = false; // This only needs to be FirstChapter updated + var index = 0; foreach (var chapter in volume.Chapters) { - chapterUpdated = UpdateMetadata(chapter, forceUpdate); + var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); + // If cover was update, either the file has changed or first scan and we should force a metadata update + UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); + if (index == 0 && chapterUpdated) + { + firstChapterUpdated = true; + } + + index++; } - volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); + var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); + if (volumeIndex == 0 && volumeUpdated) + { + firstVolumeUpdated = true; + } + volumeIndex++; } - UpdateMetadata(series, volumeUpdated || forceUpdate); - - - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); - } - - _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); + UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetadataService] There was an exception during updating metadata for {SeriesName} ", series.Name); } } + + + /// + /// Refreshes Metadata for a whole library + /// + /// This can be heavy on memory first run + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) + { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + _logger.LogInformation("[MetadataService] Beginning metadata refresh of {LibraryName}", library.Name); + + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); + + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, + new UserParams() + { + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); + _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + + var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); + + + var seriesIndex = 0; + foreach (var series in nonLibrarySeries) + { + try + { + ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); + } + var index = chunk * seriesIndex; + var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); + seriesIndex++; + } + + await _unitOfWork.CommitAsync(); + foreach (var series in nonLibrarySeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); + } + _logger.LogInformation( + "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); + } + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); + + await RemoveAbandonedMetadataKeys(); + + + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); + } + + private async Task RemoveAbandonedMetadataKeys() + { + await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); + await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + } + + // TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same. + private async Task PerformScan(Library library, bool forceUpdate, Action action) + { + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); + + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + action(chunk, chunkInfo); + + // _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + // chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + // var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, + // new UserParams() + // { + // PageNumber = chunk, + // PageSize = chunkInfo.ChunkSize + // }); + // _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + // + // var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(nonLibrarySeries.Select(s => s.Id).ToArray()); + // var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + // var allGenres = await _unitOfWork.GenreRepository.GetAllGenres(); + // + // + // var seriesIndex = 0; + // foreach (var series in nonLibrarySeries) + // { + // try + // { + // ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate); + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); + // } + // var index = chunk * seriesIndex; + // var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); + // + // await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + // MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); + // seriesIndex++; + // } + + await _unitOfWork.CommitAsync(); + } + } + + + + /// + /// Refreshes Metadata for a Series. Will always force updates. + /// + /// + /// + public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true) + { + var sw = Stopwatch.StartNew(); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + if (series == null) + { + _logger.LogError("[MetadataService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId); + return; + } + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F)); + + var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); + + ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); + + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); + } + + await RemoveAbandonedMetadataKeys(); + + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); + } } diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs new file mode 100644 index 000000000..b9862cf05 --- /dev/null +++ b/API/Services/ReaderService.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Comparators; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Entities; +using API.Extensions; +using Kavita.Common; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IReaderService +{ + void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters); + void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters); + Task SaveReadingProgress(ProgressDto progressDto, int userId); + Task CapPageToChapter(int chapterId, int page); + Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); + Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); +} + +public class ReaderService : IReaderService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly ICacheService _cacheService; + private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + + public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IDirectoryService directoryService, ICacheService cacheService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _directoryService = directoryService; + _cacheService = cacheService; + } + + public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) + { + return Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}")); + } + + /// + /// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit. + /// + /// + /// + /// + public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable chapters) + { + foreach (var chapter in chapters) + { + var userProgress = GetUserProgressForChapter(user, chapter); + + if (userProgress == null) + { + user.Progresses.Add(new AppUserProgress + { + PagesRead = chapter.Pages, + VolumeId = chapter.VolumeId, + SeriesId = seriesId, + ChapterId = chapter.Id + }); + } + else + { + userProgress.PagesRead = chapter.Pages; + userProgress.SeriesId = seriesId; + userProgress.VolumeId = chapter.VolumeId; + } + } + } + + /// + /// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit. + /// + /// + /// + /// + public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable chapters) + { + foreach (var chapter in chapters) + { + var userProgress = GetUserProgressForChapter(user, chapter); + + if (userProgress == null) continue; + + userProgress.PagesRead = 0; + userProgress.SeriesId = seriesId; + userProgress.VolumeId = chapter.VolumeId; + } + } + + /// + /// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit. + /// + /// Must have Progresses populated + /// + /// + private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter) + { + AppUserProgress userProgress = null; + + if (user.Progresses == null) + { + throw new KavitaException("Progresses must exist on user"); + } + try + { + userProgress = + user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id); + } + catch (Exception) + { + // There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages + var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); + if (progresses.Count > 1) + { + user.Progresses = new List() + { + user.Progresses.First() + }; + userProgress = user.Progresses.First(); + } + } + + return userProgress; + } + + /// + /// Saves progress to DB + /// + /// + /// + /// + public async Task SaveReadingProgress(ProgressDto progressDto, int userId) + { + // Don't let user save past total pages. + progressDto.PageNum = await CapPageToChapter(progressDto.ChapterId, progressDto.PageNum); + + try + { + var userProgress = + await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(progressDto.ChapterId, userId); + + if (userProgress == null) + { + // Create a user object + var userWithProgress = + await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Progress); + userWithProgress.Progresses ??= new List(); + userWithProgress.Progresses.Add(new AppUserProgress + { + PagesRead = progressDto.PageNum, + VolumeId = progressDto.VolumeId, + SeriesId = progressDto.SeriesId, + ChapterId = progressDto.ChapterId, + BookScrollId = progressDto.BookScrollId, + LastModified = DateTime.Now + }); + _unitOfWork.UserRepository.Update(userWithProgress); + } + else + { + userProgress.PagesRead = progressDto.PageNum; + userProgress.SeriesId = progressDto.SeriesId; + userProgress.VolumeId = progressDto.VolumeId; + userProgress.BookScrollId = progressDto.BookScrollId; + userProgress.LastModified = DateTime.Now; + _unitOfWork.AppUserProgressRepository.Update(userProgress); + } + + if (await _unitOfWork.CommitAsync()) + { + return true; + } + } + catch (Exception exception) + { + _logger.LogError(exception, "Could not save progress"); + await _unitOfWork.RollbackAsync(); + } + + return false; + } + + /// + /// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call. + /// + /// + /// + /// + public async Task CapPageToChapter(int chapterId, int page) + { + var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId); + if (page > totalPages) + { + page = totalPages; + } + + if (page < 0) + { + page = 0; + } + + return page; + } + + /// + /// Tries to find the next logical Chapter + /// + /// + /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → SP 01 → SP 02 + /// + /// + /// + /// + /// + /// -1 if nothing can be found + public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) + { + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); + var currentVolume = volumes.Single(v => v.Id == volumeId); + var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); + + if (currentVolume.Number == 0) + { + // Handle specials by sorting on their Filename aka Range + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + + foreach (var volume in volumes) + { + if (volume.Number == currentVolume.Number && volume.Chapters.Count > 1) + { + // Handle Chapters within current Volume + // In this case, i need 0 first because 0 represents a full volume file. + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + + if (volume.Number != currentVolume.Number + 1) continue; + + // Handle Chapters within next Volume + // ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+ + var chapters = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).ToList(); + if (currentChapter.Number.Equals("0") && chapters.Last().Number.Equals("0")) + { + return chapters.Last().Id; + } + + var firstChapter = chapters.FirstOrDefault(); + if (firstChapter == null) return -1; + return firstChapter.Id; + + } + + return -1; + } + /// + /// Tries to find the prev logical Chapter + /// + /// + /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← SP 01 ← SP 02 + /// + /// + /// + /// + /// + /// -1 if nothing can be found + public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) + { + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList(); + var currentVolume = volumes.Single(v => v.Id == volumeId); + var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); + + if (currentVolume.Number == 0) + { + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + + foreach (var volume in volumes) + { + if (volume.Number == currentVolume.Number) + { + var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).Reverse(), currentChapter.Number); + if (chapterId > 0) return chapterId; + } + if (volume.Number == currentVolume.Number - 1) + { + var lastChapter = volume.Chapters + .OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault(); + if (lastChapter == null) return -1; + return lastChapter.Id; + } + } + return -1; + } + + + private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) + { + var next = false; + var chaptersList = chapters.ToList(); + foreach (var chapter in chaptersList) + { + if (next) + { + return chapter.Id; + } + if (currentChapterNumber.Equals(chapter.Number)) next = true; + } + + return -1; + } + + +} diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs new file mode 100644 index 000000000..d791efd55 --- /dev/null +++ b/API/Services/ReadingItemService.cs @@ -0,0 +1,141 @@ +using System; +using API.Data.Metadata; +using API.Entities.Enums; +using API.Parser; + +namespace API.Services; + +public interface IReadingItemService +{ + ComicInfo GetComicInfo(string filePath); + int GetNumberOfPages(string filePath, MangaFormat format); + string GetCoverImage(string filePath, string fileName, MangaFormat format); + void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); + ParserInfo Parse(string path, string rootPath, LibraryType type); +} + +public class ReadingItemService : IReadingItemService +{ + private readonly IArchiveService _archiveService; + private readonly IBookService _bookService; + private readonly IImageService _imageService; + private readonly IDirectoryService _directoryService; + private readonly DefaultParser _defaultParser; + + public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, IDirectoryService directoryService) + { + _archiveService = archiveService; + _bookService = bookService; + _imageService = imageService; + _directoryService = directoryService; + + _defaultParser = new DefaultParser(directoryService); + } + + /// + /// Gets the ComicInfo for the file if it exists. Null otherewise. + /// + /// Fully qualified path of file + /// + public ComicInfo? GetComicInfo(string filePath) + { + if (Parser.Parser.IsEpub(filePath)) + { + return _bookService.GetComicInfo(filePath); + } + + if (Parser.Parser.IsComicInfoExtension(filePath)) + { + return _archiveService.GetComicInfo(filePath); + } + + return null; + } + + /// + /// + /// + /// + /// + /// + public int GetNumberOfPages(string filePath, MangaFormat format) + { + switch (format) + { + case MangaFormat.Archive: + { + return _archiveService.GetNumberOfPagesFromArchive(filePath); + } + case MangaFormat.Pdf: + case MangaFormat.Epub: + { + return _bookService.GetNumberOfPages(filePath); + } + case MangaFormat.Image: + { + return 1; + } + case MangaFormat.Unknown: + default: + return 0; + } + } + + public string GetCoverImage(string filePath, string fileName, MangaFormat format) + { + if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(fileName)) + { + return string.Empty; + } + + return format switch + { + MangaFormat.Epub => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), + MangaFormat.Archive => _archiveService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), + MangaFormat.Image => _imageService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), + MangaFormat.Pdf => _bookService.GetCoverImage(filePath, fileName, _directoryService.CoverImageDirectory), + _ => string.Empty + }; + } + + /// + /// Extracts the reading item to the target directory using the appropriate method + /// + /// File to extract + /// Where to extract to. Will be created if does not exist + /// Format of the File + /// If the file is of type image, pass number of files needed. If > 0, will copy the whole directory. + /// + public void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1) + { + switch (format) + { + case MangaFormat.Pdf: + _bookService.ExtractPdfImages(fileFilePath, targetDirectory); + break; + case MangaFormat.Archive: + _archiveService.ExtractArchive(fileFilePath, targetDirectory); + break; + case MangaFormat.Image: + _imageService.ExtractImages(fileFilePath, targetDirectory, imageCount); + break; + case MangaFormat.Unknown: + case MangaFormat.Epub: + break; + default: + throw new ArgumentOutOfRangeException(nameof(format), format, null); + } + } + + /// + /// Parses information out of a file. If file is a book (epub), it will use book metadata regardless of LibraryType + /// + /// + /// + /// + /// + public ParserInfo Parse(string path, string rootPath, LibraryType type) + { + return Parser.Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type); + } +} diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 77c745535..9f91bf75c 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,178 +1,187 @@ using System; -using System.IO; using System.Threading; using System.Threading.Tasks; +using API.Data; using API.Entities.Enums; using API.Helpers.Converters; -using API.Interfaces; -using API.Interfaces.Services; +using API.Services.Tasks; using Hangfire; using Microsoft.Extensions.Logging; -namespace API.Services +namespace API.Services; + +public interface ITaskScheduler { - public class TaskScheduler : ITaskScheduler + Task ScheduleTasks(); + Task ScheduleStatsTasks(); + void ScheduleUpdaterTasks(); + void ScanLibrary(int libraryId, bool forceUpdate = false); + void CleanupChapters(int[] chapterIds); + void RefreshMetadata(int libraryId, bool forceUpdate = true); + void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); + void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); + void CancelStatsTasks(); + Task RunStatCollection(); +} +public class TaskScheduler : ITaskScheduler +{ + private readonly ICacheService _cacheService; + private readonly ILogger _logger; + private readonly IScannerService _scannerService; + private readonly IUnitOfWork _unitOfWork; + private readonly IMetadataService _metadataService; + private readonly IBackupService _backupService; + private readonly ICleanupService _cleanupService; + + private readonly IStatsService _statsService; + private readonly IVersionUpdaterService _versionUpdaterService; + private readonly IDirectoryService _directoryService; + + public static BackgroundJobServer Client => new BackgroundJobServer(); + private static readonly Random Rnd = new Random(); + + + public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, + IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, + ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, + IDirectoryService directoryService) { - private readonly ICacheService _cacheService; - private readonly ILogger _logger; - private readonly IScannerService _scannerService; - private readonly IUnitOfWork _unitOfWork; - private readonly IMetadataService _metadataService; - private readonly IBackupService _backupService; - private readonly ICleanupService _cleanupService; + _cacheService = cacheService; + _logger = logger; + _scannerService = scannerService; + _unitOfWork = unitOfWork; + _metadataService = metadataService; + _backupService = backupService; + _cleanupService = cleanupService; + _statsService = statsService; + _versionUpdaterService = versionUpdaterService; + _directoryService = directoryService; + } - private readonly IStatsService _statsService; - private readonly IVersionUpdaterService _versionUpdaterService; + public async Task ScheduleTasks() + { + _logger.LogInformation("Scheduling reoccurring tasks"); - public static BackgroundJobServer Client => new BackgroundJobServer(); - private static readonly Random Rnd = new Random(); - - - public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, - IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, - ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService) + var setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Value; + if (setting != null) { - _cacheService = cacheService; - _logger = logger; - _scannerService = scannerService; - _unitOfWork = unitOfWork; - _metadataService = metadataService; - _backupService = backupService; - _cleanupService = cleanupService; - _statsService = statsService; - _versionUpdaterService = versionUpdaterService; + var scanLibrarySetting = setting; + _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); + RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), + () => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); + } + else + { + RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); } - public void ScheduleTasks() + setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; + if (setting != null) { - _logger.LogInformation("Scheduling reoccurring tasks"); - - var setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).GetAwaiter().GetResult().Value; - if (setting != null) - { - var scanLibrarySetting = setting; - _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); - RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), - () => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); - } - else - { - RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); - } - - setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Result.Value; - if (setting != null) - { - _logger.LogDebug("Scheduling Backup Task for {Setting}", setting); - RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); - } - else - { - RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); - } - - RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); - + _logger.LogDebug("Scheduling Backup Task for {Setting}", setting); + RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); + } + else + { + RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); } - #region StatsTasks + RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); + RecurringJob.AddOrUpdate("cleanup-db", () => _cleanupService.CleanupDbEntries(), Cron.Daily, TimeZoneInfo.Local); + } + + #region StatsTasks - public async Task ScheduleStatsTasks() + public async Task ScheduleStatsTasks() + { + var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; + if (!allowStatCollection) { - var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; - if (!allowStatCollection) - { - _logger.LogDebug("User has opted out of stat collection, not registering tasks"); - return; - } - - _logger.LogDebug("Scheduling stat collection daily"); - RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local); + _logger.LogDebug("User has opted out of stat collection, not registering tasks"); + return; } - public void CancelStatsTasks() + _logger.LogDebug("Scheduling stat collection daily"); + RecurringJob.AddOrUpdate("report-stats", () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), TimeZoneInfo.Local); + } + + public void CancelStatsTasks() + { + _logger.LogDebug("Cancelling/Removing StatsTasks"); + + RecurringJob.RemoveIfExists("report-stats"); + } + + /// + /// First time run stat collection. Executes immediately on a background thread. Does not block. + /// + public async Task RunStatCollection() + { + var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; + if (!allowStatCollection) { - _logger.LogDebug("Cancelling/Removing StatsTasks"); - - RecurringJob.RemoveIfExists("report-stats"); + _logger.LogDebug("User has opted out of stat collection, not sending stats"); + return; } + BackgroundJob.Enqueue(() => _statsService.Send()); + } - /// - /// First time run stat collection. Executes immediately on a background thread. Does not block. - /// - public async Task RunStatCollection() - { - var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; - if (!allowStatCollection) - { - _logger.LogDebug("User has opted out of stat collection, not sending stats"); - return; - } - BackgroundJob.Enqueue(() => _statsService.Send()); - } + #endregion - #endregion + #region UpdateTasks - #region UpdateTasks + public void ScheduleUpdaterTasks() + { + _logger.LogInformation("Scheduling Auto-Update tasks"); + // Schedule update check between noon and 6pm local time + RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); + } + #endregion - public void ScheduleUpdaterTasks() - { - _logger.LogInformation("Scheduling Auto-Update tasks"); - // Schedule update check between noon and 6pm local time - RecurringJob.AddOrUpdate("check-updates", () => _versionUpdaterService.CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); - } - #endregion + public void ScanLibrary(int libraryId, bool forceUpdate = false) + { + _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); + BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId)); + // When we do a scan, force cache to re-unpack in case page numbers change + BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); + } - public void ScanLibrary(int libraryId, bool forceUpdate = false) - { - _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId)); - // When we do a scan, force cache to re-unpack in case page numbers change - BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); - } + public void CleanupChapters(int[] chapterIds) + { + BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); + } - public void CleanupChapters(int[] chapterIds) - { - BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); - } + public void RefreshMetadata(int libraryId, bool forceUpdate = true) + { + _logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate)); + } - public void RefreshMetadata(int libraryId, bool forceUpdate = true) - { - _logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate)); - } + public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true) + { + _logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate)); + } - public void CleanupTemp() - { - BackgroundJob.Enqueue(() => DirectoryService.ClearDirectory(DirectoryService.TempDirectory)); - } + public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) + { + _logger.LogInformation("Enqueuing series scan for: {SeriesId}", seriesId); + BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None)); + } - public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true) - { - _logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate)); - } + public void BackupDatabase() + { + BackgroundJob.Enqueue(() => _backupService.BackupDatabase()); + } - public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) - { - _logger.LogInformation("Enqueuing series scan for: {SeriesId}", seriesId); - BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None)); - } - - public void BackupDatabase() - { - BackgroundJob.Enqueue(() => _backupService.BackupDatabase()); - } - - /// - /// Not an external call. Only public so that we can call this for a Task - /// - // ReSharper disable once MemberCanBePrivate.Global - public async Task CheckForUpdate() - { - var update = await _versionUpdaterService.CheckForUpdate(); - await _versionUpdaterService.PushUpdate(update); - } + /// + /// Not an external call. Only public so that we can call this for a Task + /// + // ReSharper disable once MemberCanBePrivate.Global + public async Task CheckForUpdate() + { + var update = await _versionUpdaterService.CheckForUpdate(); + await _versionUpdaterService.PushUpdate(update); } } diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index 04cb279ec..12f4f1083 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -4,204 +4,195 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.Entities.Enums; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Services; using API.SignalR; using Hangfire; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks +namespace API.Services.Tasks; + +public interface IBackupService { - public class BackupService : IBackupService + Task BackupDatabase(); + /// + /// Returns a list of full paths of the logs files detailed in . + /// + /// + /// + /// + IEnumerable GetLogFiles(int maxRollingFiles, string logFileName); +} +public class BackupService : IBackupService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IHubContext _messageHub; + + private readonly IList _backupFiles; + + public BackupService(ILogger logger, IUnitOfWork unitOfWork, + IDirectoryService directoryService, IConfiguration config, IHubContext messageHub) { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; - private readonly IHubContext _messageHub; + _unitOfWork = unitOfWork; + _logger = logger; + _directoryService = directoryService; + _messageHub = messageHub; - private readonly IList _backupFiles; + var maxRollingFiles = config.GetMaxRollingFiles(); + var loggingSection = config.GetLoggingFileName(); + var files = GetLogFiles(maxRollingFiles, loggingSection); - public BackupService(IUnitOfWork unitOfWork, ILogger logger, - IDirectoryService directoryService, IConfiguration config, IHubContext messageHub) + + _backupFiles = new List() { - _unitOfWork = unitOfWork; - _logger = logger; - _directoryService = directoryService; - _messageHub = messageHub; + "appsettings.json", + "Hangfire.db", // This is not used atm + "Hangfire-log.db", // This is not used atm + "kavita.db", + "kavita.db-shm", // This wont always be there + "kavita.db-wal" // This wont always be there + }; - var maxRollingFiles = config.GetMaxRollingFiles(); - var loggingSection = config.GetLoggingFileName(); - var files = LogFiles(maxRollingFiles, loggingSection); - - - _backupFiles = new List() - { - "appsettings.json", - "Hangfire.db", // This is not used atm - "Hangfire-log.db", // This is not used atm - "kavita.db", - "kavita.db-shm", // This wont always be there - "kavita.db-wal" // This wont always be there - }; - - foreach (var file in files.Select(f => (new FileInfo(f)).Name).ToList()) - { - _backupFiles.Add(file); - } + foreach (var file in files.Select(f => (_directoryService.FileSystem.FileInfo.FromFileName(f)).Name).ToList()) + { + _backupFiles.Add(file); } - public IEnumerable LogFiles(int maxRollingFiles, string logFileName) - { - var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty; - var fi = new FileInfo(logFileName); - - var files = maxRollingFiles > 0 - ? DirectoryService.GetFiles(DirectoryService.LogDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") - : new[] {"kavita.log"}; - return files; - } - - /// - /// Will backup anything that needs to be backed up. This includes logs, setting files, bare minimum cover images (just locked and first cover). - /// - [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] - public async Task BackupDatabase() - { - _logger.LogInformation("Beginning backup of Database at {BackupTime}", DateTime.Now); - var backupDirectory = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Result.Value; - - _logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory); - if (!DirectoryService.ExistOrCreate(backupDirectory)) - { - _logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory); - return; - } - - await SendProgress(0F); - - var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); - var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); - - if (File.Exists(zipPath)) - { - _logger.LogInformation("{ZipFile} already exists, aborting", zipPath); - return; - } - - var tempDirectory = Path.Join(DirectoryService.TempDirectory, dateString); - DirectoryService.ExistOrCreate(tempDirectory); - DirectoryService.ClearDirectory(tempDirectory); - - _directoryService.CopyFilesToDirectory( - _backupFiles.Select(file => Path.Join(DirectoryService.ConfigDirectory, file)).ToList(), tempDirectory); - - await SendProgress(0.25F); - - await CopyCoverImagesToBackupDirectory(tempDirectory); - - await SendProgress(0.75F); - - try - { - ZipFile.CreateFromDirectory(tempDirectory, zipPath); - } - catch (AggregateException ex) - { - _logger.LogError(ex, "There was an issue when archiving library backup"); - } - - DirectoryService.ClearAndDeleteDirectory(tempDirectory); - _logger.LogInformation("Database backup completed"); - await SendProgress(1F); - } - - private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) - { - var outputTempDir = Path.Join(tempDirectory, "covers"); - DirectoryService.ExistOrCreate(outputTempDir); - - try - { - var seriesImages = await _unitOfWork.SeriesRepository.GetLockedCoverImagesAsync(); - _directoryService.CopyFilesToDirectory( - seriesImages.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir); - - var collectionTags = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync(); - _directoryService.CopyFilesToDirectory( - collectionTags.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir); - - var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync(); - _directoryService.CopyFilesToDirectory( - chapterImages.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir); - } - catch (IOException) - { - // Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file. - } - - if (!DirectoryService.GetFiles(outputTempDir).Any()) - { - DirectoryService.ClearAndDeleteDirectory(outputTempDir); - } - } - - private async Task SendProgress(float progress) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress, - MessageFactory.BackupDatabaseProgressEvent(progress)); - } - - /// - /// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept. - /// - public void CleanupBackups() - { - const int dayThreshold = 30; - _logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now); - var backupDirectory = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Result.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 => new FileInfo(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(); - for (var i = 1; i < toDelete.Count; i++) - { - try - { - toDelete[i].Delete(); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue deleting {FileName}", toDelete[i].Name); - } - } - } - else - { - foreach (var file in expiredBackups) - { - try - { - file.Delete(); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an issue deleting {FileName}", file.Name); - } - } - - } - _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); - } } + + public IEnumerable GetLogFiles(int maxRollingFiles, string logFileName) + { + var multipleFileRegex = maxRollingFiles > 0 ? @"\d*" : string.Empty; + var fi = _directoryService.FileSystem.FileInfo.FromFileName(logFileName); + + var files = maxRollingFiles > 0 + ? _directoryService.GetFiles(_directoryService.LogDirectory, + $@"{_directoryService.FileSystem.Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log") + : new[] {"kavita.log"}; + return files; + } + + /// + /// Will backup anything that needs to be backed up. This includes logs, setting files, bare minimum cover images (just locked and first cover). + /// + [AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)] + public async Task BackupDatabase() + { + _logger.LogInformation("Beginning backup of Database at {BackupTime}", DateTime.Now); + var backupDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value; + + _logger.LogDebug("Backing up to {BackupDirectory}", backupDirectory); + if (!_directoryService.ExistOrCreate(backupDirectory)) + { + _logger.LogCritical("Could not write to {BackupDirectory}; aborting backup", backupDirectory); + return; + } + + await SendProgress(0F); + + var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); + var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); + + if (File.Exists(zipPath)) + { + _logger.LogInformation("{ZipFile} already exists, aborting", zipPath); + return; + } + + var tempDirectory = Path.Join(_directoryService.TempDirectory, dateString); + _directoryService.ExistOrCreate(tempDirectory); + _directoryService.ClearDirectory(tempDirectory); + + _directoryService.CopyFilesToDirectory( + _backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)).ToList(), tempDirectory); + + await SendProgress(0.25F); + + await CopyCoverImagesToBackupDirectory(tempDirectory); + + await SendProgress(0.5F); + + await CopyBookmarksToBackupDirectory(tempDirectory); + + await SendProgress(0.75F); + + try + { + ZipFile.CreateFromDirectory(tempDirectory, zipPath); + } + catch (AggregateException ex) + { + _logger.LogError(ex, "There was an issue when archiving library backup"); + } + + _directoryService.ClearAndDeleteDirectory(tempDirectory); + _logger.LogInformation("Database backup completed"); + await SendProgress(1F); + } + + private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) + { + var outputTempDir = Path.Join(tempDirectory, "covers"); + _directoryService.ExistOrCreate(outputTempDir); + + try + { + var seriesImages = await _unitOfWork.SeriesRepository.GetLockedCoverImagesAsync(); + _directoryService.CopyFilesToDirectory( + seriesImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + + var collectionTags = await _unitOfWork.CollectionTagRepository.GetAllCoverImagesAsync(); + _directoryService.CopyFilesToDirectory( + collectionTags.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + + var chapterImages = await _unitOfWork.ChapterRepository.GetCoverImagesForLockedChaptersAsync(); + _directoryService.CopyFilesToDirectory( + chapterImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + } + catch (IOException) + { + // Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file. + } + + if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any()) + { + _directoryService.ClearAndDeleteDirectory(outputTempDir); + } + } + + private async Task CopyBookmarksToBackupDirectory(string tempDirectory) + { + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + + var outputTempDir = Path.Join(tempDirectory, "bookmarks"); + _directoryService.ExistOrCreate(outputTempDir); + + try + { + _directoryService.CopyDirectoryToDirectory(bookmarkDirectory, outputTempDir); + } + catch (IOException) + { + // Swallow exception. + } + + if (!_directoryService.GetFiles(outputTempDir, searchOption: SearchOption.AllDirectories).Any()) + { + _directoryService.ClearAndDeleteDirectory(outputTempDir); + } + } + + private async Task SendProgress(float progress) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress, + MessageFactory.BackupDatabaseProgressEvent(progress)); + } + } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 1ecc9cec5..d31e50a22 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -1,7 +1,9 @@ -using System.IO; +using System; +using System.IO; +using System.Linq; using System.Threading.Tasks; -using API.Interfaces; -using API.Interfaces.Services; +using API.Data; +using API.Entities.Enums; using API.SignalR; using Hangfire; using Microsoft.AspNetCore.SignalR; @@ -9,32 +11,37 @@ using Microsoft.Extensions.Logging; namespace API.Services.Tasks { + public interface ICleanupService + { + Task Cleanup(); + Task CleanupDbEntries(); + void CleanupCacheDirectory(); + Task DeleteSeriesCoverImages(); + Task DeleteChapterCoverImages(); + Task DeleteTagCoverImages(); + Task CleanupBackups(); + Task CleanupBookmarks(); + } /// /// Cleans up after operations on reoccurring basis /// public class CleanupService : ICleanupService { - private readonly ICacheService _cacheService; private readonly ILogger _logger; - private readonly IBackupService _backupService; private readonly IUnitOfWork _unitOfWork; private readonly IHubContext _messageHub; + private readonly IDirectoryService _directoryService; - public CleanupService(ICacheService cacheService, ILogger logger, - IBackupService backupService, IUnitOfWork unitOfWork, IHubContext messageHub) + public CleanupService(ILogger logger, + IUnitOfWork unitOfWork, IHubContext messageHub, + IDirectoryService directoryService) { - _cacheService = cacheService; _logger = logger; - _backupService = backupService; _unitOfWork = unitOfWork; _messageHub = messageHub; + _directoryService = directoryService; } - public void CleanupCacheDirectory() - { - _logger.LogInformation("Cleaning cache directory"); - _cacheService.Cleanup(); - } /// /// Cleans up Temp, cache, deleted cover images, and old database backups @@ -45,12 +52,12 @@ namespace API.Services.Tasks _logger.LogInformation("Starting Cleanup"); await SendProgress(0F); _logger.LogInformation("Cleaning temp directory"); - DirectoryService.ClearDirectory(DirectoryService.TempDirectory); + _directoryService.ClearDirectory(_directoryService.TempDirectory); await SendProgress(0.1F); CleanupCacheDirectory(); await SendProgress(0.25F); _logger.LogInformation("Cleaning old database backups"); - _backupService.CleanupBackups(); + await CleanupBackups(); await SendProgress(0.50F); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -58,49 +65,137 @@ namespace API.Services.Tasks await DeleteChapterCoverImages(); await SendProgress(0.7F); await DeleteTagCoverImages(); + await SendProgress(0.8F); + _logger.LogInformation("Cleaning old bookmarks"); + await CleanupBookmarks(); await SendProgress(1F); _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(); + } + private async Task SendProgress(float progress) { await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress, MessageFactory.CleanupProgressEvent(progress)); } - private async Task DeleteSeriesCoverImages() + /// + /// 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); - foreach (var file in files) - { - if (images.Contains(Path.GetFileName(file))) continue; - File.Delete(file); - - } + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.SeriesCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); } - private async Task DeleteChapterCoverImages() + /// + /// 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); - foreach (var file in files) - { - if (images.Contains(Path.GetFileName(file))) continue; - File.Delete(file); - - } + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.ChapterCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); } - private async Task DeleteTagCoverImages() + /// + /// 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); - foreach (var file in files) - { - if (images.Contains(Path.GetFileName(file))) continue; - File.Delete(file); + var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.CollectionTagCoverImageRegex); + _directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file)))); + } + /// + /// Removes all files and directories in the cache directory + /// + public void CleanupCacheDirectory() + { + _logger.LogInformation("Performing cleanup of Cache directory"); + _directoryService.ExistOrCreate(_directoryService.CacheDirectory); + + try + { + _directoryService.ClearDirectory(_directoryService.CacheDirectory); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); + } + + _logger.LogInformation("Cache directory purged"); + } + + /// + /// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept. + /// + public async Task CleanupBackups() + { + const int dayThreshold = 30; + _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); + } + + /// + /// Removes all files in the BookmarkDirectory that don't currently have bookmarks in the Database + /// + public async Task CleanupBookmarks() + { + // Search all files in bookmarks/ except bookmark files and delete those + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories).Select(Parser.Parser.NormalizePath); + var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()) + .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, + b.FileName))); + + + var filesToDelete = allBookmarkFiles.ToList().Except(bookmarks).ToList(); + _logger.LogDebug("[Bookmarks] Bookmark cleanup wants to delete {Count} files", filesToDelete.Count()); + + _directoryService.DeleteFiles(filesToDelete); + + // Clear all empty directories + foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory)) + { + if (_directoryService.FileSystem.Directory.GetFiles(directory).Length == 0 && + _directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0) + { + _directoryService.FileSystem.Directory.Delete(directory, false); + } } } } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index f88caab89..50cb98da9 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -6,7 +6,7 @@ using System.IO; using System.Linq; using API.Entities; using API.Entities.Enums; -using API.Interfaces.Services; +using API.Helpers; using API.Parser; using Microsoft.Extensions.Logging; @@ -23,34 +23,46 @@ namespace API.Services.Tasks.Scanner public class ParseScannedFiles { private readonly ConcurrentDictionary> _scannedSeries; - private readonly IBookService _bookService; private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IReadingItemService _readingItemService; + private readonly DefaultParser _defaultParser; /// /// 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. /// - /// - /// - public ParseScannedFiles(IBookService bookService, ILogger logger) + /// Logger of the parent class that invokes this + /// Directory Service + /// ReadingItemService Service for extracting information on a number of formats + public ParseScannedFiles(ILogger logger, IDirectoryService directoryService, + IReadingItemService readingItemService) { - _bookService = bookService; _logger = logger; + _directoryService = directoryService; + _readingItemService = readingItemService; _scannedSeries = new ConcurrentDictionary>(); + _defaultParser = new DefaultParser(_directoryService); } /// - /// Gets the list of parserInfos given a Series. If the series does not exist within, return empty list. + /// Gets the list of all parserInfos given a Series (Will match on Name, LocalizedName, OriginalName). If the series does not exist within, return empty list. /// /// /// /// public static IList GetInfosByName(Dictionary> parsedSeries, Series series) { - var existingKey = parsedSeries.Keys.FirstOrDefault(ps => - ps.Format == series.Format && ps.NormalizedName.Equals(Parser.Parser.Normalize(series.OriginalName))); + var allKeys = parsedSeries.Keys.Where(ps => + SeriesHelper.FindSeries(series, ps)); - return existingKey != null ? parsedSeries[existingKey] : new List(); + var infos = new List(); + foreach (var key in allKeys) + { + infos.AddRange(parsedSeries[key]); + } + + return infos; } /// @@ -62,20 +74,12 @@ namespace API.Services.Tasks.Scanner /// Library type to determine parsing to perform private void ProcessFile(string path, string rootPath, LibraryType type) { - ParserInfo info; + // TODO: Emit event with what is being processed. It can look like Kavita isn't doing anything during file scan - if (Parser.Parser.IsEpub(path)) - { - info = _bookService.ParseInfo(path); - } - else - { - info = Parser.Parser.Parse(path, rootPath, type); - } - - // If we couldn't match, log. But don't log if the file parses as a cover image + var info = _readingItemService.Parse(path, rootPath, type); if (info == null) { + // If the file is an image and literally a cover image, skip processing. if (!(Parser.Parser.IsImage(path) && Parser.Parser.IsCoverImage(path))) { _logger.LogWarning("[Scanner] Could not parse series from {Path}", path); @@ -83,16 +87,36 @@ namespace API.Services.Tasks.Scanner return; } - if (Parser.Parser.IsEpub(path) && Parser.Parser.ParseVolume(info.Series) != Parser.Parser.DefaultVolume) + + // This catches when original library type is Manga/Comic and when parsing with non + if (Parser.Parser.IsEpub(path) && Parser.Parser.ParseVolume(info.Series) != Parser.Parser.DefaultVolume) // Shouldn't this be info.Volume != DefaultVolume? { - info = Parser.Parser.Parse(path, rootPath, type); - var info2 = _bookService.ParseInfo(path); + info = _defaultParser.Parse(path, rootPath, LibraryType.Book); + var info2 = _readingItemService.Parse(path, rootPath, type); info.Merge(info2); } + info.ComicInfo = _readingItemService.GetComicInfo(path); + if (info.ComicInfo != null) + { + if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) + { + info.Volumes = info.ComicInfo.Volume; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Series)) + { + info.Series = info.ComicInfo.Series; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) + { + info.Chapters = info.ComicInfo.Number; + } + } + TrackSeries(info); } + /// /// 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) @@ -133,7 +157,7 @@ namespace API.Services.Tasks.Scanner /// 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 public string MergeName(ParserInfo info) { var normalizedSeries = Parser.Parser.Normalize(info.Series); @@ -161,12 +185,11 @@ namespace API.Services.Tasks.Scanner { var sw = Stopwatch.StartNew(); totalFiles = 0; - var searchPattern = GetLibrarySearchPattern(); foreach (var folderPath in folders) { try { - totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath, (f) => + totalFiles += _directoryService.TraverseTreeParallelForEach(folderPath, (f) => { try { @@ -176,7 +199,7 @@ namespace API.Services.Tasks.Scanner { _logger.LogError(exception, "The file {Filename} could not be found", f); } - }, searchPattern, _logger); + }, Parser.Parser.SupportedExtensions, _logger); } catch (ArgumentException ex) { @@ -191,11 +214,6 @@ namespace API.Services.Tasks.Scanner return SeriesWithInfos(); } - private static string GetLibrarySearchPattern() - { - return Parser.Parser.SupportedExtensions; - } - /// /// Returns any series where there were parsed infos /// diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index f1d5a2b96..7c6a51f2c 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -7,13 +7,12 @@ using System.Threading; using System.Threading.Tasks; using API.Comparators; using API.Data; +using API.Data.Metadata; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; -using API.Interfaces; -using API.Interfaces.Services; using API.Parser; using API.Services.Tasks.Scanner; using API.SignalR; @@ -21,697 +20,875 @@ using Hangfire; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks +namespace API.Services.Tasks; +public interface IScannerService { - public class ScannerService : IScannerService + /// + /// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite + /// cover images if forceUpdate is true. + /// + /// Library to scan against + Task ScanLibrary(int libraryId); + Task ScanLibraries(); + Task ScanSeries(int libraryId, int seriesId, CancellationToken token); +} + +public class ScannerService : IScannerService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IMetadataService _metadataService; + private readonly ICacheService _cacheService; + private readonly IHubContext _messageHub; + private readonly IFileService _fileService; + private readonly IDirectoryService _directoryService; + private readonly IReadingItemService _readingItemService; + private readonly ICacheHelper _cacheHelper; + + public ScannerService(IUnitOfWork unitOfWork, ILogger logger, + IMetadataService metadataService, ICacheService cacheService, IHubContext messageHub, + IFileService fileService, IDirectoryService directoryService, IReadingItemService readingItemService, + ICacheHelper cacheHelper) { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IArchiveService _archiveService; - private readonly IMetadataService _metadataService; - private readonly IBookService _bookService; - private readonly ICacheService _cacheService; - private readonly IHubContext _messageHub; - private readonly NaturalSortComparer _naturalSort = new (); + _unitOfWork = unitOfWork; + _logger = logger; + _metadataService = metadataService; + _cacheService = cacheService; + _messageHub = messageHub; + _fileService = fileService; + _directoryService = directoryService; + _readingItemService = readingItemService; + _cacheHelper = cacheHelper; + } - public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService, - IMetadataService metadataService, IBookService bookService, ICacheService cacheService, IHubContext messageHub) - { - _unitOfWork = unitOfWork; - _logger = logger; - _archiveService = archiveService; - _metadataService = metadataService; - _bookService = bookService; - _cacheService = cacheService; - _messageHub = messageHub; - } + [DisableConcurrentExecution(timeoutInSeconds: 360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) + { + var sw = new Stopwatch(); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); + var folderPaths = library.Folders.Select(f => f.Path).ToList(); - [DisableConcurrentExecution(timeoutInSeconds: 360)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) - { - var sw = new Stopwatch(); - var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); - var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); - var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); - var folderPaths = library.Folders.Select(f => f.Path).ToList(); + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (folderPaths.Any(f => !_directoryService.IsDriveMounted(f))) + { + _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; + } - // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are - if (folderPaths.Any(f => !DirectoryService.IsDriveMounted(f))) - { - _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - return; - } + var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); + var dirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); - _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); - var scanner = new ParseScannedFiles(_bookService, _logger); - var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime); + _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); + var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); + var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime); - // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder - RemoveParsedInfosNotForSeries(parsedSeries, series); + // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder + RemoveParsedInfosNotForSeries(parsedSeries, series); - // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow - if (parsedSeries.Count == 0) - { - var anyFilesExist = - (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id)).Any(m => File.Exists(m.FilePath)); + // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow + if (parsedSeries.Count == 0) + { + var anyFilesExist = + (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id)).Any(m => File.Exists(m.FilePath)); - if (!anyFilesExist) - { - try - { - _unitOfWork.SeriesRepository.Remove(series); - await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an error during ScanSeries to delete the series"); - await _unitOfWork.RollbackAsync(); - } + if (!anyFilesExist) + { + try + { + _unitOfWork.SeriesRepository.Remove(series); + await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an error during ScanSeries to delete the series"); + await _unitOfWork.RollbackAsync(); + } - } - else - { - // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, - // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root - // is the series folder. - var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); - if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) - { - dirs = new Dictionary(); - var path = Directory.GetParent(existingFolder)?.FullName; - if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) - { - _logger.LogInformation("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); - return; - } - if (!string.IsNullOrEmpty(path)) - { - dirs[path] = string.Empty; - } - } + } + else + { + // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, + // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root + // is the series folder. + var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); + if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) + { + dirs = new Dictionary(); + var path = Directory.GetParent(existingFolder)?.FullName; + if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) + { + _logger.LogInformation("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); + return; + } + if (!string.IsNullOrEmpty(path)) + { + dirs[path] = string.Empty; + } + } - _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); - scanner = new ParseScannedFiles(_bookService, _logger); - parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); - totalFiles += totalFiles2; - scanElapsedTime += scanElapsedTime2; - RemoveParsedInfosNotForSeries(parsedSeries, series); - } - } + _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); + scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); + parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); + totalFiles += totalFiles2; + scanElapsedTime += scanElapsedTime2; + RemoveParsedInfosNotForSeries(parsedSeries, series); + } + } - // At this point, parsedSeries will have at least one key and we can perform the update. If it still doesn't, just return and don't do anything - if (parsedSeries.Count == 0) return; + // At this point, parsedSeries will have at least one key and we can perform the update. If it still doesn't, just return and don't do anything + if (parsedSeries.Count == 0) return; - try - { - UpdateSeries(series, parsedSeries); - await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an error during ScanSeries to update the series"); - await _unitOfWork.RollbackAsync(); - } - // Tell UI that this series is done - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token); - await CleanupDbEntities(); - BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); - } + // Merge any series together that might have different ParsedSeries but belong to another group of ParsedSeries + try + { + UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); - private static void RemoveParsedInfosNotForSeries(Dictionary> parsedSeries, Series series) - { - var keys = parsedSeries.Keys; - foreach (var key in keys.Where(key => - !series.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || series.Format != key.Format)) - { - parsedSeries.Remove(key); - } - } + await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an error during ScanSeries to update the series"); + await _unitOfWork.RollbackAsync(); + } + // Tell UI that this series is done + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token); + await CleanupDbEntities(); + BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); + } - private async Task CommitAndSend(int totalFiles, - Dictionary> parsedSeries, Stopwatch sw, long scanElapsedTime, Series series) - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - _logger.LogInformation( - "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", - totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); - } - } + private static void RemoveParsedInfosNotForSeries(Dictionary> parsedSeries, Series series) + { + var keys = parsedSeries.Keys; + foreach (var key in keys.Where(key => + series.Format != key.Format || !SeriesHelper.FindSeries(series, key))) + { + parsedSeries.Remove(key); + } + } + + private async Task CommitAndSend(int totalFiles, + Dictionary> parsedSeries, Stopwatch sw, long scanElapsedTime, Series series) + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", + totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); + } + } - [DisableConcurrentExecution(timeoutInSeconds: 360)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanLibraries() - { - _logger.LogInformation("Starting Scan of All Libraries"); - var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); - foreach (var lib in libraries) - { - await ScanLibrary(lib.Id); - } - _logger.LogInformation("Scan of All Libraries Finished"); - } + [DisableConcurrentExecution(timeoutInSeconds: 360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibraries() + { + _logger.LogInformation("Starting Scan of All Libraries"); + var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); + foreach (var lib in libraries) + { + await ScanLibrary(lib.Id); + } + _logger.LogInformation("Scan of All Libraries Finished"); + } - /// - /// Scans a library for file changes. - /// Will kick off a scheduled background task to refresh metadata, - /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes - /// - /// - [DisableConcurrentExecution(360)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanLibrary(int libraryId) - { - Library library; - try - { - library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); - } - catch (Exception ex) - { - // This usually only fails if user is not authenticated. - _logger.LogError(ex, "[ScannerService] There was an issue fetching Library {LibraryId}", libraryId); - return; - } + /// + /// Scans a library for file changes. + /// Will kick off a scheduled background task to refresh metadata, + /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes + /// + /// + [DisableConcurrentExecution(360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibrary(int libraryId) + { + Library library; + try + { + library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); + } + catch (Exception ex) + { + // This usually only fails if user is not authenticated. + _logger.LogError(ex, "[ScannerService] There was an issue fetching Library {LibraryId}", libraryId); + return; + } - // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are - if (library.Folders.Any(f => !DirectoryService.IsDriveMounted(f.Path))) - { - _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - return; - } + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (library.Folders.Any(f => !_directoryService.IsDriveMounted(f.Path))) + { + _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + return; + } + + // For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are + if (library.Folders.Any(f => _directoryService.IsDirectoryEmpty(f.Path))) + { + _logger.LogCritical("Some of the root folders for the library are empty. " + + "Either your mount has been disconnected or you are trying to delete all series in the library. " + + "Scan will be aborted. " + + "Check that your mount is connected or change the library's root folder and rescan"); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + return; + } + + _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); + + var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); + var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); + + foreach (var folderPath in library.Folders) + { + folderPath.LastScanned = DateTime.Now; + } + var sw = Stopwatch.StartNew(); + + await UpdateLibrary(library, series); + + library.LastScanned = DateTime.Now; + _unitOfWork.LibraryRepository.Update(library); + if (await _unitOfWork.CommitAsync()) + { + _logger.LogInformation( + "[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", + totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); + } + else + { + _logger.LogCritical( + "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); + } + + await CleanupDbEntities(); + + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); + } + + /// + /// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters + /// + private async Task CleanupAbandonedChapters() + { + var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + _logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp); + } - _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); + /// + /// Cleans up any abandoned rows due to removals from Scan loop + /// + private async Task CleanupDbEntities() + { + await CleanupAbandonedChapters(); + var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp); + } - var scanner = new ParseScannedFiles(_bookService, _logger); - var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); + private async Task UpdateLibrary(Library library, Dictionary> parsedSeries) + { + if (parsedSeries == null) return; - foreach (var folderPath in library.Folders) - { - folderPath.LastScanned = DateTime.Now; - } - var sw = Stopwatch.StartNew(); + // Library contains no Series, so we need to fetch series in groups of ChunkSize + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; - await UpdateLibrary(library, series); + var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); + var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - library.LastScanned = DateTime.Now; - _unitOfWork.LibraryRepository.Update(library); - if (await _unitOfWork.CommitAsync()) - { - _logger.LogInformation( - "[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", - totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); - } - else - { - _logger.LogCritical( - "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); - } + // Update existing series + _logger.LogInformation("[ScannerService] Updating existing series for {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", + library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + _logger.LogInformation("[ScannerService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, new UserParams() + { + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); - await CleanupDbEntities(); + // First, remove any series that are not in parsedSeries list + var missingSeries = FindSeriesNotOnDisk(nonLibrarySeries, parsedSeries).ToList(); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); - } + foreach (var missing in missingSeries) + { + _unitOfWork.SeriesRepository.Remove(missing); + } - /// - /// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters - /// - private async Task CleanupAbandonedChapters() - { - var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - _logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp); - } + var cleanedSeries = SeriesHelper.RemoveMissingSeries(nonLibrarySeries, missingSeries, out var removeCount); + if (removeCount > 0) + { + _logger.LogInformation("[ScannerService] Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); + foreach (var s in missingSeries) + { + _logger.LogDebug("[ScannerService] Removed {SeriesName} ({Format})", s.Name, s.Format); + } + } + + // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series + var librarySeries = cleanedSeries.ToList(); + Parallel.ForEach(librarySeries, (series) => + { + UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); + }); + + try + { + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk); + foreach (var series in nonLibrarySeries) + { + _logger.LogDebug("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); + } + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryError, + MessageFactory.ScanLibraryError(library.Id)); + continue; + } + _logger.LogInformation( + "[ScannerService] Processed {SeriesStart} - {SeriesEnd} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, totalTime, library.Name); + + // Emit any series removed + foreach (var missing in missingSeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); + } + + var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + } - /// - /// Cleans up any abandoned rows due to removals from Scan loop - /// - private async Task CleanupDbEntities() - { - await CleanupAbandonedChapters(); - var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp); - } + // Add new series that have parsedInfos + _logger.LogDebug("[ScannerService] Adding new series"); + var newSeries = new List(); + var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList(); + _logger.LogDebug("[ScannerService] Fetched {AllSeriesCount} series for comparing new series with. There should be {DeltaToParsedSeries} new series", + allSeries.Count, parsedSeries.Count - allSeries.Count); + foreach (var (key, infos) in parsedSeries) + { + // Key is normalized already + Series existingSeries; + try + { + existingSeries = allSeries.SingleOrDefault(s => SeriesHelper.FindSeries(s, key)); + } + catch (Exception e) + { + // NOTE: If I ever want to put Duplicates table, this is where it can go + _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); + var duplicateSeries = allSeries.Where(s => SeriesHelper.FindSeries(s, key)); + foreach (var series in duplicateSeries) + { + _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); + } - private async Task UpdateLibrary(Library library, Dictionary> parsedSeries) - { - if (parsedSeries == null) return; + continue; + } - // Library contains no Series, so we need to fetch series in groups of ChunkSize - var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); - var stopwatch = Stopwatch.StartNew(); - var totalTime = 0L; + if (existingSeries != null) continue; - // Update existing series - _logger.LogInformation("[ScannerService] Updating existing series for {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", - library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); - for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) - { - if (chunkInfo.TotalChunks == 0) continue; - totalTime += stopwatch.ElapsedMilliseconds; - stopwatch.Restart(); - _logger.LogInformation("[ScannerService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", - chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); - var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, new UserParams() - { - PageNumber = chunk, - PageSize = chunkInfo.ChunkSize - }); - - // First, remove any series that are not in parsedSeries list - var missingSeries = FindSeriesNotOnDisk(nonLibrarySeries, parsedSeries).ToList(); - - foreach (var missing in missingSeries) - { - _unitOfWork.SeriesRepository.Remove(missing); - } - - var cleanedSeries = RemoveMissingSeries(nonLibrarySeries, missingSeries, out var removeCount); - if (removeCount > 0) - { - _logger.LogInformation("[ScannerService] Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); - foreach (var s in missingSeries) - { - _logger.LogDebug("[ScannerService] Removed {SeriesName} ({Format})", s.Name, s.Format); - } - } - - // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series - var librarySeries = cleanedSeries.ToList(); - Parallel.ForEach(librarySeries, (series) => - { - UpdateSeries(series, parsedSeries); - }); - - try - { - await _unitOfWork.CommitAsync(); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk); - foreach (var series in nonLibrarySeries) - { - _logger.LogDebug("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); - } - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryError, - MessageFactory.ScanLibraryError(library.Id)); - continue; - } - _logger.LogInformation( - "[ScannerService] Processed {SeriesStart} - {SeriesEnd} series in {ElapsedScanTime} milliseconds for {LibraryName}", - chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, totalTime, library.Name); - - // Emit any series removed - foreach (var missing in missingSeries) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); - } - - var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); - } + var s = DbFactory.Series(infos[0].Series); + if (!string.IsNullOrEmpty(infos[0].SeriesSort)) + { + s.SortName = infos[0].SeriesSort; + } + s.Format = key.Format; + s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. + newSeries.Add(s); + } - // Add new series that have parsedInfos - _logger.LogDebug("[ScannerService] Adding new series"); - var newSeries = new List(); - var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList(); - _logger.LogDebug("[ScannerService] Fetched {AllSeriesCount} series for comparing new series with. There should be {DeltaToParsedSeries} new series", - allSeries.Count, parsedSeries.Count - allSeries.Count); - foreach (var (key, infos) in parsedSeries) - { - // Key is normalized already - Series existingSeries; - try - { - existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key)); - } - catch (Exception e) - { - // NOTE: If I ever want to put Duplicates table, this is where it can go - _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); - var duplicateSeries = allSeries.Where(s => FindSeries(s, key)); - foreach (var series in duplicateSeries) - { - _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); - } + var i = 0; + foreach(var series in newSeries) + { + _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); + UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); + _unitOfWork.SeriesRepository.Attach(series); + try + { + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); - continue; - } + // Inform UI of new series added + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ", + series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); + } - if (existingSeries != null) continue; + var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + i++; + } - var s = DbFactory.Series(infos[0].Series); - s.Format = key.Format; - s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. - newSeries.Add(s); - } + _logger.LogInformation( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); + } - var i = 0; - foreach(var series in newSeries) - { - _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); - UpdateSeries(series, parsedSeries); - _unitOfWork.SeriesRepository.Attach(series); - try - { - await _unitOfWork.CommitAsync(); - _logger.LogInformation( - "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); + private void UpdateSeries(Series series, Dictionary> parsedSeries, + ICollection allPeople, ICollection allTags, ICollection allGenres, LibraryType libraryType) + { + try + { + _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); - // Inform UI of new series added - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ", - series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); - } + // Get all associated ParsedInfos to the series. This includes infos that use a different filename that matches Series LocalizedName + var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series); + UpdateVolumes(series, parsedInfos, allPeople, allTags, allGenres); + series.Pages = series.Volumes.Sum(v => v.Pages); - var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); - i++; - } + series.NormalizedName = Parser.Parser.Normalize(series.Name); + series.Metadata ??= DbFactory.SeriesMetadata(new List()); + if (series.Format == MangaFormat.Unknown) + { + series.Format = parsedInfos[0].Format; + } + series.OriginalName ??= parsedInfos[0].Series; + series.SortName ??= parsedInfos[0].SeriesSort; - _logger.LogInformation( - "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); - } + UpdateSeriesMetadata(series, allPeople, allGenres, allTags, libraryType); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); + } + } - private static bool FindSeries(Series series, ParsedSeries parsedInfoKey) - { - return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName)) - && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); - } + public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) + { + return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries)); + } - private void UpdateSeries(Series series, Dictionary> parsedSeries) - { - try - { - _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); - var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series); - UpdateVolumes(series, parsedInfos); - series.Pages = series.Volumes.Sum(v => v.Pages); + private static void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, LibraryType libraryType) + { + var isBook = libraryType == LibraryType.Book; + var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); + var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); - series.NormalizedName = Parser.Parser.Normalize(series.Name); - series.Metadata ??= DbFactory.SeriesMetadata(new List()); - if (series.Format == MangaFormat.Unknown) - { - series.Format = parsedInfos[0].Format; - } - series.OriginalName ??= parsedInfos[0].Series; - } - catch (Exception ex) - { - _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); - } - } + var firstFile = firstChapter?.Files.FirstOrDefault(); + if (firstFile == null) return; + if (Parser.Parser.IsPdf(firstFile.FilePath)) return; - public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) - { - var foundSeries = parsedSeries.Select(s => s.Key.Name).ToList(); - return existingSeries.Where(es => !es.NameInList(foundSeries) && !SeriesHasMatchingParserInfoFormat(es, parsedSeries)); - } + var chapters = series.Volumes.SelectMany(volume => volume.Chapters).ToList(); - /// - /// Checks each parser info to see if there is a name match and if so, checks if the format matches the Series object. - /// This accounts for if the Series has an Unknown type and if so, considers it matching. - /// - /// - /// - /// - private static bool SeriesHasMatchingParserInfoFormat(Series series, - Dictionary> parsedSeries) - { - var format = MangaFormat.Unknown; - foreach (var pSeries in parsedSeries.Keys) - { - var name = pSeries.Name; - var normalizedName = Parser.Parser.Normalize(name); + // Update Metadata based on Chapter metadata + series.Metadata.ReleaseYear = chapters.Min(c => c.ReleaseDate.Year); - if (normalizedName == series.NormalizedName || - normalizedName == Parser.Parser.Normalize(series.Name) || - name == series.Name || name == series.LocalizedName || - name == series.OriginalName || - normalizedName == Parser.Parser.Normalize(series.OriginalName)) - { - format = pSeries.Format; - break; - } - } + if (series.Metadata.ReleaseYear < 1000) + { + // Not a valid year, default to 0 + series.Metadata.ReleaseYear = 0; + } - if (series.Format == MangaFormat.Unknown && format != MangaFormat.Unknown) - { - return true; - } + // Set the AgeRating as highest in all the comicInfos + series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); - return format == series.Format; - } - /// - /// Removes all instances of missingSeries' Series from existingSeries Collection. Existing series is updated by - /// reference and the removed element count is returned. - /// - /// Existing Series in DB - /// Series not found on disk or can't be parsed - /// - /// the updated existingSeries - public static IEnumerable RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) - { - var existingCount = existingSeries.Count; - var missingList = missingSeries.ToList(); + series.Metadata.Count = chapters.Max(chapter => chapter.TotalCount); + series.Metadata.PublicationStatus = PublicationStatus.OnGoing; + if (chapters.Max(chapter => chapter.Count) >= series.Metadata.Count && series.Metadata.Count > 0) + { + series.Metadata.PublicationStatus = PublicationStatus.Completed; + } - existingSeries = existingSeries.Where( - s => !missingList.Exists( - m => m.NormalizedName.Equals(s.NormalizedName) && m.Format == s.Format)).ToList(); + if (!string.IsNullOrEmpty(firstChapter.Summary)) + { + series.Metadata.Summary = firstChapter.Summary; + } - removeCount = existingCount - existingSeries.Count; + if (!string.IsNullOrEmpty(firstChapter.Language)) + { + series.Metadata.Language = firstChapter.Language; + } - return existingSeries; - } - private void UpdateVolumes(Series series, IList parsedInfos) - { - var startingVolumeCount = series.Volumes.Count; - // Add new volumes and update chapters per volume - var distinctVolumes = parsedInfos.DistinctVolumes(); - _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); - foreach (var volumeNumber in distinctVolumes) - { - var volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); - if (volume == null) - { + // Handle People + foreach (var chapter in chapters) + { + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => p.Name), PersonRole.CoverArtist, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Publisher).Select(p => p.Name), PersonRole.Publisher, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Name), PersonRole.Character, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Colorist).Select(p => p.Name), PersonRole.Colorist, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Editor).Select(p => p.Name), PersonRole.Editor, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Inker).Select(p => p.Name), PersonRole.Inker, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Letterer).Select(p => p.Name), PersonRole.Letterer, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); + + TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) => + TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); + + GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre => + GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre)); + } + + var people = chapters.SelectMany(c => c.People).ToList(); + PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, + people, person => series.Metadata.People.Remove(person)); + } + + + + private void UpdateVolumes(Series series, IList parsedInfos, ICollection allPeople, ICollection allTags, ICollection allGenres) + { + var startingVolumeCount = series.Volumes.Count; + // Add new volumes and update chapters per volume + var distinctVolumes = parsedInfos.DistinctVolumes(); + _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); + foreach (var volumeNumber in distinctVolumes) + { + var volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); + if (volume == null) + { volume = DbFactory.Volume(volumeNumber); series.Volumes.Add(volume); _unitOfWork.VolumeRepository.Add(volume); - } + } - _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); - var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(volume, infos); - volume.Pages = volume.Chapters.Sum(c => c.Pages); - } + _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); + var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); + UpdateChapters(volume, infos); + volume.Pages = volume.Chapters.Sum(c => c.Pages); - // Remove existing volumes that aren't in parsedInfos - var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList(); - if (series.Volumes.Count != nonDeletedVolumes.Count) - { - _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + // Update all the metadata on the Chapters + foreach (var chapter in volume.Chapters) + { + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) continue; + try + { + var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); + UpdateChapterFromComicInfo(chapter, allPeople, allTags, allGenres, firstChapterInfo?.ComicInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was some issue when updating chapter's metadata"); + } + } + } + + // Remove existing volumes that aren't in parsedInfos + var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList(); + if (series.Volumes.Count != nonDeletedVolumes.Count) + { + _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); - var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); - foreach (var volume in deletedVolumes) - { - var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? ""; - if (!string.IsNullOrEmpty(file) && File.Exists(file)) - { - _logger.LogError( - "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", - file); - } + var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); + foreach (var volume in deletedVolumes) + { + var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? ""; + if (!string.IsNullOrEmpty(file) && File.Exists(file)) + { + _logger.LogError( + "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", + file); + } - _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); - } + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + } - series.Volumes = nonDeletedVolumes; - } + series.Volumes = nonDeletedVolumes; + } - _logger.LogDebug("[ScannerService] Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", - series.Name, startingVolumeCount, series.Volumes.Count); - } + _logger.LogDebug("[ScannerService] Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", + series.Name, startingVolumeCount, series.Volumes.Count); + } - /// - /// - /// - /// - /// - private void UpdateChapters(Volume volume, IList parsedInfos) - { - // Add new chapters - foreach (var info in parsedInfos) - { - // Specials go into their own chapters with Range being their filename and IsSpecial = True. Non-Specials with Vol and Chap as 0 - // also are treated like specials for UI grouping. - Chapter chapter; - try - { + private void UpdateChapters(Volume volume, IList parsedInfos) + { + // Add new chapters + foreach (var info in parsedInfos) + { + // Specials go into their own chapters with Range being their filename and IsSpecial = True. Non-Specials with Vol and Chap as 0 + // also are treated like specials for UI grouping. + Chapter chapter; + try + { chapter = volume.Chapters.GetChapterByRange(info); - } - catch (Exception ex) - { + } + catch (Exception ex) + { _logger.LogError(ex, "{FileName} mapped as '{Series} - Vol {Volume} Ch {Chapter}' is a duplicate, skipping", info.FullFilePath, info.Series, info.Volumes, info.Chapters); continue; - } + } - if (chapter == null) - { + if (chapter == null) + { _logger.LogDebug( - "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); + "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); volume.Chapters.Add(DbFactory.Chapter(info)); - } - else - { + } + else + { chapter.UpdateFrom(info); - } + } - } + } - // Add files - foreach (var info in parsedInfos) - { - var specialTreatment = info.IsSpecialInfo(); - Chapter chapter; - try - { + // Add files + foreach (var info in parsedInfos) + { + var specialTreatment = info.IsSpecialInfo(); + Chapter chapter; + try + { chapter = volume.Chapters.GetChapterByRange(info); - } - catch (Exception ex) - { + } + catch (Exception ex) + { _logger.LogError(ex, "There was an exception parsing chapter. Skipping {SeriesName} Vol {VolumeNumber} Chapter {ChapterNumber} - Special treatment: {NeedsSpecialTreatment}", info.Series, volume.Name, info.Chapters, specialTreatment); continue; - } - if (chapter == null) continue; - AddOrUpdateFileForChapter(chapter, info); - chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty; - chapter.Range = specialTreatment ? info.Filename : info.Chapters; - } + } + if (chapter == null) continue; + AddOrUpdateFileForChapter(chapter, info); + chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty; + chapter.Range = specialTreatment ? info.Filename : info.Chapters; + } - // Remove chapters that aren't in parsedInfos or have no files linked - var existingChapters = volume.Chapters.ToList(); - foreach (var existingChapter in existingChapters) - { - if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) - { + // Remove chapters that aren't in parsedInfos or have no files linked + var existingChapters = volume.Chapters.ToList(); + foreach (var existingChapter in existingChapters) + { + if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) + { _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); - } - else - { + } + else + { // Ensure we remove any files that no longer exist AND order existingChapter.Files = existingChapter.Files - .Where(f => parsedInfos.Any(p => p.FullFilePath == f.FilePath)) - .OrderBy(f => f.FilePath, _naturalSort).ToList(); + .Where(f => parsedInfos.Any(p => p.FullFilePath == f.FilePath)) + .OrderByNatural(f => f.FilePath).ToList(); existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); - } - } - } + } + } + } - private MangaFile CreateMangaFile(ParserInfo info) - { - MangaFile mangaFile = null; - switch (info.Format) - { - case MangaFormat.Archive: - { - mangaFile = new MangaFile() + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) + { + chapter.Files ??= new List(); + var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + if (existingFile != null) + { + existingFile.Format = info.Format; + if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; + existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); + // We skip updating DB here with last modified time so that metadata refresh can do it + } + else + { + var file = DbFactory.MangaFile(info.FullFilePath, info.Format, _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format)); + if (file == null) return; + + chapter.Files.Add(file); + } + } + + private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, ICollection allTags, ICollection allGenres, ComicInfo? info) + { + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile == null || + _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) return; + + var comicInfo = info; + if (info == null) + { + comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath); + } + + if (comicInfo == null) return; + + chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); + + if (!string.IsNullOrEmpty(comicInfo.Title)) + { + chapter.TitleName = comicInfo.Title.Trim(); + } + + if (!string.IsNullOrEmpty(comicInfo.Summary)) + { + chapter.Summary = comicInfo.Summary; + } + + if (!string.IsNullOrEmpty(comicInfo.LanguageISO)) + { + chapter.Language = comicInfo.LanguageISO; + } + + if (comicInfo.Count > 0) + { + chapter.TotalCount = comicInfo.Count; + } + + if (!string.IsNullOrEmpty(comicInfo.Number) && int.Parse(comicInfo.Number) > 0) + { + chapter.Count = int.Parse(comicInfo.Number); + } + + + + + if (comicInfo.Year > 0) + { + var day = Math.Max(comicInfo.Day, 1); + var month = Math.Max(comicInfo.Month, 1); + chapter.ReleaseDate = DateTime.Parse($"{month}/{day}/{comicInfo.Year}"); + } + + if (!string.IsNullOrEmpty(comicInfo.Colorist)) + { + var people = comicInfo.Colorist.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Colorist, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Characters)) + { + var people = comicInfo.Characters.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Character, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Translator)) + { + var people = comicInfo.Translator.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Tags)) + { + var tags = comicInfo.Tags.Split(",").Select(s => s.Trim()).ToList(); + // Remove all tags that aren't matching between chapter tags and metadata + TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); + TagHelper.UpdateTag(allTags, tags, false, + (tag, added) => { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath) - }; - break; - } - case MangaFormat.Pdf: - case MangaFormat.Epub: - { - mangaFile = new MangaFile() - { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = _bookService.GetNumberOfPages(info.FullFilePath) - }; - break; - } - case MangaFormat.Image: - { - mangaFile = new MangaFile() - { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = 1 - }; - break; - } - default: - _logger.LogWarning("[Scanner] Ignoring {Filename}. File type is not supported", info.Filename); - break; - } + chapter.Tags.Add(tag); + }); + } - mangaFile?.UpdateLastModified(); - return mangaFile; - } + if (!string.IsNullOrEmpty(comicInfo.Writer)) + { + var people = comicInfo.Writer.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Writer, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } - private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) - { - chapter.Files ??= new List(); - var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); - if (existingFile != null) - { - existingFile.Format = info.Format; - if (!existingFile.HasFileBeenModified() && existingFile.Pages != 0) return; - switch (existingFile.Format) - { - case MangaFormat.Epub: - case MangaFormat.Pdf: - existingFile.Pages = _bookService.GetNumberOfPages(info.FullFilePath); - break; - case MangaFormat.Image: - existingFile.Pages = 1; - break; - case MangaFormat.Unknown: - existingFile.Pages = 0; - break; - case MangaFormat.Archive: - existingFile.Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); - break; - } - existingFile.LastModified = File.GetLastWriteTime(info.FullFilePath); - } - else - { - var file = CreateMangaFile(info); - if (file == null) return; + if (!string.IsNullOrEmpty(comicInfo.Editor)) + { + var people = comicInfo.Editor.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Editor, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } - chapter.Files.Add(file); - } - } + if (!string.IsNullOrEmpty(comicInfo.Inker)) + { + var people = comicInfo.Inker.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Inker, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Letterer)) + { + var people = comicInfo.Letterer.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Letterer, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Penciller)) + { + var people = comicInfo.Penciller.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Penciller, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.CoverArtist)) + { + var people = comicInfo.CoverArtist.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.CoverArtist, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Publisher)) + { + var people = comicInfo.Publisher.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Genre)) + { + var genres = comicInfo.Genre.Split(","); + GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList()); + GenreHelper.UpdateGenre(allGenres, genres, false, + genre => chapter.Genres.Add(genre)); + } } } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 0052e0cb4..1b9f25593 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -2,108 +2,111 @@ using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; +using API.Data; using API.DTOs.Stats; using API.Entities.Enums; -using API.Interfaces; -using API.Interfaces.Services; using Flurl.Http; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks +namespace API.Services.Tasks; + +public interface IStatsService { - public class StatsService : IStatsService + Task Send(); + Task GetServerInfo(); +} +public class StatsService : IStatsService +{ + private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; + private const string ApiUrl = "https://stats.kavitareader.com"; + + public StatsService(ILogger logger, IUnitOfWork unitOfWork) { - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - private const string ApiUrl = "https://stats.kavitareader.com"; + _logger = logger; + _unitOfWork = unitOfWork; - public StatsService(ILogger logger, IUnitOfWork unitOfWork) + FlurlHttp.ConfigureClient(ApiUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + } + + /// + /// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run + /// randomly over a 6 hour spread + /// + public async Task Send() + { + var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; + if (!allowStatCollection) { - _logger = logger; - _unitOfWork = unitOfWork; - - FlurlHttp.ConfigureClient(ApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + return; } - /// - /// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run - /// randomly over a 6 hour spread - /// - public async Task Send() + await SendData(); + } + + /// + /// This must be public for Hangfire. Do not call this directly. + /// + // ReSharper disable once MemberCanBePrivate.Global + public async Task SendData() + { + var data = await GetServerInfo(); + await SendDataToStatsServer(data); + } + + + private async Task SendDataToStatsServer(ServerInfoDto data) + { + var responseContent = string.Empty; + + try { - var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; - if (!allowStatCollection) + var response = await (ApiUrl + "/api/v2/stats") + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(30)) + .PostJsonAsync(data); + + if (response.StatusCode != StatusCodes.Status200OK) { - return; - } - - await SendData(); - } - - /// - /// This must be public for Hangfire. Do not call this directly. - /// - // ReSharper disable once MemberCanBePrivate.Global - public async Task SendData() - { - var data = await GetServerInfo(); - await SendDataToStatsServer(data); - } - - - private async Task SendDataToStatsServer(ServerInfoDto data) - { - var responseContent = string.Empty; - - try - { - var response = await (ApiUrl + "/api/v2/stats") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) - .PostJsonAsync(data); - - if (response.StatusCode != StatusCodes.Status200OK) - { - _logger.LogError("KavitaStats did not respond successfully. {Content}", response); - } - } - catch (HttpRequestException e) - { - var info = new - { - dataSent = data, - response = responseContent - }; - - _logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info); - } - catch (Exception e) - { - _logger.LogError(e, "An error happened during the request to KavitaStats"); + _logger.LogError("KavitaStats did not respond successfully. {Content}", response); } } - - public async Task GetServerInfo() + catch (HttpRequestException e) { - var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId); - var serverInfo = new ServerInfoDto + var info = new { - InstallId = installId.Value, - Os = RuntimeInformation.OSDescription, - KavitaVersion = BuildInfo.Version.ToString(), - DotnetVersion = Environment.Version.ToString(), - IsDocker = new OsInfo(Array.Empty()).IsDocker, - NumOfCores = Math.Max(Environment.ProcessorCount, 1) + dataSent = data, + response = responseContent }; - return serverInfo; + _logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info); + } + catch (Exception e) + { + _logger.LogError(e, "An error happened during the request to KavitaStats"); } } + + public async Task GetServerInfo() + { + var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId); + var serverInfo = new ServerInfoDto + { + InstallId = installId.Value, + Os = RuntimeInformation.OSDescription, + KavitaVersion = BuildInfo.Version.ToString(), + DotnetVersion = Environment.Version.ToString(), + IsDocker = new OsInfo(Array.Empty()).IsDocker, + NumOfCores = Math.Max(Environment.ProcessorCount, 1) + }; + + return serverInfo; + } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 64e21d39a..178111051 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Net.Http; using System.Threading.Tasks; using API.DTOs.Update; -using API.Interfaces.Services; using API.SignalR; using API.SignalR.Presence; using Flurl.Http; @@ -15,159 +14,155 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks +namespace API.Services.Tasks; + +internal class GithubReleaseMetadata { - internal class GithubReleaseMetadata - { - /// - /// Name of the Tag - /// v0.4.3 - /// - // ReSharper disable once InconsistentNaming - public string Tag_Name { get; init; } - /// - /// Name of the Release - /// - public string Name { get; init; } - /// - /// Body of the Release - /// - public string Body { get; init; } - /// - /// Url of the release on Github - /// - // ReSharper disable once InconsistentNaming - public string Html_Url { get; init; } - /// - /// Date Release was Published - /// - // ReSharper disable once InconsistentNaming - public string Published_At { get; init; } - } + /// + /// Name of the Tag + /// v0.4.3 + /// + // ReSharper disable once InconsistentNaming + public string Tag_Name { get; init; } + /// + /// Name of the Release + /// + public string Name { get; init; } + /// + /// Body of the Release + /// + public string Body { get; init; } + /// + /// Url of the release on Github + /// + // ReSharper disable once InconsistentNaming + public string Html_Url { get; init; } + /// + /// Date Release was Published + /// + // ReSharper disable once InconsistentNaming + public string Published_At { get; init; } +} - public class UntrustedCertClientFactory : DefaultHttpClientFactory - { - public override HttpMessageHandler CreateMessageHandler() { - return new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } - } - - public class VersionUpdaterService : IVersionUpdaterService - { - private readonly ILogger _logger; - private readonly IHubContext _messageHub; - private readonly IPresenceTracker _tracker; - private readonly Markdown _markdown = new MarkdownDeep.Markdown(); -#pragma warning disable S1075 - private static readonly string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; - private static readonly string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; -#pragma warning restore S1075 - - public VersionUpdaterService(ILogger logger, IHubContext messageHub, IPresenceTracker tracker) - { - _logger = logger; - _messageHub = messageHub; - _tracker = tracker; - - FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - } - - /// - /// Fetches the latest release from Github - /// - public async Task CheckForUpdate() - { - var update = await GetGithubRelease(); - return CreateDto(update); - } - - /// - /// - /// - /// - public async Task> GetAllReleases() - { - var updates = await GetGithubReleases(); - return updates.Select(CreateDto); - } - - private UpdateNotificationDto CreateDto(GithubReleaseMetadata update) - { - if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null; - var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty)); - var currentVersion = BuildInfo.Version.ToString(); - - if (updateVersion.Revision == -1) - { - currentVersion = currentVersion.Substring(0, currentVersion.LastIndexOf(".", StringComparison.Ordinal)); - } - - return new UpdateNotificationDto() - { - CurrentVersion = currentVersion, - UpdateVersion = updateVersion.ToString(), - UpdateBody = _markdown.Transform(update.Body.Trim()), - UpdateTitle = update.Name, - UpdateUrl = update.Html_Url, - IsDocker = new OsInfo(Array.Empty()).IsDocker, - PublishDate = update.Published_At - }; - } - - public async Task PushUpdate(UpdateNotificationDto update) - { - if (update == null) return; - - var admins = await _tracker.GetOnlineAdmins(); - var updateVersion = new Version(update.CurrentVersion); - - if (BuildInfo.Version < updateVersion) - { - _logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion); - await SendEvent(update, admins); - } - else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) - { - _logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version); - await SendEvent(update, admins); - } - } - - private async Task SendEvent(UpdateNotificationDto update, IReadOnlyList admins) - { - var connections = new List(); - foreach (var admin in admins) - { - connections.AddRange(await _tracker.GetConnectionsForUser(admin)); - } - - await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateVersion, MessageFactory.UpdateVersionEvent(update)); - } - - - private static async Task GetGithubRelease() - { - var update = await GithubLatestReleasesUrl - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .GetJsonAsync(); - - return update; - } - - private static async Task> GetGithubReleases() - { - var update = await GithubAllReleasesUrl - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .GetJsonAsync>(); - - return update; - } +public class UntrustedCertClientFactory : DefaultHttpClientFactory +{ + public override HttpMessageHandler CreateMessageHandler() { + return new HttpClientHandler { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + } +} + +public interface IVersionUpdaterService +{ + Task CheckForUpdate(); + Task PushUpdate(UpdateNotificationDto update); + Task> GetAllReleases(); +} + +public class VersionUpdaterService : IVersionUpdaterService +{ + private readonly ILogger _logger; + private readonly IHubContext _messageHub; + private readonly IPresenceTracker _tracker; + private readonly Markdown _markdown = new MarkdownDeep.Markdown(); +#pragma warning disable S1075 + private static readonly string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; + private static readonly string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; +#pragma warning restore S1075 + + public VersionUpdaterService(ILogger logger, IHubContext messageHub, IPresenceTracker tracker) + { + _logger = logger; + _messageHub = messageHub; + _tracker = tracker; + + FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + } + + /// + /// Fetches the latest release from Github + /// + public async Task CheckForUpdate() + { + var update = await GetGithubRelease(); + return CreateDto(update); + } + + public async Task> GetAllReleases() + { + var updates = await GetGithubReleases(); + return updates.Select(CreateDto); + } + + private UpdateNotificationDto CreateDto(GithubReleaseMetadata update) + { + if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null; + var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty)); + var currentVersion = BuildInfo.Version.ToString(); + + if (updateVersion.Revision == -1) + { + currentVersion = currentVersion.Substring(0, currentVersion.LastIndexOf(".", StringComparison.Ordinal)); + } + + return new UpdateNotificationDto() + { + CurrentVersion = currentVersion, + UpdateVersion = updateVersion.ToString(), + UpdateBody = _markdown.Transform(update.Body.Trim()), + UpdateTitle = update.Name, + UpdateUrl = update.Html_Url, + IsDocker = new OsInfo(Array.Empty()).IsDocker, + PublishDate = update.Published_At + }; + } + + public async Task PushUpdate(UpdateNotificationDto update) + { + if (update == null) return; + + var admins = await _tracker.GetOnlineAdmins(); + var updateVersion = new Version(update.CurrentVersion); + + if (BuildInfo.Version < updateVersion) + { + _logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion); + await SendEvent(update, admins); + } + else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) + { + _logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version); + await SendEvent(update, admins); + } + } + + private async Task SendEvent(UpdateNotificationDto update, IReadOnlyList admins) + { + await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateAvailable, MessageFactory.UpdateVersionEvent(update)); + } + + + private static async Task GetGithubRelease() + { + var update = await GithubLatestReleasesUrl + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync(); + + return update; + } + + private static async Task> GetGithubReleases() + { + var update = await GithubAllReleasesUrl + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync>(); + + return update; } } diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 3b292cb8c..8145b330e 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -6,51 +6,54 @@ using System.Security.Claims; using System.Text; using System.Threading.Tasks; using API.Entities; -using API.Interfaces.Services; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; -namespace API.Services +namespace API.Services; + +public interface ITokenService { - public class TokenService : ITokenService + Task CreateToken(AppUser user); +} + +public class TokenService : ITokenService +{ + private readonly UserManager _userManager; + private readonly SymmetricSecurityKey _key; + + public TokenService(IConfiguration config, UserManager userManager) { - private readonly UserManager _userManager; - private readonly SymmetricSecurityKey _key; - public TokenService(IConfiguration config, UserManager userManager) - { - - _userManager = userManager; - _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); - } - - public async Task CreateToken(AppUser user) - { - var claims = new List - { - new Claim(JwtRegisteredClaimNames.NameId, user.UserName) - }; - - var roles = await _userManager.GetRolesAsync(user); - - claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); - - var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); - - var tokenDescriptor = new SecurityTokenDescriptor() - { - Subject = new ClaimsIdentity(claims), - Expires = DateTime.Now.AddDays(7), - SigningCredentials = creds - }; - - var tokenHandler = new JwtSecurityTokenHandler(); - var token = tokenHandler.CreateToken(tokenDescriptor); - - return tokenHandler.WriteToken(token); - } + _userManager = userManager; + _key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"])); } -} \ No newline at end of file + + public async Task CreateToken(AppUser user) + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.NameId, user.UserName) + }; + + var roles = await _userManager.GetRolesAsync(user); + + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + var creds = new SigningCredentials(_key, SecurityAlgorithms.HmacSha512Signature); + + var tokenDescriptor = new SecurityTokenDescriptor() + { + Subject = new ClaimsIdentity(claims), + Expires = DateTime.Now.AddDays(7), + SigningCredentials = creds + }; + + var tokenHandler = new JwtSecurityTokenHandler(); + var token = tokenHandler.CreateToken(tokenDescriptor); + + return tokenHandler.WriteToken(token); + } +} diff --git a/API/Services/WarmupServiceStartupTask.cs b/API/Services/WarmupServiceStartupTask.cs deleted file mode 100644 index 36463451a..000000000 --- a/API/Services/WarmupServiceStartupTask.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using API.Interfaces.Services; -using Microsoft.Extensions.DependencyInjection; - -namespace API.Services -{ - public class WarmupServicesStartupTask : IStartupTask - { - private readonly IServiceCollection _services; - private readonly IServiceProvider _provider; - public WarmupServicesStartupTask(IServiceCollection services, IServiceProvider provider) - { - _services = services; - _provider = provider; - } - - public Task ExecuteAsync(CancellationToken cancellationToken = default) - { - using var scope = _provider.CreateScope(); - foreach (var singleton in GetServices(_services)) - { - Console.WriteLine("DI preloading of " + singleton.FullName); - scope.ServiceProvider.GetServices(singleton); - } - - return Task.CompletedTask; - } - - static IEnumerable GetServices(IServiceCollection services) - { - return services - .Where(descriptor => descriptor.ImplementationType != typeof(WarmupServicesStartupTask)) - .Where(descriptor => !descriptor.ServiceType.ContainsGenericParameters) - .Select(descriptor => descriptor.ServiceType) - .Distinct(); - } - } - -} \ No newline at end of file diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 3ab6c646c..25262430a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -50,7 +50,7 @@ namespace API.SignalR { return new SignalRMessage() { - Name = SignalREvents.ScanLibrary, + Name = SignalREvents.ScanLibraryProgress, Body = new { LibraryId = libraryId, @@ -118,7 +118,7 @@ namespace API.SignalR { return new SignalRMessage { - Name = SignalREvents.UpdateVersion, + Name = SignalREvents.UpdateAvailable, Body = update }; } @@ -127,7 +127,7 @@ namespace API.SignalR { return new SignalRMessage { - Name = SignalREvents.UpdateVersion, + Name = SignalREvents.UpdateAvailable, Body = new { TagId = tagId, @@ -147,5 +147,19 @@ namespace API.SignalR } }; } + + public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.DownloadProgress, + Body = new + { + UserName = username, + DownloadName = downloadName, + Progress = progress + } + }; + } } } diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs index 1453bd0f7..73d6479ff 100644 --- a/API/SignalR/Presence/PresenceTracker.cs +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Interfaces; +using API.Data; namespace API.SignalR.Presence { diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 06908c2be..15590f426 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -2,7 +2,7 @@ { public static class SignalREvents { - public const string UpdateVersion = "UpdateVersion"; + public const string UpdateAvailable = "UpdateAvailable"; public const string ScanSeries = "ScanSeries"; /// /// Event during Refresh Metadata for cover image change @@ -12,12 +12,29 @@ /// Event sent out during Refresh Metadata for progress tracking /// public const string RefreshMetadataProgress = "RefreshMetadataProgress"; - public const string ScanLibrary = "ScanLibrary"; + /// + /// Series is added to server + /// public const string SeriesAdded = "SeriesAdded"; + /// + /// Series is removed from server + /// public const string SeriesRemoved = "SeriesRemoved"; + /// + /// Progress event for Scan library + /// public const string ScanLibraryProgress = "ScanLibraryProgress"; + /// + /// 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"; + /// + /// When an error occurs during a scan library task + /// public const string ScanLibraryError = "ScanLibraryError"; /// /// Event sent out during backing up the database @@ -27,5 +44,10 @@ /// Event sent out during cleaning up temp and cache folders /// public const string CleanupProgress = "CleanupProgress"; + /// + /// Event sent out during downloading of files + /// + public const string DownloadProgress = "DownloadProgress"; + } } diff --git a/API/SignalR/SignalRMessage.cs b/API/SignalR/SignalRMessage.cs index 89b992b4b..dfb181105 100644 --- a/API/SignalR/SignalRMessage.cs +++ b/API/SignalR/SignalRMessage.cs @@ -1,5 +1,8 @@ namespace API.SignalR { + /// + /// Payload for SignalR messages to Frontend + /// public class SignalRMessage { public object Body { get; set; } diff --git a/API/Startup.cs b/API/Startup.cs index 6668927b4..00bfdd589 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -4,12 +4,14 @@ using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Threading.Tasks; +using API.Data; +using API.Entities; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Repositories; using API.Middleware; using API.Services; using API.Services.HostedServices; +using API.Services.Tasks; using API.SignalR; using Hangfire; using Hangfire.MemoryStorage; @@ -19,13 +21,16 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Models; +using TaskScheduler = API.Services.TaskScheduler; namespace API { @@ -128,8 +133,66 @@ namespace API // 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) + 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 + // If we have pending migrations, make a backup first + //var isDocker = new OsInfo(Array.Empty()).IsDocker; + var logger = serviceProvider.GetRequiredService>(); + var context = serviceProvider.GetRequiredService(); + // var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); + // if (pendingMigrations.Any()) + // { + // logger.LogInformation("Performing backup as migrations are needed"); + // await backupService.BackupDatabase(); + // } + // + // await context.Database.MigrateAsync(); + // var roleManager = serviceProvider.GetRequiredService>(); + // + // await Seed.SeedRoles(roleManager); + // await Seed.SeedSettings(context, directoryService); + // await Seed.SeedUserApiKeys(context); + + await MigrateBookmarks.Migrate(directoryService, unitOfWork, + logger, cacheService); + + var requiresCoverImageMigration = !Directory.Exists(directoryService.CoverImageDirectory); + try + { + // If this is a new install, tables wont exist yet + if (requiresCoverImageMigration) + { + MigrateCoverImages.ExtractToImages(context, directoryService, imageService); + } + } + catch (Exception) + { + requiresCoverImageMigration = false; + } + + if (requiresCoverImageMigration) + { + await MigrateCoverImages.UpdateDatabaseWithImages(context, directoryService); + } + }).GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + var logger = serviceProvider.GetRequiredService>(); + logger.LogCritical(ex, "An error occurred during migration"); + } + + + app.UseMiddleware(); if (env.IsDevelopment()) @@ -146,7 +209,7 @@ namespace API app.UseForwardedHeaders(new ForwardedHeadersOptions { - ForwardedHeaders = ForwardedHeaders.All + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }); app.UseRouting(); diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 6e6899f3f..7593ae84a 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text.Json; using Kavita.Common.EnvironmentInfo; using Microsoft.Extensions.Hosting; @@ -193,12 +194,9 @@ namespace Kavita.Common foreach (var property in tokenElement.EnumerateObject()) { if (!property.Name.Equals("LogLevel")) continue; - foreach (var logProperty in property.Value.EnumerateObject()) + foreach (var logProperty in property.Value.EnumerateObject().Where(logProperty => logProperty.Name.Equals("Default"))) { - if (logProperty.Name.Equals("Default")) - { - return logProperty.Value.GetString(); - } + return logProperty.Value.GetString(); } } } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 005b8a5b8..8285453bb 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -1,17 +1,17 @@ - net5.0 + net6.0 kavitareader.com Kavita - 0.4.9.2 + 0.5.0.0 en - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 5f8160aba..8a71fcd8b 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -5326,9 +5326,9 @@ "dev": true }, "decimal.js": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz", - "integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", + "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", "dev": true }, "decode-uri-component": { @@ -5352,9 +5352,9 @@ } }, "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, "deepmerge": { @@ -5956,18 +5956,24 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", "dev": true, "requires": { "esprima": "^4.0.1", - "estraverse": "^4.2.0", + "estraverse": "^5.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1", "source-map": "~0.6.1" }, "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6623,9 +6629,9 @@ } }, "follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==", + "version": "1.14.7", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz", + "integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==", "dev": true }, "for-in": { @@ -7856,9 +7862,9 @@ } }, "is-potential-custom-element-name": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz", - "integrity": "sha1-DFLlS8yjkbssSUsh6GJtczbG45c=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, "is-regex": { @@ -9593,66 +9599,91 @@ "dev": true }, "jsdom": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.4.0.tgz", - "integrity": "sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w==", + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", "dev": true, "requires": { - "abab": "^2.0.3", - "acorn": "^7.1.1", + "abab": "^2.0.5", + "acorn": "^8.2.4", "acorn-globals": "^6.0.0", "cssom": "^0.4.4", - "cssstyle": "^2.2.0", + "cssstyle": "^2.3.0", "data-urls": "^2.0.0", - "decimal.js": "^10.2.0", + "decimal.js": "^10.2.1", "domexception": "^2.0.1", - "escodegen": "^1.14.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", "html-encoding-sniffer": "^2.0.1", - "is-potential-custom-element-name": "^1.0.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.0", - "parse5": "5.1.1", - "request": "^2.88.2", - "request-promise-native": "^1.0.8", - "saxes": "^5.0.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", "symbol-tree": "^3.2.4", - "tough-cookie": "^3.0.1", + "tough-cookie": "^4.0.0", "w3c-hr-time": "^1.0.2", "w3c-xmlserializer": "^2.0.0", "webidl-conversions": "^6.1.0", "whatwg-encoding": "^1.0.5", "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0", - "ws": "^7.2.3", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", "xml-name-validator": "^3.0.0" }, "dependencies": { "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", + "integrity": "sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw==", "dev": true }, - "parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true - }, - "tough-cookie": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", - "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "requires": { - "ip-regex": "^2.1.0", - "psl": "^1.1.28", - "punycode": "^2.1.1" + "debug": "4" + } + }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.1.2" } }, "ws": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", - "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.6.tgz", + "integrity": "sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==", "dev": true } } @@ -9675,9 +9706,9 @@ "dev": true }, "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "dev": true }, "json-schema-traverse": { @@ -9728,14 +9759,14 @@ "dev": true }, "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", - "json-schema": "0.2.3", + "json-schema": "0.4.0", "verror": "1.10.0" } }, @@ -9955,12 +9986,6 @@ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true - }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -13093,26 +13118,6 @@ } } }, - "request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "dev": true, - "requires": { - "lodash": "^4.17.19" - } - }, - "request-promise-native": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", - "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", - "dev": true, - "requires": { - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - } - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14442,12 +14447,6 @@ "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", "dev": true }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", - "dev": true - }, "stream-browserify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", @@ -15009,9 +15008,9 @@ } }, "tr46": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", - "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, "requires": { "punycode": "^2.1.1" @@ -16651,13 +16650,13 @@ "dev": true }, "whatwg-url": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz", - "integrity": "sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^2.0.2", + "lodash": "^4.7.0", + "tr46": "^2.1.0", "webidl-conversions": "^6.1.0" } }, @@ -16819,9 +16818,9 @@ } }, "ws": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", - "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz", + "integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==", "requires": { "async-limiter": "~1.0.0" } diff --git a/UI/Web/src/app/_models/chapter-metadata.ts b/UI/Web/src/app/_models/chapter-metadata.ts new file mode 100644 index 000000000..56f400210 --- /dev/null +++ b/UI/Web/src/app/_models/chapter-metadata.ts @@ -0,0 +1,16 @@ +import { Person } from "./person"; + +export interface ChapterMetadata { + id: number; + chapterId: number; + title: string; + year: string; + writers: Array; + penciller: Array; + inker: Array; + colorist: Array; + letterer: Array; + coverArtist: Array; + editor: Array; + publishers: Array; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 4a9399489..1a66e2471 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -1,4 +1,6 @@ import { MangaFile } from './manga-file'; +import { Person } from './person'; +import { Tag } from './tag'; export interface Chapter { id: number; @@ -16,4 +18,19 @@ export interface Chapter { isSpecial: boolean; title: string; created: string; + + titleName: string; + /** + * This is only Year and Month, Day is not supported from underlying sources + */ + releaseDate: string; + writers: Array; + penciller: Array; + inker: Array; + colorist: Array; + letterer: Array; + coverArtist: Array; + editor: Array; + publisher: Array; + tags: Array; } diff --git a/UI/Web/src/app/_models/genre.ts b/UI/Web/src/app/_models/genre.ts new file mode 100644 index 000000000..f1160230d --- /dev/null +++ b/UI/Web/src/app/_models/genre.ts @@ -0,0 +1,4 @@ +export interface Genre { + id: number, + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/age-rating-dto.ts b/UI/Web/src/app/_models/metadata/age-rating-dto.ts new file mode 100644 index 000000000..ebf0728b9 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/age-rating-dto.ts @@ -0,0 +1,6 @@ +import { AgeRating } from "./age-rating"; + +export interface AgeRatingDto { + value: AgeRating; + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/age-rating.ts b/UI/Web/src/app/_models/metadata/age-rating.ts new file mode 100644 index 000000000..d44a8e250 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/age-rating.ts @@ -0,0 +1,15 @@ +export enum AgeRating { + Unknown = 0, + AdultsOnly = 1, + EarlyChildhood = 2, + Everyone = 3, + Everyone10Plus = 4, + G = 5, + KidsToAdults = 6, + Mature = 7, + Mature15Plus = 8, + Mature17Plus = 9, + RatingPending = 10, + Teen = 11, + X18Plus = 12 +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts new file mode 100644 index 000000000..c88ff3939 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -0,0 +1,4 @@ +export interface Language { + isoCode: string; + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/publication-status-dto.ts b/UI/Web/src/app/_models/metadata/publication-status-dto.ts new file mode 100644 index 000000000..74281c2d2 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/publication-status-dto.ts @@ -0,0 +1,6 @@ +import { PublicationStatus } from "./publication-status"; + +export interface PublicationStatusDto { + value: PublicationStatus; + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/publication-status.ts b/UI/Web/src/app/_models/metadata/publication-status.ts new file mode 100644 index 000000000..7881da383 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/publication-status.ts @@ -0,0 +1,5 @@ +export enum PublicationStatus { + OnGoing = 0, + Hiatus = 1, + Completed = 2 +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/page-bookmark.ts b/UI/Web/src/app/_models/page-bookmark.ts index 6d46bfe4d..e47ef0a06 100644 --- a/UI/Web/src/app/_models/page-bookmark.ts +++ b/UI/Web/src/app/_models/page-bookmark.ts @@ -4,4 +4,5 @@ export interface PageBookmark { seriesId: number; volumeId: number; chapterId: number; + fileName: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/person.ts b/UI/Web/src/app/_models/person.ts index a8a60436d..e23925cef 100644 --- a/UI/Web/src/app/_models/person.ts +++ b/UI/Web/src/app/_models/person.ts @@ -1,10 +1,20 @@ export enum PersonRole { - Other = 0, - Author = 1, - Artist = 2 + Other = 1, + Artist = 2, + Writer = 3, + Penciller = 4, + Inker = 5, + Colorist = 6, + Letterer = 7, + CoverArtist = 8, + Editor = 9, + Publisher = 10, + Character = 11, + Translator = 12 } export interface Person { + id: number; name: string; role: PersonRole; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 7e44dbbee..664afb7f7 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,3 +1,4 @@ + import { PageSplitOption } from './page-split-option'; import { READER_MODE } from './reader-mode'; import { ReadingDirection } from './reading-direction'; diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index a81cfe381..068054a27 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -1,38 +1,70 @@ import { MangaFormat } from "./manga-format"; -export interface FilterItem { +export interface FilterItem { title: string; - value: any; + value: T; selected: boolean; } export interface SeriesFilter { - mangaFormat: MangaFormat | null; + formats: Array; + libraries: Array, + readStatus: ReadStatus; + genres: Array; + writers: Array; + penciller: Array; + inker: Array; + colorist: Array; + letterer: Array; + coverArtist: Array; + editor: Array; + publisher: Array; + character: Array; + translators: Array; + collectionTags: Array; + rating: number; + ageRating: Array; + sortOptions: SortOptions | null; + tags: Array; + languages: Array; + publicationStatus: Array; +} + +export interface SortOptions { + sortField: SortField; + isAscending: boolean; +} + +export enum SortField { + SortName = 1, + Created = 2, + LastModified = 3 +} + +export interface ReadStatus { + notRead: boolean, + inProgress: boolean, + read: boolean, } export const mangaFormatFilters = [ { - title: 'Format: All', - value: null, - selected: false - }, - { - title: 'Format: Images', + title: 'Images', value: MangaFormat.IMAGE, selected: false }, { - title: 'Format: EPUB', + title: 'EPUB', value: MangaFormat.EPUB, selected: false }, { - title: 'Format: PDF', + title: 'PDF', value: MangaFormat.PDF, selected: false }, { - title: 'Format: ARCHIVE', + title: 'ARCHIVE', value: MangaFormat.ARCHIVE, selected: false } diff --git a/UI/Web/src/app/_models/series-metadata.ts b/UI/Web/src/app/_models/series-metadata.ts index 4cba7848f..e143a9639 100644 --- a/UI/Web/src/app/_models/series-metadata.ts +++ b/UI/Web/src/app/_models/series-metadata.ts @@ -1,10 +1,29 @@ import { CollectionTag } from "./collection-tag"; +import { Genre } from "./genre"; +import { AgeRating } from "./metadata/age-rating"; +import { PublicationStatus } from "./metadata/publication-status"; import { Person } from "./person"; +import { Tag } from "./tag"; export interface SeriesMetadata { publisher: string; - genres: Array; - tags: Array; - persons: Array; + summary: string; + genres: Array; + tags: Array; + collectionTags: Array; + writers: Array; + coverArtists: Array; + publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + colorists: Array; + letterers: Array; + editors: Array; + translators: Array; + ageRating: AgeRating; + releaseYear: number; + language: string; seriesId: number; + publicationStatus: PublicationStatus; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index be233122b..332407105 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -7,7 +7,6 @@ export interface Series { originalName: string; // This is not shown to user localizedName: string; sortName: string; - summary: string; coverImageLocked: boolean; volumes: Volume[]; pages: number; // Total pages in series diff --git a/UI/Web/src/app/_models/tag.ts b/UI/Web/src/app/_models/tag.ts new file mode 100644 index 000000000..c75d48be6 --- /dev/null +++ b/UI/Web/src/app/_models/tag.ts @@ -0,0 +1,4 @@ +export interface Tag { + id: number, + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 0df929e8f..a0d1609ff 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -79,7 +79,7 @@ export class ActionFactoryService { this.seriesActions.push({ action: Action.RefreshMetadata, - title: 'Refresh Metadata', + title: 'Refresh Covers', callback: this.dummyCallback, requiresAdmin: true }); @@ -114,7 +114,7 @@ export class ActionFactoryService { this.libraryActions.push({ action: Action.RefreshMetadata, - title: 'Refresh Metadata', + title: 'Refresh Covers', callback: this.dummyCallback, requiresAdmin: true }); diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 5ada05dc2..66615388c 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -23,6 +23,7 @@ export type VolumeActionCallback = (volume: Volume) => void; export type ChapterActionCallback = (chapter: Chapter) => void; export type ReadingListActionCallback = (readingList: ReadingList) => void; export type VoidActionCallback = () => void; +export type BooleanActionCallback = (result: boolean) => void; /** * Responsible for executing actions @@ -57,7 +58,7 @@ export class ActionService implements OnDestroy { return; } this.libraryService.scan(library?.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Scan started for ' + library.name); + this.toastr.success('Scan queued for ' + library.name); if (callback) { callback(library); } @@ -75,12 +76,15 @@ export class ActionService implements OnDestroy { return; } - if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (!await this.confirmService.confirm('Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (callback) { + callback(library); + } return; } this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Scan started for ' + library.name); + this.toastr.success('Scan queued for ' + library.name); if (callback) { callback(library); } @@ -124,7 +128,7 @@ export class ActionService implements OnDestroy { */ scanSeries(series: Series, callback?: SeriesActionCallback) { this.seriesService.scan(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Scan started for ' + series.name); + this.toastr.success('Scan queued for ' + series.name); if (callback) { callback(series); } @@ -137,7 +141,10 @@ export class ActionService implements OnDestroy { * @param callback Optional callback to perform actions after API completes */ async refreshMetdata(series: Series, callback?: SeriesActionCallback) { - if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (!await this.confirmService.confirm('Refresh covers will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (callback) { + callback(series); + } return; } @@ -484,4 +491,20 @@ export class ActionService implements OnDestroy { }); } + async deleteSeries(series: Series, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) { + if (callback) { + callback(false); + } + return; + } + + this.seriesService.delete(series.id).subscribe((res: boolean) => { + if (callback) { + this.toastr.success('Series deleted'); + callback(res); + } + }); + } + } diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 2141d3e4d..4cb21c299 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -1,18 +1,24 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { AccountService } from './account.service'; import { NavService } from './nav.service'; @Injectable({ providedIn: 'root' }) -export class ImageService { +export class ImageService implements OnDestroy { baseUrl = environment.apiUrl; + apiKey: string = ''; public placeholderImage = 'assets/images/image-placeholder-min.png'; public errorImage = 'assets/images/error-placeholder2-min.png'; public resetCoverImage = 'assets/images/image-reset-cover-min.png'; - constructor(private navSerivce: NavService) { + private onDestroy: Subject = new Subject(); + + constructor(private navSerivce: NavService, private accountService: AccountService) { this.navSerivce.darkMode$.subscribe(res => { if (res) { this.placeholderImage = 'assets/images/image-placeholder.dark-min.png'; @@ -22,6 +28,17 @@ export class ImageService { this.errorImage = 'assets/images/error-placeholder2-min.png'; } }); + + this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => { + if (user) { + this.apiKey = user.apiKey; + } + }); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); } getVolumeCoverImage(volumeId: number) { @@ -41,7 +58,7 @@ export class ImageService { } getBookmarkedImage(chapterId: number, pageNum: number) { - return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId + '&pageNum=' + pageNum; + return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey); } updateErroredImage(event: any) { diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 85a543322..259f2a4c1 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -24,7 +24,8 @@ export enum EVENTS { SeriesAddedToCollection = 'SeriesAddedToCollection', ScanLibraryError = 'ScanLibraryError', BackupDatabaseProgress = 'BackupDatabaseProgress', - CleanupProgress = 'CleanupProgress' + CleanupProgress = 'CleanupProgress', + DownloadProgress = 'DownloadProgress' } export interface Message { @@ -38,7 +39,6 @@ export interface Message { export class MessageHubService { hubUrl = environment.hubUrl; private hubConnection!: HubConnection; - private updateNotificationModalRef: NgbModalRef | null = null; private messagesSource = new ReplaySubject>(1); public messages$ = this.messagesSource.asObservable(); @@ -53,7 +53,7 @@ export class MessageHubService { isAdmin: boolean = false; - constructor(private modalService: NgbModal, private toastr: ToastrService, private router: Router) { + constructor(private toastr: ToastrService, private router: Router) { } @@ -106,6 +106,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.DownloadProgress, resp => { + this.messagesSource.next({ + event: EVENTS.DownloadProgress, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => { this.messagesSource.next({ event: EVENTS.RefreshMetadataProgress, @@ -162,16 +169,6 @@ export class MessageHubService { event: EVENTS.UpdateAvailable, payload: resp.body }); - // Ensure only 1 instance of UpdateNotificationModal can be open at once - if (this.updateNotificationModalRef != null) { return; } - this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' }); - this.updateNotificationModalRef.componentInstance.updateData = resp.body; - this.updateNotificationModalRef.closed.subscribe(() => { - this.updateNotificationModalRef = null; - }); - this.updateNotificationModalRef.dismissed.subscribe(() => { - this.updateNotificationModalRef = null; - }); }); } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts new file mode 100644 index 000000000..4d64a7920 --- /dev/null +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -0,0 +1,87 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { environment } from 'src/environments/environment'; +import { ChapterMetadata } from '../_models/chapter-metadata'; +import { Genre } from '../_models/genre'; +import { AgeRating } from '../_models/metadata/age-rating'; +import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; +import { Language } from '../_models/metadata/language'; +import { PublicationStatusDto } from '../_models/metadata/publication-status-dto'; +import { Person } from '../_models/person'; +import { Tag } from '../_models/tag'; + +@Injectable({ + providedIn: 'root' +}) +export class MetadataService { + + baseUrl = environment.apiUrl; + + private ageRatingTypes: {[key: number]: string} | undefined = undefined; + + constructor(private httpClient: HttpClient) { } + + getAgeRating(ageRating: AgeRating) { + if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) { + return of(this.ageRatingTypes[ageRating]); + } + return this.httpClient.get(this.baseUrl + 'series/age-rating?ageRating=' + ageRating, {responseType: 'text' as 'json'}).pipe(map(ratingString => { + if (this.ageRatingTypes === undefined) { + this.ageRatingTypes = {}; + } + + this.ageRatingTypes[ageRating] = ratingString; + return this.ageRatingTypes[ageRating]; + })); + } + + getAllAgeRatings(libraries?: Array) { + let method = 'metadata/age-ratings' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get>(this.baseUrl + method);; + } + + getAllPublicationStatus(libraries?: Array) { + let method = 'metadata/publication-status' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get>(this.baseUrl + method);; + } + + getAllTags(libraries?: Array) { + let method = 'metadata/tags' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get>(this.baseUrl + method);; + } + + getAllGenres(libraries?: Array) { + let method = 'metadata/genres' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get(this.baseUrl + method); + } + + getAllLanguages(libraries?: Array) { + let method = 'metadata/languages' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get(this.baseUrl + method); + } + + getAllPeople(libraries?: Array) { + let method = 'metadata/people' + if (libraries != undefined && libraries.length > 0) { + method += '?libraryIds=' + libraries.join(','); + } + return this.httpClient.get(this.baseUrl + method); + } +} diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 2412c794a..74ccc3339 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Renderer2, RendererFactory2 } from '@angular/core'; import { ReplaySubject } from 'rxjs'; @Injectable({ @@ -13,7 +13,10 @@ export class NavService { private darkModeSource = new ReplaySubject(1); darkMode$ = this.darkModeSource.asObservable(); - constructor() { + private renderer: Renderer2; + + constructor(rendererFactory: RendererFactory2) { + this.renderer = rendererFactory.createRenderer(null, null); this.showNavBar(); } @@ -27,13 +30,23 @@ export class NavService { toggleDarkMode() { this.darkMode = !this.darkMode; + this.updateColorScheme(); this.darkModeSource.next(this.darkMode); } setDarkMode(mode: boolean) { this.darkMode = mode; + this.updateColorScheme(); this.darkModeSource.next(this.darkMode); } + private updateColorScheme() { + if (this.darkMode) { + this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'dark'); + } else { + this.renderer.setStyle(document.querySelector('html'), 'color-scheme', 'light'); + } + } + } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 8281ab9e9..16c2ea656 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -193,4 +193,34 @@ export class ReaderService { } return params; } + + enterFullscreen(el: Element, callback?: VoidFunction) { + if (!document.fullscreenElement) { + if (el.requestFullscreen) { + el.requestFullscreen().then(() => { + if (callback) { + callback(); + } + }); + } + } + } + + exitFullscreen(callback?: VoidFunction) { + if (document.exitFullscreen && this.checkFullscreenMode()) { + document.exitFullscreen().then(() => { + if (callback) { + callback(); + } + }); + } + } + + /** + * + * @returns If document is in fullscreen mode + */ + checkFullscreenMode() { + return document.fullscreenElement != null; + } } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index e520154d6..3ad5255f6 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -75,7 +75,7 @@ export class ReadingListService { } removeRead(readingListId: number) { - return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, { responseType: 'text' as 'json' }); + return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, { responseType: 'text' as 'json' }); } actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 8ed258c45..af02b8e88 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -8,7 +8,7 @@ import { CollectionTag } from '../_models/collection-tag'; import { InProgressChapter } from '../_models/in-progress-chapter'; import { PaginatedResult } from '../_models/pagination'; import { Series } from '../_models/series'; -import { SeriesFilter } from '../_models/series-filter'; +import { ReadStatus, SeriesFilter } from '../_models/series-filter'; import { SeriesMetadata } from '../_models/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; @@ -39,6 +39,18 @@ export class SeriesService { return paginatedVariable; } + getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + let params = new HttpParams(); + params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + const data = this.createSeriesFilter(filter); + + return this.httpClient.post>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( + map((response: any) => { + return this._cachePaginatedResults(response, this.paginatedResults); + }) + ); + } + getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); params = this._addPaginationIfExists(params, pageNum, itemsPerPage); @@ -137,7 +149,7 @@ export class SeriesService { getMetadata(seriesId: number) { return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => { - items?.tags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); + items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); return items; })); } @@ -177,13 +189,35 @@ export class SeriesService { createSeriesFilter(filter?: SeriesFilter) { const data: SeriesFilter = { - mangaFormat: null + formats: [], + libraries: [], + genres: [], + writers: [], + penciller: [], + inker: [], + colorist: [], + letterer: [], + coverArtist: [], + editor: [], + publisher: [], + character: [], + translators: [], + collectionTags: [], + rating: 0, + readStatus: { + read: true, + inProgress: true, + notRead: true + }, + sortOptions: null, + ageRating: [], + tags: [], + languages: [], + publicationStatus: [], }; - if (filter) { - data.mangaFormat = filter.mangaFormat; - } + if (filter === undefined) return data; - return data; + return filter; } } diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 6f6d02630..60debc54d 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -36,6 +36,7 @@ Back + @@ -50,6 +51,6 @@ diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index 85fa530bb..2885c40de 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Stack } from 'src/app/shared/data-structures/stack'; import { LibraryService } from '../../../_services/library.service'; @@ -17,6 +17,12 @@ export interface DirectoryPickerResult { }) export class DirectoryPickerComponent implements OnInit { + @Input() startingFolder: string = ''; + /** + * Url to give more information about selecting directories. Passing nothing will suppress. + */ + @Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/adding-a-library'; + currentRoot = ''; folders: string[] = []; routeStack: Stack = new Stack(); @@ -27,7 +33,22 @@ export class DirectoryPickerComponent implements OnInit { } ngOnInit(): void { - this.loadChildren(this.currentRoot); + if (this.startingFolder && this.startingFolder.length > 0) { + let folders = this.startingFolder.split('/'); + let folders2 = this.startingFolder.split('\\'); + if (folders.length === 1 && folders2.length > 1) { + folders = folders2; + } + if (!folders[0].endsWith('/')) { + folders[0] = folders[0] + '/'; + } + folders.forEach(folder => this.routeStack.push(folder)); + + const fullPath = this.routeStack.items.join('/'); + this.loadChildren(fullPath); + } else { + this.loadChildren(this.currentRoot); + } } filterFolder = (folder: string) => { @@ -38,7 +59,7 @@ export class DirectoryPickerComponent implements OnInit { this.currentRoot = folderName; this.routeStack.push(folderName); const fullPath = this.routeStack.items.join('/'); - this.loadChildren(fullPath); + this.loadChildren(fullPath); } goBack() { @@ -86,7 +107,7 @@ export class DirectoryPickerComponent implements OnInit { if (lastPath && lastPath != path) { let replaced = path.replace(lastPath, ''); if (replaced.startsWith('/') || replaced.startsWith('\\')) { - replaced = replaced.substr(1, replaced.length); + replaced = replaced.substring(1, replaced.length); } return replaced; } @@ -95,14 +116,11 @@ export class DirectoryPickerComponent implements OnInit { } navigateTo(index: number) { - const numberOfPops = this.routeStack.items.length - index; - if (this.routeStack.items.length - numberOfPops > this.routeStack.items.length) { - this.routeStack.items = []; - } - for (let i = 0; i < numberOfPops; i++) { + while(this.routeStack.items.length - 1 > index) { this.routeStack.pop(); } - - this.loadChildren(this.routeStack.peek() || ''); + + const fullPath = this.routeStack.items.join('/'); + this.loadChildren(fullPath); } } diff --git a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html b/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html index 867be0a4a..a972328c3 100644 --- a/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html +++ b/UI/Web/src/app/admin/_modals/library-editor-modal/library-editor-modal.component.html @@ -16,8 +16,10 @@
- -
diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index fbcb2a0f0..1f5b398e9 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -8,4 +8,5 @@ export interface ServerSettings { enableOpds: boolean; enableAuthentication: boolean; baseUrl: string; + bookmarksDirectory: string; } diff --git a/UI/Web/src/app/admin/changelog/changelog.component.html b/UI/Web/src/app/admin/changelog/changelog.component.html index 9655fe18e..10709f0ed 100644 --- a/UI/Web/src/app/admin/changelog/changelog.component.html +++ b/UI/Web/src/app/admin/changelog/changelog.component.html @@ -3,11 +3,11 @@

{{update.updateTitle}}  - Installed - Available + Installed + Available

Published: {{update.publishDate | date: 'short'}}
- +

           Download
         
diff --git a/UI/Web/src/app/admin/changelog/changelog.component.ts b/UI/Web/src/app/admin/changelog/changelog.component.ts index e24ad9845..86724e92e 100644 --- a/UI/Web/src/app/admin/changelog/changelog.component.ts +++ b/UI/Web/src/app/admin/changelog/changelog.component.ts @@ -11,13 +11,26 @@ export class ChangelogComponent implements OnInit { updates: Array = []; isLoading: boolean = true; + installedVersion: string = ''; constructor(private serverService: ServerService) { } ngOnInit(): void { - this.serverService.getChangelog().subscribe(updates => { - this.updates = updates; - this.isLoading = false; + + this.serverService.getServerInfo().subscribe(info => { + this.installedVersion = info.kavitaVersion; + this.serverService.getChangelog().subscribe(updates => { + this.updates = updates; + this.isLoading = false; + + if (this.updates.filter(u => u.updateVersion === this.installedVersion).length === 0) { + // User is on a nightly version. Tell them the last stable is installed + this.installedVersion = this.updates[0].updateVersion; + } + }); }); + + + } } diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index d7a694ac1..d6e5a2364 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -39,14 +39,16 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { // when a progress event comes in, show it on the UI next to library this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => { if (event.event !== EVENTS.ScanLibraryProgress) return; + + console.log('scan event: ', event.payload); const scanEvent = event.payload as ProgressEvent; - this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100}; + this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 1}; if (scanEvent.progress === 0) { this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime; } - if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 100) { + if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 1) { this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => { const newLibrary = libraries.find(lib => lib.id === scanEvent.libraryId); const existingLibrary = this.libraries.find(lib => lib.id === scanEvent.libraryId); 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 727d1069f..ecda0b4ac 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 @@ -8,6 +8,20 @@
+
+   + Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted. + +
+ +
+ +
+
+
+ -
-   - Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. - Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. - -
- -
-   - 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. - +
+
+   + Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. + Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. + +
+ +
+   + 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. + +
@@ -78,6 +94,7 @@
+
diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index 94509776a..eadfe5b71 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -1,9 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { SettingsService } from '../settings.service'; +import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component'; import { ServerSettings } from '../_models/server-settings'; @Component({ @@ -18,7 +20,8 @@ export class ManageSettingsComponent implements OnInit { taskFrequencies: Array = []; logLevels: Array = []; - constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { } + constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService, + private modalService: NgbModal) { } ngOnInit(): void { this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => { @@ -30,6 +33,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => { this.serverSettings = settings; this.settingsForm.addControl('cacheDirectory', new FormControl(this.serverSettings.cacheDirectory, [Validators.required])); + this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required])); this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required])); this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required])); this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required])); @@ -43,6 +47,7 @@ export class ManageSettingsComponent implements OnInit { resetForm() { this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory); + this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory); this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan); this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup); this.settingsForm.get('port')?.setValue(this.serverSettings.port); @@ -77,4 +82,26 @@ export class ManageSettingsComponent implements OnInit { }); } + resetToDefaults() { + this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => { + this.serverSettings = settings; + this.resetForm(); + this.toastr.success('Server settings updated'); + }, (err: any) => { + console.error('error: ', err); + }); + } + + openDirectoryChooser(existingDirectory: string, formControl: string) { + const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' }); + modalRef.componentInstance.startingFolder = existingDirectory || ''; + modalRef.componentInstance.helpUrl = ''; + modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => { + if (closeResult.success) { + this.settingsForm.get(formControl)?.setValue(closeResult.folderPath); + this.settingsForm.markAsTouched(); + } + }); + } + } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 9f0de0b3f..646fde087 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -21,6 +21,10 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings', model); } + resetServerSettings() { + return this.http.post(this.baseUrl + 'settings/reset', {}); + } + getTaskFrequencies() { return this.http.get(this.baseUrl + 'settings/task-frequencies'); } diff --git a/UI/Web/src/app/all-series/all-series.component.html b/UI/Web/src/app/all-series/all-series.component.html new file mode 100644 index 000000000..dc589e566 --- /dev/null +++ b/UI/Web/src/app/all-series/all-series.component.html @@ -0,0 +1,14 @@ + + + + + + diff --git a/UI/Web/src/app/all-series/all-series.component.scss b/UI/Web/src/app/all-series/all-series.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts new file mode 100644 index 000000000..51a37fc59 --- /dev/null +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -0,0 +1,145 @@ +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { Subject } from 'rxjs'; +import { take, debounceTime, takeUntil } from 'rxjs/operators'; +import { BulkSelectionService } from '../cards/bulk-selection.service'; +import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; +import { KEY_CODES } from '../shared/_services/utility.service'; +import { SeriesAddedEvent } from '../_models/events/series-added-event'; +import { Library } from '../_models/library'; +import { Pagination } from '../_models/pagination'; +import { Series } from '../_models/series'; +import { SeriesFilter } from '../_models/series-filter'; +import { ActionItem, Action } from '../_services/action-factory.service'; +import { ActionService } from '../_services/action.service'; +import { MessageHubService } from '../_services/message-hub.service'; +import { SeriesService } from '../_services/series.service'; + +@Component({ + selector: 'app-all-series', + templateUrl: './all-series.component.html', + styleUrls: ['./all-series.component.scss'] +}) +export class AllSeriesComponent implements OnInit, OnDestroy { + + series: Series[] = []; + loadingSeries = false; + pagination!: Pagination; + actions: ActionItem[] = []; + filter: SeriesFilter | undefined = undefined; + onDestroy: Subject = new Subject(); + filterSettings: FilterSettings = new FilterSettings(); + + bulkActionCallback = (action: Action, data: any) => { + const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); + const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); + + switch (action) { + case Action.AddToReadingList: + this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => { + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.AddToCollection: + this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => { + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.MarkAsRead: + this.actionService.markMultipleSeriesAsRead(selectedSeries, () => { + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + + break; + case Action.MarkAsUnread: + this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => { + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + break; + case Action.Delete: + this.actionService.deleteMultipleSeries(selectedSeries, () => { + this.loadPage(); + this.bulkSelectionService.deselectAll(); + }); + break; + } + } + + constructor(private router: Router, private seriesService: SeriesService, + private titleService: Title, private actionService: ActionService, + public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) { + + this.router.routeReuseStrategy.shouldReuseRoute = () => false; + + this.titleService.setTitle('Kavita - All Series'); + this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + + this.loadPage(); + } + + ngOnInit(): void { + this.hubService.seriesAdded.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => { + this.loadPage(); + }); + } + + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + @HostListener('document:keydown.shift', ['$event']) + handleKeypress(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = true; + } + } + + @HostListener('document:keyup.shift', ['$event']) + handleKeyUp(event: KeyboardEvent) { + if (event.key === KEY_CODES.SHIFT) { + this.bulkSelectionService.isShiftDown = false; + } + } + + updateFilter(data: SeriesFilter) { + this.filter = data; + if (this.pagination !== undefined && this.pagination !== null) { + this.pagination.currentPage = 1; + this.onPageChange(this.pagination); + } else { + this.loadPage(); + } + } + + loadPage() { + const page = this.getPage(); + if (page != null) { + this.pagination.currentPage = parseInt(page, 10); + } + this.loadingSeries = true; + + this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { + this.series = series.result; + this.pagination = series.pagination; + this.loadingSeries = false; + window.scrollTo(0, 0); + }); + } + + onPageChange(pagination: Pagination) { + window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); + this.loadPage(); + } + + trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`; + + getPage() { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('page'); + } + +} diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 39800d8cd..7f8bd88f7 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -9,6 +9,8 @@ import { AuthGuard } from './_guards/auth.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard'; import { OnDeckComponent } from './on-deck/on-deck.component'; import { DashboardComponent } from './dashboard/dashboard.component'; +import { AllSeriesComponent } from './all-series/all-series.component'; +import { AdminGuard } from './_guards/admin.guard'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules @@ -16,18 +18,22 @@ const routes: Routes = [ {path: '', component: UserLoginComponent}, { path: 'admin', + canActivate: [AdminGuard], loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) }, { path: 'collections', + canActivate: [AuthGuard], loadChildren: () => import('./collections/collections.module').then(m => m.CollectionsModule) }, { path: 'preferences', + canActivate: [AuthGuard], loadChildren: () => import('./user-settings/user-settings.module').then(m => m.UserSettingsModule) }, { path: 'lists', + canActivate: [AuthGuard], loadChildren: () => import('./reading-list/reading-list.module').then(m => m.ReadingListModule) }, { @@ -55,6 +61,8 @@ const routes: Routes = [ {path: 'library', component: DashboardComponent}, {path: 'recently-added', component: RecentlyAddedComponent}, {path: 'on-deck', component: OnDeckComponent}, + {path: 'all-series', component: AllSeriesComponent}, + ] }, {path: 'login', component: UserLoginComponent}, diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index d1e778d3c..c4a8cd863 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -6,7 +6,7 @@ import { LibraryService } from './_services/library.service'; import { MessageHubService } from './_services/message-hub.service'; import { NavService } from './_services/nav.service'; import { filter } from 'rxjs/operators'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; @Component({ selector: 'app-root', @@ -17,7 +17,11 @@ export class AppComponent implements OnInit { constructor(private accountService: AccountService, public navService: NavService, private messageHub: MessageHubService, private libraryService: LibraryService, - private router: Router, private ngbModal: NgbModal) { + private router: Router, private ngbModal: NgbModal, private ratingConfig: NgbRatingConfig) { + + // Setup default rating config + ratingConfig.max = 5; + ratingConfig.resettable = true; // Close any open modals when a route change occurs router.events diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index a9cb82151..89be77453 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -22,7 +22,6 @@ import { AutocompleteLibModule } from 'angular-ng-autocomplete'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; import { CarouselModule } from './carousel/carousel.module'; -import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { TypeaheadModule } from './typeahead/typeahead.module'; import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { OnDeckComponent } from './on-deck/on-deck.component'; @@ -33,6 +32,10 @@ import { ReadingListModule } from './reading-list/reading-list.module'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; import { ConfigData } from './_models/config-data'; import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component'; +import { PersonRolePipe } from './person-role.pipe'; +import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; +import { AllSeriesComponent } from './all-series/all-series.component'; +import { PublicationStatusPipe } from './publication-status.pipe'; @NgModule({ @@ -45,11 +48,14 @@ import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle. SeriesDetailComponent, NotConnectedComponent, // Move into ExtrasModule ReviewSeriesModalComponent, - PersonBadgeComponent, RecentlyAddedComponent, OnDeckComponent, DashboardComponent, NavEventsToggleComponent, + PersonRolePipe, + PublicationStatusPipe, + SeriesMetadataDetailComponent, + AllSeriesComponent, ], imports: [ HttpClientModule, diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index 181de7cea..44e05dd86 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -1,4 +1,4 @@ -
+
Skip to main content @@ -57,6 +57,17 @@ The ability to click the sides of the page to page left and right
+
+ + Put reader in fullscreen mode + + + + +
@@ -83,7 +94,7 @@
-
    +
    • {{chapterGroup.title}}
    • @@ -99,13 +110,14 @@
-
-
+
-
-
-
-
+
+ +
+
diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index 7132626f9..12823f996 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -154,18 +154,40 @@ $primary-color: #0062cc; } .reading-section { - height: 100vh; + max-height: 100vh; width: 100%; + //overflow: auto; // This will break progress reporting +} + +.reader-container { + outline: none; // Only the reading section itself shouldn't receive any outline. We use it to shift focus in fullscreen mode + overflow: auto; } .book-content { position: relative; } +// A bunch of resets so books render correctly +::ng-deep .book-content { + & a, & :link { + color: blue; + } +} + .drawer-body { padding-bottom: 20px; } +.chapter-title { + padding-inline-start: 0px +} + +::ng-deep .scale-width { + max-width: 100%; + object-fit: contain; + object-position: top center; +} // Click to Paginate styles diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index e5019010f..74120972e 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -1,5 +1,5 @@ -import { AfterViewInit, Component, ElementRef, HostListener, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; -import {Location} from '@angular/common'; +import { AfterViewInit, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; +import {DOCUMENT, Location} from '@angular/common'; import { FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; @@ -111,6 +111,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('readingHtml', {static: false}) readingHtml!: ElementRef; @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef; + @ViewChild('reader', {static: true}) reader!: ElementRef; /** * Next Chapter Id. This is not garunteed to be a valid ChapterId. Prefetched on page load (non-blocking). @@ -185,6 +186,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ originalBodyColor: string | undefined; + /** + * If the web browser is in fullscreen mode + */ + isFullscreen: boolean = false; + darkModeStyles = ` *:not(input), *:not(select), *:not(code), *:not(:link), *:not(.ngx-toastr) { color: #dcdcdc !important; @@ -237,7 +243,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private seriesService: SeriesService, private readerService: ReaderService, private location: Location, private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService, - private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService) { + private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService, + @Inject(DOCUMENT) private document: Document) { this.navService.hideNavBar(); this.darkModeStyleElem = this.renderer.createElement('style'); @@ -275,7 +282,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); } - const bodyNode = document.querySelector('body'); + const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null) { this.originalBodyColor = bodyNode.style.background; } @@ -290,14 +297,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ ngAfterViewInit() { // check scroll offset and if offset is after any of the "id" markers, save progress - fromEvent(window, 'scroll') + fromEvent(this.reader.nativeElement, 'scroll') .pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => { if (this.isLoading) return; // Highlight the current chapter we are on if (Object.keys(this.pageAnchors).length !== 0) { // get the height of the document so we can capture markers that are halfway on the document viewport - const verticalOffset = this.scrollService.scrollPosition + (document.body.offsetHeight / 2); + const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); if (alreadyReached.length > 0) { @@ -344,7 +351,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { - const bodyNode = document.querySelector('body'); + const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) { bodyNode.style.background = this.originalBodyColor; if (this.user.preferences.siteDarkMode) { @@ -353,7 +360,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.navService.showNavBar(); - const head = document.querySelector('head'); + const head = this.document.querySelector('head'); this.renderer.removeChild(head, this.darkModeStyleElem); if (this.clickToPaginateVisualOverlayTimeout !== undefined) { @@ -365,6 +372,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.clickToPaginateVisualOverlayTimeout2 = undefined; } + this.readerService.exitFullscreen(); + this.onDestroy.next(); this.onDestroy.complete(); } @@ -573,8 +582,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { resetSettings() { const windowWidth = window.innerWidth - || document.documentElement.clientWidth - || document.body.clientWidth; + || this.document.documentElement.clientWidth + || this.document.body.clientWidth; let margin = '15%'; if (windowWidth <= 700) { @@ -623,7 +632,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } moveFocus() { - const elems = document.getElementsByClassName('reading-section'); + const elems = this.document.getElementsByClassName('reading-section'); if (elems.length > 0) { (elems[0] as HTMLDivElement).focus(); } @@ -671,10 +680,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getPageMarkers(ids: Array) { try { - return document.querySelectorAll(ids.map(id => '#' + this.cleanIdSelector(id)).join(', ')); + return this.document.querySelectorAll(ids.map(id => '#' + this.cleanIdSelector(id)).join(', ')); } catch (Exception) { // Fallback to anchors instead. Some books have ids that are not valid for querySelectors, so anchors should be used instead - return document.querySelectorAll(ids.map(id => '[href="#' + id + '"]').join(', ')); + return this.document.querySelectorAll(ids.map(id => '[href="#' + id + '"]').join(', ')); } } @@ -709,6 +718,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setupPage(part, scrollTop); return; } + + // Apply scaling class to all images to ensure they scale down to max width to not blow out the reader + Array.from(imgs).forEach(img => this.renderer.addClass(img, 'scale-width')); + Promise.all(Array.from(imgs) .filter(img => !img.complete) .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) @@ -730,16 +743,19 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (part !== undefined && part !== '') { this.scrollTo(part); } else if (scrollTop !== undefined && scrollTop !== 0) { - this.scrollService.scrollTo(scrollTop); + this.scrollService.scrollTo(scrollTop, this.reader.nativeElement); } else { - this.scrollService.scrollTo(0); + this.scrollService.scrollTo(0, this.reader.nativeElement); } + + // we need to click the document before arrow keys will scroll down. + this.reader.nativeElement.focus(); } setPageNum(pageNum: number) { if (pageNum < 0) { this.pageNum = 0; - } else if (pageNum >= this.maxPages - 1) { + } else if (pageNum >= this.maxPages) { this.pageNum = this.maxPages - 1; } else { this.pageNum = pageNum; @@ -799,13 +815,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setPageNum(this.pageNum - 1); } - if (this.pageNum >= this.maxPages - 1) { + if (oldPageNum + 1 === this.maxPages) { // Move to next volume/chapter automatically this.loadNextChapter(); } if (oldPageNum === this.pageNum) { return; } + this.loadPage(); } @@ -860,13 +877,27 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { updateReaderStyles() { if (this.readingHtml != undefined && this.readingHtml.nativeElement) { + Object.entries(this.pageStyles).forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); + return; + } + this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); + }); + for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { const elem = this.readingHtml.nativeElement.children.item(i); - if (elem?.tagName != 'STYLE') { + if (elem?.tagName === 'STYLE') continue; Object.entries(this.pageStyles).forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(elem, item[0]); + return; + } this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); }); - } + } } } @@ -895,7 +926,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } setOverrideStyles() { - const bodyNode = document.querySelector('body'); + const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null) { if (this.user.preferences.siteDarkMode) { bodyNode.classList.remove('bg-dark'); @@ -904,7 +935,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { bodyNode.style.background = this.getDarkModeBackgroundColor(); } this.backgroundColor = this.getDarkModeBackgroundColor(); - const head = document.querySelector('head'); + const head = this.document.querySelector('head'); if (this.darkMode) { this.renderer.appendChild(head, this.darkModeStyleElem) } else { @@ -940,12 +971,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Part selector is a XPATH element = this.getElementFromXPath(partSelector); } else { - element = document.querySelector('*[id="' + partSelector + '"]'); + element = this.document.querySelector('*[id="' + partSelector + '"]'); } if (element === null) return; - this.scrollService.scrollTo(element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET); + this.scrollService.scrollTo(element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET, this.reader.nativeElement); } toggleClickToPaginate() { @@ -976,7 +1007,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } getElementFromXPath(path: string) { - const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (node?.nodeType === Node.ELEMENT_NODE) { return node as Element; } @@ -986,7 +1017,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getXPathTo(element: any): string { if (element === null) return ''; if (element.id !== '') { return 'id("' + element.id + '")'; } - if (element === document.body) { return element.tagName; } + if (element === this.document.body) { return element.tagName; } let ix = 0; @@ -1014,4 +1045,21 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */}); } + toggleFullscreen() { + this.isFullscreen = this.readerService.checkFullscreenMode(); + if (this.isFullscreen) { + this.readerService.exitFullscreen(() => { + this.isFullscreen = false; + this.renderer.removeStyle(this.reader.nativeElement, 'background'); + }); + } else { + this.readerService.enterFullscreen(this.reader.nativeElement, () => { + this.isFullscreen = true; + // HACK: This is a bug with how browsers change the background color for fullscreen mode + if (!this.darkMode) { + this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); + } + }); + } + } } diff --git a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html index 2318b61e3..2dd4b93e1 100644 --- a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html +++ b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html @@ -5,15 +5,16 @@
- -
-
-
-
- - -
-
+
+
+
-
+ +
+ +
+

Book Settings + +

+ +
+
+ +
+
+
+ + + This is library agnostic +
+
+
+
+   + + + + {{item.title}} + + + {{item.title}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+   + + + + {{item.title}} + + + {{item.title}} + + +
+
+ +
+
+ + + + {{item.title}} + + + {{item.title}} + + +
+
+ +
+
+ + + + {{item.title}} + + + {{item.title}} + + +
+
+
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+ +
+
+ + + + {{item.name}} + + + {{item.name}} + + +
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + + + + +
+
+ +
+ + + + {{item.title}} + + + {{item.title}} + + +
+ +
+ + + + {{item.title}} + + + {{item.title}} + + +
+ +
+ + + + {{item.title}} + + + {{item.title}} + + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+ diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss index e69de29bb..23d8c738a 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.scss @@ -0,0 +1,9 @@ +@use '../../../theme/colors'; + +.star { + font-size: 1.5rem; + color: colors.$rating-empty; +} +.filled { + color: colors.$rating-filled; +} \ No newline at end of file diff --git a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts index aab49eff7..1bece3ff6 100644 --- a/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/cards/card-detail-layout/card-detail-layout.component.ts @@ -1,39 +1,57 @@ -import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core'; -import { FormGroup, FormControl } from '@angular/forms'; +import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { forkJoin, Observable, of, ReplaySubject, Subject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; +import { UtilityService } from 'src/app/shared/_services/utility.service'; +import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings'; +import { CollectionTag } from 'src/app/_models/collection-tag'; +import { Genre } from 'src/app/_models/genre'; +import { Library } from 'src/app/_models/library'; +import { MangaFormat } from 'src/app/_models/manga-format'; +import { AgeRating } from 'src/app/_models/metadata/age-rating'; +import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; +import { Language } from 'src/app/_models/metadata/language'; +import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto'; import { Pagination } from 'src/app/_models/pagination'; -import { FilterItem } from 'src/app/_models/series-filter'; +import { Person, PersonRole } from 'src/app/_models/person'; +import { FilterItem, mangaFormatFilters, SeriesFilter, SortField } from 'src/app/_models/series-filter'; +import { Tag } from 'src/app/_models/tag'; import { ActionItem } from 'src/app/_services/action-factory.service'; +import { CollectionTagService } from 'src/app/_services/collection-tag.service'; +import { LibraryService } from 'src/app/_services/library.service'; +import { MetadataService } from 'src/app/_services/metadata.service'; +import { SeriesService } from 'src/app/_services/series.service'; const FILTER_PAG_REGEX = /[^0-9]/g; -export enum FilterAction { - /** - * If an option is selected on a multi select component - */ - Added = 0, - /** - * If an option is unselected on a multi select component - */ - Removed = 1, - /** - * If an option is selected on a single select component - */ - Selected = 2 -} - -export interface UpdateFilterEvent { - filterItem: FilterItem; - action: FilterAction; -} - const ANIMATION_SPEED = 300; +export class FilterSettings { + libraryDisabled = false; + formatDisabled = false; + collectionDisabled = false; + genresDisabled = false; + peopleDisabled = false; + readProgressDisabled = false; + ratingDisabled = false; + sortDisabled = false; + ageRatingDisabled = false; + tagsDisabled = false; + languageDisabled = false; + publicationStatusDisabled = false; + presets: SeriesFilter | undefined; + /** + * Should the filter section be open by default + */ + openByDefault = false; +} + @Component({ selector: 'app-card-detail-layout', templateUrl: './card-detail-layout.component.html', styleUrls: ['./card-detail-layout.component.scss'] }) -export class CardDetailLayoutComponent implements OnInit { +export class CardDetailLayoutComponent implements OnInit, OnDestroy { @Input() header: string = ''; @Input() isLoading: boolean = false; @@ -43,32 +61,382 @@ export class CardDetailLayoutComponent implements OnInit { * Any actions to exist on the header for the parent collection (library, collection) */ @Input() actions: ActionItem[] = []; - /** - * A list of Filters which can filter the data of the page. If nothing is passed, the control will not show. - */ - @Input() filters: Array = []; @Input() trackByIdentity!: (index: number, item: any) => string; + @Input() filterSettings!: FilterSettings; @Output() itemClicked: EventEmitter = new EventEmitter(); @Output() pageChange: EventEmitter = new EventEmitter(); - @Output() applyFilter: EventEmitter = new EventEmitter(); + @Output() applyFilter: EventEmitter = new EventEmitter(); @ContentChild('cardItem') itemTemplate!: TemplateRef; - - filterForm: FormGroup = new FormGroup({ - filter: new FormControl(0, []), - }); + + formatSettings: TypeaheadSettings> = new TypeaheadSettings(); + librarySettings: TypeaheadSettings = new TypeaheadSettings(); + genreSettings: TypeaheadSettings = new TypeaheadSettings(); + collectionSettings: TypeaheadSettings = new TypeaheadSettings(); + ageRatingSettings: TypeaheadSettings = new TypeaheadSettings(); + publicationStatusSettings: TypeaheadSettings = new TypeaheadSettings(); + tagsSettings: TypeaheadSettings = new TypeaheadSettings(); + languageSettings: TypeaheadSettings = new TypeaheadSettings(); + peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; + resetTypeaheads: Subject = new ReplaySubject(1); /** * Controls the visiblity of extended controls that sit below the main header. */ filteringCollapsed: boolean = true; - constructor() { } + filter!: SeriesFilter; + libraries: Array> = []; + + + readProgressGroup!: FormGroup; + sortGroup!: FormGroup; + isAscendingSort: boolean = true; + + updateApplied: number = 0; + + private onDestory: Subject = new Subject(); + + get PersonRole(): typeof PersonRole { + return PersonRole; + } + + get SortField(): typeof SortField { + return SortField; + } + + constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService, + private utilityService: UtilityService, private collectionTagService: CollectionTagService) { + this.filter = this.seriesService.createSeriesFilter(); + this.readProgressGroup = new FormGroup({ + read: new FormControl(this.filter.readStatus.read, []), + notRead: new FormControl(this.filter.readStatus.notRead, []), + inProgress: new FormControl(this.filter.readStatus.inProgress, []), + }); + + this.sortGroup = new FormGroup({ + sortField: new FormControl(this.filter.sortOptions?.sortField || SortField.SortName, []), + }); + + this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => { + this.filter.readStatus.read = this.readProgressGroup.get('read')?.value; + this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value; + this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value; + + let sum = 0; + sum += (this.filter.readStatus.read ? 1 : 0); + sum += (this.filter.readStatus.inProgress ? 1 : 0); + sum += (this.filter.readStatus.notRead ? 1 : 0); + + if (sum === 1) { + if (this.filter.readStatus.read) this.readProgressGroup.get('read')?.disable({ emitEvent: false }); + if (this.filter.readStatus.notRead) this.readProgressGroup.get('notRead')?.disable({ emitEvent: false }); + if (this.filter.readStatus.inProgress) this.readProgressGroup.get('inProgress')?.disable({ emitEvent: false }); + } else { + this.readProgressGroup.get('read')?.enable({ emitEvent: false }); + this.readProgressGroup.get('notRead')?.enable({ emitEvent: false }); + this.readProgressGroup.get('inProgress')?.enable({ emitEvent: false }); + } + }); + + this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => { + if (this.filter.sortOptions == null) { + this.filter.sortOptions = { + isAscending: this.isAscendingSort, + sortField: parseInt(this.sortGroup.get('sortField')?.value, 10) + }; + } + this.filter.sortOptions.sortField = parseInt(this.sortGroup.get('sortField')?.value, 10); + }); + } ngOnInit(): void { - this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.filterForm.get('filter')?.value}_${item.id}_${index}`; + this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}`; + + if (this.filterSettings === undefined) { + this.filterSettings = new FilterSettings(); + } + + this.setupTypeaheads(); } + ngOnDestroy() { + this.onDestory.next(); + this.onDestory.complete(); + } + + setupTypeaheads() { + + this.setupFormatTypeahead(); + + forkJoin([ + this.setupLibraryTypeahead(), + this.setupCollectionTagTypeahead(), + this.setupAgeRatingSettings(), + this.setupPublicationStatusSettings(), + this.setupTagSettings(), + this.setupLanguageSettings(), + this.setupGenreTypeahead(), + this.setupPersonTypeahead(), + ]).subscribe(results => { + this.resetTypeaheads.next(true); + if (this.filterSettings.openByDefault) { + this.filteringCollapsed = false; + } + this.apply(); + }); + } + + + setupFormatTypeahead() { + this.formatSettings.minCharacters = 0; + this.formatSettings.multiple = true; + this.formatSettings.id = 'format'; + this.formatSettings.unique = true; + this.formatSettings.addIfNonExisting = false; + this.formatSettings.fetchFn = (filter: string) => of(mangaFormatFilters); + this.formatSettings.compareFn = (options: FilterItem[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f); + } + + if (this.filterSettings.presets?.formats && this.filterSettings.presets?.formats.length > 0) { + this.formatSettings.savedData = mangaFormatFilters.filter(item => this.filterSettings.presets?.formats.includes(item.value)); + this.filter.formats = this.formatSettings.savedData.map(item => item.value); + this.resetTypeaheads.next(true); + } + } + + setupLibraryTypeahead() { + this.librarySettings.minCharacters = 0; + this.librarySettings.multiple = true; + this.librarySettings.id = 'libraries'; + this.librarySettings.unique = true; + this.librarySettings.addIfNonExisting = false; + this.librarySettings.fetchFn = (filter: string) => { + return this.libraryService.getLibrariesForMember(); + }; + this.librarySettings.compareFn = (options: Library[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.name.toLowerCase() === f); + } + + if (this.filterSettings.presets?.libraries && this.filterSettings.presets?.libraries.length > 0) { + return this.librarySettings.fetchFn('').pipe(map(libraries => { + this.librarySettings.savedData = libraries.filter(item => this.filterSettings.presets?.libraries.includes(item.id)); + this.filter.libraries = this.librarySettings.savedData.map(item => item.id); + return of(true); + })); + } + return of(true); + } + + setupGenreTypeahead() { + this.genreSettings.minCharacters = 0; + this.genreSettings.multiple = true; + this.genreSettings.id = 'genres'; + this.genreSettings.unique = true; + this.genreSettings.addIfNonExisting = false; + this.genreSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllGenres(this.filter.libraries); + }; + this.genreSettings.compareFn = (options: Genre[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f); + } + + if (this.filterSettings.presets?.genres && this.filterSettings.presets?.genres.length > 0) { + return this.genreSettings.fetchFn('').pipe(map(genres => { + this.genreSettings.savedData = genres.filter(item => this.filterSettings.presets?.genres.includes(item.id)); + this.filter.genres = this.genreSettings.savedData.map(item => item.id); + return of(true); + })); + } + return of(true); + } + + setupAgeRatingSettings() { + this.ageRatingSettings.minCharacters = 0; + this.ageRatingSettings.multiple = true; + this.ageRatingSettings.id = 'age-rating'; + this.ageRatingSettings.unique = true; + this.ageRatingSettings.addIfNonExisting = false; + this.ageRatingSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllAgeRatings(this.filter.libraries); + }; + this.ageRatingSettings.compareFn = (options: AgeRatingDto[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + + if (this.filterSettings.presets?.ageRating && this.filterSettings.presets?.ageRating.length > 0) { + return this.ageRatingSettings.fetchFn('').pipe(map(rating => { + this.ageRatingSettings.savedData = rating.filter(item => this.filterSettings.presets?.ageRating.includes(item.value)); + this.filter.ageRating = this.ageRatingSettings.savedData.map(item => item.value); + return of(true); + })); + } + return of(true); + } + + setupPublicationStatusSettings() { + this.publicationStatusSettings.minCharacters = 0; + this.publicationStatusSettings.multiple = true; + this.publicationStatusSettings.id = 'publication-status'; + this.publicationStatusSettings.unique = true; + this.publicationStatusSettings.addIfNonExisting = false; + this.publicationStatusSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllPublicationStatus(this.filter.libraries); + }; + this.publicationStatusSettings.compareFn = (options: PublicationStatusDto[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + + if (this.filterSettings.presets?.publicationStatus && this.filterSettings.presets?.publicationStatus.length > 0) { + return this.publicationStatusSettings.fetchFn('').pipe(map(statuses => { + this.publicationStatusSettings.savedData = statuses.filter(item => this.filterSettings.presets?.publicationStatus.includes(item.value)); + this.filter.publicationStatus = this.publicationStatusSettings.savedData.map(item => item.value); + return of(true); + })); + } + return of(true); + } + + setupTagSettings() { + this.tagsSettings.minCharacters = 0; + this.tagsSettings.multiple = true; + this.tagsSettings.id = 'tags'; + this.tagsSettings.unique = true; + this.tagsSettings.addIfNonExisting = false; + this.tagsSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllTags(this.filter.libraries); + }; + this.tagsSettings.compareFn = (options: Tag[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + + if (this.filterSettings.presets?.tags && this.filterSettings.presets?.tags.length > 0) { + return this.tagsSettings.fetchFn('').pipe(map(tags => { + this.tagsSettings.savedData = tags.filter(item => this.filterSettings.presets?.tags.includes(item.id)); + this.filter.tags = this.tagsSettings.savedData.map(item => item.id); + return of(true); + })); + } + return of(true); + } + + setupLanguageSettings() { + this.languageSettings.minCharacters = 0; + this.languageSettings.multiple = true; + this.languageSettings.id = 'languages'; + this.languageSettings.unique = true; + this.languageSettings.addIfNonExisting = false; + this.languageSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllLanguages(this.filter.libraries); + }; + this.languageSettings.compareFn = (options: Language[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f && this.utilityService.filter(m.title, filter)); + } + + if (this.filterSettings.presets?.languages && this.filterSettings.presets?.languages.length > 0) { + return this.languageSettings.fetchFn('').pipe(map(languages => { + this.languageSettings.savedData = languages.filter(item => this.filterSettings.presets?.languages.includes(item.isoCode)); + this.filter.languages = this.languageSettings.savedData.map(item => item.isoCode); + return of(true); + })); + } + return of(true); + } + + setupCollectionTagTypeahead() { + this.collectionSettings.minCharacters = 0; + this.collectionSettings.multiple = true; + this.collectionSettings.id = 'collections'; + this.collectionSettings.unique = true; + this.collectionSettings.addIfNonExisting = false; + this.collectionSettings.fetchFn = (filter: string) => { + return this.collectionTagService.allTags(); + }; + this.collectionSettings.compareFn = (options: CollectionTag[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.title.toLowerCase() === f); + } + + if (this.filterSettings.presets?.collectionTags && this.filterSettings.presets?.collectionTags.length > 0) { + return this.collectionSettings.fetchFn('').pipe(map(tags => { + this.collectionSettings.savedData = tags.filter(item => this.filterSettings.presets?.collectionTags.includes(item.id)); + this.filter.collectionTags = this.collectionSettings.savedData.map(item => item.id); + return of(true); + })); + } + return of(true); + } + + updateFromPreset(id: string, peopleFilterField: Array, presetField: Array | undefined, role: PersonRole) { + const personSettings = this.createBlankPersonSettings(id, role) + if (presetField && presetField.length > 0) { + const fetch = personSettings.fetchFn as ((filter: string) => Observable); + return fetch('').pipe(map(people => { + personSettings.savedData = people.filter(item => presetField.includes(item.id)); + peopleFilterField = personSettings.savedData.map(item => item.id); + this.resetTypeaheads.next(true); + this.peopleSettings[role] = personSettings; + this.updatePersonFilters(personSettings.savedData as Person[], role); + return true; + })); + } else { + this.peopleSettings[role] = personSettings; + return of(true); + } + } + + setupPersonTypeahead() { + this.peopleSettings = {}; + + return forkJoin([ + this.updateFromPreset('writers', this.filter.writers, this.filterSettings.presets?.writers, PersonRole.Writer), + this.updateFromPreset('character', this.filter.character, this.filterSettings.presets?.character, PersonRole.Character), + this.updateFromPreset('colorist', this.filter.colorist, this.filterSettings.presets?.colorist, PersonRole.Colorist), + this.updateFromPreset('cover-artist', this.filter.coverArtist, this.filterSettings.presets?.coverArtist, PersonRole.CoverArtist), + this.updateFromPreset('editor', this.filter.editor, this.filterSettings.presets?.editor, PersonRole.Editor), + this.updateFromPreset('inker', this.filter.inker, this.filterSettings.presets?.inker, PersonRole.Inker), + this.updateFromPreset('letterer', this.filter.letterer, this.filterSettings.presets?.letterer, PersonRole.Letterer), + this.updateFromPreset('penciller', this.filter.penciller, this.filterSettings.presets?.penciller, PersonRole.Penciller), + this.updateFromPreset('publisher', this.filter.publisher, this.filterSettings.presets?.publisher, PersonRole.Publisher), + this.updateFromPreset('translators', this.filter.translators, this.filterSettings.presets?.translators, PersonRole.Translator) + ]).pipe(map(results => { + this.resetTypeaheads.next(true); + return of(true); + })); + } + + fetchPeople(role: PersonRole, filter: string) { + return this.metadataService.getAllPeople(this.filter.libraries).pipe(map(people => { + return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); + })); + } + + createBlankPersonSettings(id: string, role: PersonRole) { + var personSettings = new TypeaheadSettings(); + personSettings.minCharacters = 0; + personSettings.multiple = true; + personSettings.unique = true; + personSettings.addIfNonExisting = false; + personSettings.id = id; + personSettings.compareFn = (options: Person[], filter: string) => { + const f = filter.toLowerCase(); + return options.filter(m => m.name.toLowerCase() === f); + } + personSettings.fetchFn = (filter: string) => { + return this.fetchPeople(role, filter); + }; + return personSettings; + } + + onPageChange(page: number) { this.pageChange.emit(this.pagination); } @@ -88,11 +456,118 @@ export class CardDetailLayoutComponent implements OnInit { } } - handleFilterChange(index: string) { - this.applyFilter.emit({ - filterItem: this.filters[parseInt(index, 10)], - action: FilterAction.Selected - }); + + updateFormatFilters(formats: MangaFormat[]) { + this.filter.formats = formats.map(item => item) || []; + } + + updateLibraryFilters(libraries: Library[]) { + this.filter.libraries = libraries.map(item => item.id) || []; + } + + updateGenreFilters(genres: Genre[]) { + this.filter.genres = genres.map(item => item.id) || []; + } + + updateTagFilters(tags: Tag[]) { + this.filter.tags = tags.map(item => item.id) || []; + } + + updatePersonFilters(persons: Person[], role: PersonRole) { + switch (role) { + case PersonRole.CoverArtist: + this.filter.coverArtist = persons.map(p => p.id); + break; + case PersonRole.Character: + this.filter.character = persons.map(p => p.id); + break; + case PersonRole.Colorist: + this.filter.colorist = persons.map(p => p.id); + break; + case PersonRole.Editor: + this.filter.editor = persons.map(p => p.id); + break; + case PersonRole.Inker: + this.filter.inker = persons.map(p => p.id); + break; + case PersonRole.Letterer: + this.filter.letterer = persons.map(p => p.id); + break; + case PersonRole.Penciller: + this.filter.penciller = persons.map(p => p.id); + break; + case PersonRole.Publisher: + this.filter.publisher = persons.map(p => p.id); + break; + case PersonRole.Writer: + this.filter.writers = persons.map(p => p.id); + break; + case PersonRole.Translator: + this.filter.translators = persons.map(p => p.id); + + } + } + + updateCollectionFilters(tags: CollectionTag[]) { + this.filter.collectionTags = tags.map(item => item.id) || []; + } + + updateRating(rating: any) { + this.filter.rating = rating; + } + + updateAgeRating(ratingDtos: AgeRatingDto[]) { + this.filter.ageRating = ratingDtos.map(item => item.value) || []; + } + + updatePublicationStatus(dtos: PublicationStatusDto[]) { + this.filter.publicationStatus = dtos.map(item => item.value) || []; + } + + updateLanguageRating(languages: Language[]) { + this.filter.languages = languages.map(item => item.isoCode) || []; + } + + updateReadStatus(status: string) { + if (status === 'read') { + this.filter.readStatus.read = !this.filter.readStatus.read; + } else if (status === 'inProgress') { + this.filter.readStatus.inProgress = !this.filter.readStatus.inProgress; + } else if (status === 'notRead') { + this.filter.readStatus.notRead = !this.filter.readStatus.notRead; + } + } + + updateSortOrder() { + this.isAscendingSort = !this.isAscendingSort; + if (this.filter.sortOptions === null) { + this.filter.sortOptions = { + isAscending: this.isAscendingSort, + sortField: SortField.SortName + } + } + + this.filter.sortOptions.isAscending = this.isAscendingSort; + } + + getPersonsSettings(role: PersonRole) { + return this.peopleSettings[role]; + } + + clear() { + this.filter = this.seriesService.createSeriesFilter(); + this.readProgressGroup.get('read')?.setValue(true); + this.readProgressGroup.get('notRead')?.setValue(true); + this.readProgressGroup.get('inProgress')?.setValue(true); + this.sortGroup.get('sortField')?.setValue(SortField.SortName); + this.isAscendingSort = true; + // Apply any presets which will trigger the apply + this.setupTypeaheads(); + } + + apply() { + this.applyFilter.emit(this.filter); + this.updateApplied++; } } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 7fee96b23..c096aac5b 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -1,4 +1,4 @@ -
+
@@ -20,13 +20,13 @@
- +
- + (promoted) diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index 0aa5c8ef6..d1c81c155 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -1,13 +1,13 @@ -@import '../../../theme/colors'; +@use '../../../theme/colors'; -$triangle-size: 40px; +$triangle-size: 30px; $image-height: 230px; $image-width: 160px; .error-banner { width: 160px; height: 18px; - background-color: $error-color; + background-color: colors.$error-color; font-size: 12px; color: white; text-transform: uppercase; @@ -38,6 +38,9 @@ $image-width: 160px; margin-bottom: 0px; } +.selected-highlight { + outline: 2px solid colors.$primary-color; +} .img-top { @@ -49,7 +52,7 @@ $image-width: 160px; height: 5px; .progress { - color: $primary-color; + color: colors.$primary-color; background-color: transparent; } } @@ -70,7 +73,7 @@ $image-width: 160px; height: 0; border-style: solid; border-width: 0 $triangle-size $triangle-size 0; - border-color: transparent $primary-color transparent transparent; + border-color: transparent colors.$primary-color transparent transparent; } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index b0974ba5a..6788881a3 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -8,6 +8,7 @@ import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { MangaFormat } from 'src/app/_models/manga-format'; +import { PageBookmark } from 'src/app/_models/page-bookmark'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; @@ -49,7 +50,7 @@ export class CardItemComponent implements OnInit, OnDestroy { /** * This is the entity we are representing. It will be returned if an action is executed. */ - @Input() entity!: Series | Volume | Chapter | CollectionTag; + @Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark; /** * If the entity is selected or not. */ @@ -79,12 +80,18 @@ export class CardItemComponent implements OnInit, OnDestroy { * Format of the entity (only applies to Series) */ format: MangaFormat = MangaFormat.UNKNOWN; + chapterTitle: string = ''; download$: Observable | null = null; downloadInProgress: boolean = false; isShiftDown: boolean = false; + + get tooltipTitle() { + if (this.chapterTitle === '' || this.chapterTitle === null) return this.title; + return this.chapterTitle; + } get MangaFormat(): typeof MangaFormat { @@ -111,6 +118,15 @@ export class CardItemComponent implements OnInit, OnDestroy { }); } this.format = (this.entity as Series).format; + + if (this.utilityService.isChapter(this.entity)) { + this.chapterTitle = this.utilityService.asChapter(this.entity).titleName; + } else if (this.utilityService.isVolume(this.entity)) { + const vol = this.utilityService.asVolume(this.entity); + if (vol.chapters !== undefined && vol.chapters.length > 0) { + this.chapterTitle = vol.chapters[0].titleName; + } + } } ngOnDestroy() { diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index dc1a23134..8b21fe1ac 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -8,7 +8,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component'; import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component'; import { LazyLoadImageModule } from 'ng-lazyload-image'; -import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NgxFileDropModule } from 'ngx-file-drop'; @@ -21,6 +21,9 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det import { BulkOperationsComponent } from './bulk-operations/bulk-operations.component'; import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/bulk-add-to-collection.component'; import { PipeModule } from '../pipe/pipe.module'; +import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component'; +import { FileInfoComponent } from './file-info/file-info.component'; +import { BookmarkComponent } from './bookmark/bookmark.component'; @@ -38,7 +41,10 @@ import { PipeModule } from '../pipe/pipe.module'; CardDetailLayoutComponent, CardDetailsModalComponent, BulkOperationsComponent, - BulkAddToCollectionComponent + BulkAddToCollectionComponent, + ChapterMetadataDetailComponent, + FileInfoComponent, + BookmarkComponent, ], imports: [ CommonModule, @@ -52,6 +58,7 @@ import { PipeModule } from '../pipe/pipe.module'; NgbNavModule, NgbTooltipModule, // Card item NgbCollapseModule, + NgbRatingModule, NgbNavModule, //Series Detail LazyLoadImageModule, @@ -75,7 +82,8 @@ import { PipeModule } from '../pipe/pipe.module'; CardActionablesComponent, CardDetailLayoutComponent, CardDetailsModalComponent, - BulkOperationsComponent + BulkOperationsComponent, + ChapterMetadataDetailComponent ] }) export class CardsModule { } diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html new file mode 100644 index 000000000..24d7b0aed --- /dev/null +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html @@ -0,0 +1,114 @@ + +
+ + + + + + +
+
+ Title: {{chapter.titleName || '-'}} +
+
+ Pages: {{chapter.pages}} +
+
+ +
+
+ Added: {{(chapter.created | date: 'short') || '-'}} +
+
+ Release Date: {{(chapter.releaseDate | date: 'shortDate') || '-'}} +
+
+
+ +
    +
  • + + + +
    +
    + + + + {{chapter.pagesRead}} / {{chapter.pages}} + UNREAD + READ + + + Files +
    +
      + +
    + + + +
    +
    +
    Writers
    +
    +
    + +
    +
    + +
    +
    +
    Artists
    +
    +
    + +
    +
    + +
    +
    +
    Publishers
    +
    +
    + +
    +
    +
    +
    +
  • +
+
+ + + \ No newline at end of file diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.scss b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts new file mode 100644 index 000000000..2c203aed0 --- /dev/null +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MetadataService } from 'src/app/_services/metadata.service'; +import { Chapter } from 'src/app/_models/chapter'; +import { ChapterMetadata } from 'src/app/_models/chapter-metadata'; +import { UtilityService } from 'src/app/shared/_services/utility.service'; +import { LibraryType } from 'src/app/_models/library'; +import { ActionItem } from 'src/app/_services/action-factory.service'; + +@Component({ + selector: 'app-chapter-metadata-detail', + templateUrl: './chapter-metadata-detail.component.html', + styleUrls: ['./chapter-metadata-detail.component.scss'] +}) +export class ChapterMetadataDetailComponent implements OnInit { + + @Input() chapter!: Chapter; + @Input() libraryType: LibraryType = LibraryType.Manga; + //metadata!: ChapterMetadata; + + get LibraryType(): typeof LibraryType { + return LibraryType; + } + + constructor(private metadataService: MetadataService, public utilityService: UtilityService) { } + + ngOnInit(): void { + // this.metadataService.getChapterMetadata(this.chapter.id).subscribe(metadata => { + // console.log('Chapter ', this.chapter.number, ' metadata: ', metadata); + // this.metadata = metadata; + // }) + } + + performAction(action: ActionItem, chapter: Chapter) { + if (typeof action.callback === 'function') { + action.callback(action.action, chapter); + } + } + + readChapter(chapter: Chapter) { + // if (chapter.pages === 0) { + // this.toastr.error('There are no pages. Kavita was not able to read this archive.'); + // return; + // } + + // if (chapter.files.length > 0 && chapter.files[0].format === MangaFormat.EPUB) { + // this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'book', chapter.id]); + // } else { + // this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]); + // } + } + + +} diff --git a/UI/Web/src/app/cards/file-info/file-info.component.html b/UI/Web/src/app/cards/file-info/file-info.component.html new file mode 100644 index 000000000..f409c2f09 --- /dev/null +++ b/UI/Web/src/app/cards/file-info/file-info.component.html @@ -0,0 +1,11 @@ +
  • + {{file.filePath}} +
    +
    + Pages: {{file.pages}} +
    +
    + Added: {{(created | date: 'short') || '-'}} +
    +
    +
  • \ No newline at end of file diff --git a/UI/Web/src/app/cards/file-info/file-info.component.scss b/UI/Web/src/app/cards/file-info/file-info.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/cards/file-info/file-info.component.ts b/UI/Web/src/app/cards/file-info/file-info.component.ts new file mode 100644 index 000000000..ce275bc45 --- /dev/null +++ b/UI/Web/src/app/cards/file-info/file-info.component.ts @@ -0,0 +1,25 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { MangaFile } from 'src/app/_models/manga-file'; + +@Component({ + selector: 'app-file-info', + templateUrl: './file-info.component.html', + styleUrls: ['./file-info.component.scss'] +}) +export class FileInfoComponent implements OnInit { + + /** + * MangaFile to display + */ + @Input() file!: MangaFile; + /** + * DateTime the entity this file belongs to was created + */ + @Input() created: string | undefined = undefined; + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 11d869401..8966cc1be 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -109,6 +109,9 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { case(Action.AddToReadingList): this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ }); break; + case(Action.AddToCollection): + this.actionService.addMultipleSeriesToCollectionTag([series], () => {/* No Operation */ }); + break; default: break; } @@ -132,35 +135,26 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { }); } - refreshMetdata(series: Series) { - this.seriesService.refreshMetadata(series).subscribe((res: any) => { - this.toastr.success('Refresh started for ' + series.name); - }); + async refreshMetdata(series: Series) { + this.actionService.refreshMetdata(series); } - scanLibrary(series: Series) { + async scanLibrary(series: Series) { this.seriesService.scan(series.libraryId, series.id).subscribe((res: any) => { - this.toastr.success('Scan started for ' + series.name); + this.toastr.success('Scan queued for ' + series.name); }); } async deleteSeries(series: Series) { - if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) { - return; - } - - this.seriesService.delete(series.id).subscribe((res: boolean) => { - if (res) { - this.toastr.success('Series deleted'); + this.actionService.deleteSeries(series, (result: boolean) => { + if (result) { this.reload.emit(true); } }); } markAsUnread(series: Series) { - this.seriesService.markUnread(series.id).subscribe(res => { - this.toastr.success(series.name + ' is now unread'); - series.pagesRead = 0; + this.actionService.markSeriesAsUnread(series, () => { if (this.data) { this.data.pagesRead = 0; } @@ -170,9 +164,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { } markAsRead(series: Series) { - this.seriesService.markRead(series.id).subscribe(res => { - this.toastr.success(series.name + ' is now read'); - series.pagesRead = series.pages; + this.actionService.markSeriesAsRead(series, () => { if (this.data) { this.data.pagesRead = series.pages; } diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html index 431680257..0e8516cf8 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html @@ -32,8 +32,8 @@ [isLoading]="isLoading" [items]="series" [pagination]="seriesPagination" + [filterSettings]="filterSettings" (pageChange)="onPageChange($event)" - [filters]="filters" (applyFilter)="updateFilter($event)" > diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.scss b/UI/Web/src/app/collections/collection-detail/collection-detail.component.scss index 7e13a843c..b392abee1 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.scss +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.scss @@ -1,4 +1,4 @@ -@import '~bootstrap/scss/mixins/_breakpoints.scss'; +@import '~bootstrap/scss/mixins/breakpoints'; .poster { width: 100%; diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 2b4f6c4a4..02f897451 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -6,15 +6,14 @@ import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; -import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component'; +import { FilterSettings } from 'src/app/cards/card-detail-layout/card-detail-layout.component'; import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; -import { KEY_CODES } from 'src/app/shared/_services/utility.service'; +import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event'; -import { SeriesRemovedEvent } from 'src/app/_models/events/series-removed-event'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; -import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter'; +import { SeriesFilter } from 'src/app/_models/series-filter'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import { ActionService } from 'src/app/_services/action.service'; @@ -39,10 +38,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { seriesPagination!: Pagination; collectionTagActions: ActionItem[] = []; isAdmin: boolean = false; - filters: Array = mangaFormatFilters; - filter: SeriesFilter = { - mangaFormat: null - }; + filter: SeriesFilter | undefined = undefined; + filterSettings: FilterSettings = new FilterSettings(); private onDestory: Subject = new Subject(); @@ -85,7 +82,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, private modalService: NgbModal, private titleService: Title, private accountService: AccountService, - public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService) { + public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService, + private utilityService: UtilityService) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -100,6 +98,10 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { return; } const tagId = parseInt(routeId, 10); + + [this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter()); + this.filterSettings.presets.collectionTags = [tagId]; + this.updateTag(tagId); } @@ -149,7 +151,6 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { this.collectionTag = matchingTags[0]; this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(this.collectionTag.id)); this.titleService.setTitle('Kavita - ' + this.collectionTag.title + ' Collection'); - this.loadPage(); }); } @@ -174,8 +175,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { }); } - updateFilter(data: UpdateFilterEvent) { - this.filter.mangaFormat = data.filterItem.value; + updateFilter(data: SeriesFilter) { + this.filter = data; if (this.seriesPagination !== undefined && this.seriesPagination !== null) { this.seriesPagination.currentPage = 1; this.onPageChange(this.seriesPagination); diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index d01cec915..cebeac178 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -1,10 +1,10 @@ - diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 810f20131..13fc0e885 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -4,13 +4,13 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; -import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component'; -import { KEY_CODES } from '../shared/_services/utility.service'; +import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component'; +import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; -import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter'; +import { SeriesFilter } from '../_models/series-filter'; import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { LibraryService } from '../_services/library.service'; @@ -30,11 +30,9 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { loadingSeries = false; pagination!: Pagination; actions: ActionItem[] = []; - filters: Array = mangaFormatFilters; - filter: SeriesFilter = { - mangaFormat: null - }; + filter: SeriesFilter | undefined = undefined; onDestroy: Subject = new Subject(); + filterSettings: FilterSettings = new FilterSettings(); bulkActionCallback = (action: Action, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); @@ -75,12 +73,14 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, - private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) { + private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, + private utilityService: UtilityService) { const routeId = this.route.snapshot.paramMap.get('id'); if (routeId === null) { this.router.navigateByUrl('/libraries'); return; } + this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.libraryId = parseInt(routeId, 10); this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => { @@ -89,7 +89,11 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { }); this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; - this.loadPage(); + + [this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter()); + this.filterSettings.presets.libraries = [this.libraryId]; + + //this.loadPage(); } ngOnInit(): void { @@ -134,8 +138,8 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { } } - updateFilter(data: UpdateFilterEvent) { - this.filter.mangaFormat = data.filterItem.value; + updateFilter(data: SeriesFilter) { + this.filter = data; if (this.pagination !== undefined && this.pagination !== null) { this.pagination.currentPage = 1; this.onPageChange(this.pagination); @@ -151,7 +155,13 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { } this.loadingSeries = true; - this.seriesService.getSeriesForLibrary(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { + // The filter is out of sync with the presets from typeaheads on first load but syncs afterwards + if (this.filter == undefined) { + this.filter = this.seriesService.createSeriesFilter(); + this.filter.libraries.push(this.libraryId); + } + + this.seriesService.getSeriesForLibrary(0, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { this.series = series.result; this.pagination = series.pagination; this.loadingSeries = false; @@ -160,7 +170,7 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { } onPageChange(pagination: Pagination) { - window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); + window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?' + 'page=' + this.pagination.currentPage); this.loadPage(); } diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html index 3157a7d80..52c642743 100644 --- a/UI/Web/src/app/library/library.component.html +++ b/UI/Web/src/app/library/library.component.html @@ -17,7 +17,7 @@ - + diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index dc5ff3228..83ff53e80 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -110,6 +110,8 @@ export class LibraryComponent implements OnInit, OnDestroy { this.router.navigate(['recently-added']); } else if (sectionTitle.toLowerCase() === 'on deck') { this.router.navigate(['on-deck']); + } else if (sectionTitle.toLowerCase() === 'libraries') { + this.router.navigate(['all-series']); } } diff --git a/UI/Web/src/app/manga-reader/_models/reader-enums.ts b/UI/Web/src/app/manga-reader/_models/reader-enums.ts index 9738edf9f..37ab08054 100644 --- a/UI/Web/src/app/manga-reader/_models/reader-enums.ts +++ b/UI/Web/src/app/manga-reader/_models/reader-enums.ts @@ -15,8 +15,3 @@ export enum PAGING_DIRECTION { BACKWARDS = -1, } -export enum COLOR_FILTER { - NONE = '', - SEPIA = 'filter-sepia', - DARK = 'filter-dark' -} diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html index 6bc608c11..a2c1f61e1 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.html @@ -26,7 +26,10 @@
    - image + image