diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 627edd9ed..45583c597 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -75,13 +75,13 @@ body: - type: dropdown id: mobile-browsers attributes: - label: If the issue is being seen on the UI, what browsers are you seeing the problem on? + label: If the issue is being seen on the Mobile UI, what browsers are you seeing the problem on? multiple: true options: - Firefox - Chrome - Safari - - Microsoft Edge + - Other iOS Browser - type: textarea id: logs attributes: diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 98ce4c439..8a6b9c2f6 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -10,12 +10,12 @@ jobs: runs-on: windows-latest steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: dotnet restore - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: csproj path: Kavita.Common/Kavita.Common.csproj diff --git a/.github/workflows/canary-workflow.yml b/.github/workflows/canary-workflow.yml index af4a45dec..32eb2d01f 100644 --- a/.github/workflows/canary-workflow.yml +++ b/.github/workflows/canary-workflow.yml @@ -12,11 +12,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: csproj path: Kavita.Common/Kavita.Common.csproj @@ -26,12 +26,12 @@ jobs: needs: [ build ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x @@ -59,14 +59,14 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Check Out Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: canary - name: NodeJS to Compile WebUI - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '18.13.x' + node-version: 20 - run: | cd UI/Web || exit echo 'Installing web dependencies' @@ -81,7 +81,7 @@ jobs: cd ../ || exit - name: Get csproj Version - uses: kzrnm/get-net-sdk-project-versions-action@v1 + uses: kzrnm/get-net-sdk-project-versions-action@v2 id: get-version with: proj-path: Kavita.Common/Kavita.Common.csproj @@ -96,7 +96,7 @@ jobs: run: echo "${{steps.get-version.outputs.assembly-version}}" - name: Compile dotnet app - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x @@ -106,28 +106,28 @@ jobs: - run: ./monorepo-build.sh - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push id: docker_build - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 53103f850..6f77e6547 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install Swashbuckle CLI shell: bash diff --git a/.github/workflows/develop-workflow.yml b/.github/workflows/develop-workflow.yml index dff82c01e..d97b7f8cf 100644 --- a/.github/workflows/develop-workflow.yml +++ b/.github/workflows/develop-workflow.yml @@ -2,10 +2,7 @@ name: Nightly Workflow on: push: - branches: ['!release/**'] - pull_request: branches: [ 'develop', '!release/**' ] - types: [ closed ] workflow_dispatch: jobs: @@ -21,14 +18,14 @@ jobs: build: name: Upload Kavita.Common for Version Bump runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release') + if: github.ref == 'refs/heads/develop' steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: csproj path: Kavita.Common/Kavita.Common.csproj @@ -37,14 +34,14 @@ jobs: name: Bump version needs: [ build ] runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release') + if: github.ref == 'refs/heads/develop' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x @@ -59,7 +56,7 @@ jobs: name: Build Nightly Docker needs: [ build, version ] runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release') + if: github.ref == 'refs/heads/develop' permissions: packages: write contents: read @@ -92,14 +89,14 @@ jobs: echo "BODY=$body" >> $GITHUB_OUTPUT - name: Check Out Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: develop - name: NodeJS to Compile WebUI - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '18.13.x' + node-version: 20 - run: | cd UI/Web || exit echo 'Installing web dependencies' @@ -114,7 +111,7 @@ jobs: cd ../ || exit - name: Get csproj Version - uses: kzrnm/get-net-sdk-project-versions-action@v1 + uses: kzrnm/get-net-sdk-project-versions-action@v2 id: get-version with: proj-path: Kavita.Common/Kavita.Common.csproj @@ -129,7 +126,7 @@ jobs: run: echo "${{steps.get-version.outputs.assembly-version}}" - name: Compile dotnet app - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x @@ -139,28 +136,28 @@ jobs: - run: ./monorepo-build.sh - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push id: docker_build - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index ca1314e8b..84a381e0b 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -30,11 +30,11 @@ jobs: if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') steps: - name: Checkout Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: csproj path: Kavita.Common/Kavita.Common.csproj @@ -77,14 +77,14 @@ jobs: echo "BODY=$body" >> $GITHUB_OUTPUT - name: Check Out Repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: develop - name: NodeJS to Compile WebUI - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '18.13.x' + node-version: 20 - run: | cd UI/Web || exit @@ -100,7 +100,7 @@ jobs: cd ../ || exit - name: Get csproj Version - uses: kzrnm/get-net-sdk-project-versions-action@v1 + uses: kzrnm/get-net-sdk-project-versions-action@v2 id: get-version with: proj-path: Kavita.Common/Kavita.Common.csproj @@ -117,7 +117,7 @@ jobs: id: parse-version - name: Compile dotnet app - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - name: Install Swashbuckle CLI @@ -126,28 +126,28 @@ jobs: - run: ./monorepo-build.sh - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Build and push stable id: docker_build_stable - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 @@ -156,7 +156,7 @@ jobs: - name: Build and push nightly id: docker_build_nightly - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 diff --git a/.gitignore b/.gitignore index bb124fc7f..584f0026e 100644 --- a/.gitignore +++ b/.gitignore @@ -520,6 +520,7 @@ UI/Web/dist/ /API/config/*.db /API/config/*.bak /API/config/*.backup +/API/config/*.csv /API/config/Hangfire.db /API/config/Hangfire-log.db API/config/covers/ diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 000000000..1876ac55a --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1,15 @@ +# Path to sources +sonar.sources=. +sonar.exclusions=API.Benchmark +#sonar.inclusions= + +# Path to tests +sonar.tests=API.Tests +#sonar.test.exclusions= +#sonar.test.inclusions= + +# Source encoding +sonar.sourceEncoding=UTF-8 + +# Exclusions for copy-paste detection +#sonar.cpd.exclusions= diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 624cef936..052226f56 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 18f0669cd..a3464db9d 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -10,6 +10,7 @@ using API.Helpers; using API.Helpers.Builders; using API.Services; using AutoMapper; +using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -47,6 +48,7 @@ public abstract class AbstractDbTest var config = new MapperConfiguration(cfg => cfg.AddProfile()); var mapper = config.CreateMapper(); + _unitOfWork = new UnitOfWork(_context, mapper, null); } diff --git a/API.Tests/Extensions/EnumerableExtensionsTests.cs b/API.Tests/Extensions/EnumerableExtensionsTests.cs index e115d45f3..bdd3433ae 100644 --- a/API.Tests/Extensions/EnumerableExtensionsTests.cs +++ b/API.Tests/Extensions/EnumerableExtensionsTests.cs @@ -74,10 +74,10 @@ public class EnumerableExtensionsTests 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"} - )] + [InlineData( + new[] {"01/001.jpg", "001.jpg"}, + new[] {"001.jpg", "01/001.jpg"} + )] public void TestNaturalSort(string[] input, string[] expected) { Assert.Equal(expected, input.OrderByNatural(x => x).ToArray()); diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 230028d44..771ba940c 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -45,17 +45,17 @@ public class QueryableExtensionsTests [InlineData(false, 1)] public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { - var items = new List() + var items = new List() { - new CollectionTagBuilder("Test") - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) + new AppUserCollectionBuilder("Test") + .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build()) .Build(), - new CollectionTagBuilder("Test 2") - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) + new AppUserCollectionBuilder("Test 2") + .WithItem(new SeriesBuilder("S2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()).Build()) + .WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build()) .Build(), - new CollectionTagBuilder("Test 3") - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) + new AppUserCollectionBuilder("Test 3") + .WithItem(new SeriesBuilder("S3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build()) .Build(), }; diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index fcedc779e..9dc926ef5 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -123,7 +123,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip"; + filepath = @"E:/Manga/Beelzebub/Beelzebub_01_[Noodles].zip"; expected.Add(filepath, new ParserInfo { Series = "Beelzebub", Volumes = Parser.LooseLeafVolume, @@ -132,7 +132,7 @@ public class DefaultParserTests }); // Note: Lots of duplicates here. I think I can move them to the ParserTests itself - filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip"; + 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", @@ -140,7 +140,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; + filepath = @"E:/Manga/Tenjo Tenge (Color)/Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; expected.Add(filepath, new ParserInfo { Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "", @@ -148,7 +148,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)\Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz"; + 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 = "", @@ -156,7 +156,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Dorohedoro\Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; + filepath = @"E:/Manga/Dorohedoro/Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; expected.Add(filepath, new ParserInfo { Series = "Dorohedoro", Volumes = "1", Edition = "", @@ -164,7 +164,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\APOSIMZ\APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz"; + filepath = @"E:/Manga/APOSIMZ/APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz"; expected.Add(filepath, new ParserInfo { Series = "APOSIMZ", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", @@ -172,7 +172,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Corpse Party Musume\Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; + 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 = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", @@ -180,7 +180,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Goblin Slayer\Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; + 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 = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", @@ -188,7 +188,7 @@ public class DefaultParserTests FullFilePath = filepath }); - filepath = @"E:\Manga\Summer Time Rendering\Specials\Record 014 (between chapter 083 and ch084) SP11.cbr"; + 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 = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, Edition = "", @@ -196,7 +196,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = true }); - filepath = @"E:\Manga\Seraph of the End\Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz"; + 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 = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", @@ -204,7 +204,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Manga\Kono Subarashii Sekai ni Bakuen wo!\Vol. 00 Ch. 000.cbz"; + 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 = "", @@ -212,7 +212,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Manga\Toukyou Akazukin\Vol. 01 Ch. 001.cbz"; + filepath = @"E:/Manga/Toukyou Akazukin/Vol. 01 Ch. 001.cbz"; expected.Add(filepath, new ParserInfo { Series = "Toukyou Akazukin", Volumes = "1", Edition = "", @@ -221,10 +221,10 @@ public class DefaultParserTests }); // If an image is cover exclusively, ignore it - filepath = @"E:\Manga\Seraph of the End\cover.png"; + filepath = @"E:/Manga/Seraph of the End/cover.png"; expected.Add(filepath, null); - filepath = @"E:\Manga\The Beginning After the End\Chapter 001.cbz"; + filepath = @"E:/Manga/The Beginning After the End/Chapter 001.cbz"; expected.Add(filepath, new ParserInfo { Series = "The Beginning After the End", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", @@ -232,7 +232,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Manga\Air Gear\Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz"; + 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", @@ -240,7 +240,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Manga\Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub"; + 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 = "", @@ -279,17 +279,17 @@ public class DefaultParserTests //[Fact] public void Parse_ParseInfo_Manga_ImageOnly() { - // Images don't have root path as E:\Manga, but rather as the path of the folder + // Images don't have root path as E:/Manga, but rather as the path of the folder // Note: Fallback to folder will parse Monster #8 and get Monster - var filepath = @"E:\Manga\Monster #8\Ch. 001-016 [MangaPlus] [Digital] [amit34521]\Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]\13.jpg"; + var filepath = @"E:/Manga/Monster #8/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg"; var expectedInfo2 = new ParserInfo { Series = "Monster #8", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Manga, null); + var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -307,7 +307,7 @@ public class DefaultParserTests Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath); _testOutputHelper.WriteLine("FullFilePath ✓"); - filepath = @"E:\Manga\Extra layer for no reason\Just Images the second\Vol19\ch. 186\Vol. 19 p106.gif"; + filepath = @"E:/Manga/Extra layer for no reason/Just Images the second/Vol19/ch. 186/Vol. 19 p106.gif"; expectedInfo2 = new ParserInfo { Series = "Just Images the second", Volumes = "19", Edition = "", @@ -315,7 +315,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga",LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -333,7 +333,7 @@ public class DefaultParserTests Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath); _testOutputHelper.WriteLine("FullFilePath ✓"); - filepath = @"E:\Manga\Extra layer for no reason\Just Images the second\Blank Folder\Vol19\ch. 186\Vol. 19 p106.gif"; + filepath = @"E:/Manga/Extra layer for no reason/Just Images the second/Blank Folder/Vol19/ch. 186/Vol. 19 p106.gif"; expectedInfo2 = new ParserInfo { Series = "Just Images the second", Volumes = "19", Edition = "", @@ -341,7 +341,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -448,7 +448,7 @@ public class DefaultParserTests }); // Fallback test with bad naming - filepath = @"E:\Comics\Comics\Babe\Babe Vol.1 #1-4\Babe 01.cbr"; + filepath = @"E:/Comics/Comics/Babe/Babe Vol.1 #1-4/Babe 01.cbr"; expected.Add(filepath, new ParserInfo { Series = "Babe", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", @@ -456,7 +456,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Comics\Comics\Publisher\Batman the Detective (2021)\Batman the Detective - v6 - 11 - (2021).cbr"; + 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 = "", @@ -464,7 +464,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Comics\Comics\Batman - The Man Who Laughs #1 (2005)\Batman - The Man Who Laughs #1 (2005).cbr"; + 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 = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", diff --git a/API.Tests/Parsing/ComicParsingTests.cs b/API.Tests/Parsing/ComicParsingTests.cs index 1d0f4ae69..4bb2948b1 100644 --- a/API.Tests/Parsing/ComicParsingTests.cs +++ b/API.Tests/Parsing/ComicParsingTests.cs @@ -78,6 +78,8 @@ public class ComicParsingTests [InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")] [InlineData("Kebab Том 1 Глава 1", "Kebab")] [InlineData("Манга Глава 1", "Манга")] + [InlineData("ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก เล่ม 1", "ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก")] + [InlineData("SKY WORLD สกายเวิลด์ เล่มที่ 1", "SKY WORLD สกายเวิลด์")] public void ParseComicSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename)); @@ -129,6 +131,9 @@ public class ComicParsingTests // Russian Tests [InlineData("Kebab Том 1 Глава 3", "1")] [InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("ย้อนเวลากลับมาร้าย เล่ม 1", "1")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "1")] + [InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] public void ParseComicVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename)); @@ -178,6 +183,9 @@ public class ComicParsingTests [InlineData("Манга Глава 2", "2")] [InlineData("Манга 2 Глава", "2")] [InlineData("Манга Том 1 2 Глава", "2")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")] + [InlineData("Max Level Returner ตอนที่ 5", "5")] + [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] public void ParseComicChapterTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename)); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index dcb3501e1..446c9e782 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -206,6 +206,10 @@ public class MangaParsingTests [InlineData("test 2 years 1권", "test 2 years")] [InlineData("test 2 years 1화", "test 2 years")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake", "Nagasarete Airantou")] + [InlineData("Cynthia The Mission - c000 - c006 (v06)", "Cynthia The Mission")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")] + [InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")] + [InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); @@ -295,6 +299,9 @@ public class MangaParsingTests [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")] [InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")] [InlineData("Adabana c00-02", "0-2")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")] + [InlineData("Max Level Returner ตอนที่ 5", "5")] + [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); diff --git a/API.Tests/Repository/CollectionTagRepositoryTests.cs b/API.Tests/Repository/CollectionTagRepositoryTests.cs index 1859ab1fc..6abf3f7e7 100644 --- a/API.Tests/Repository/CollectionTagRepositoryTests.cs +++ b/API.Tests/Repository/CollectionTagRepositoryTests.cs @@ -114,65 +114,65 @@ public class CollectionTagRepositoryTests #endregion - #region RemoveTagsWithoutSeries - - [Fact] - public async Task RemoveTagsWithoutSeries_ShouldRemoveTags() - { - var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); - var series = new SeriesBuilder("Test 1").Build(); - var commonTag = new CollectionTagBuilder("Tag 1").Build(); - series.Metadata.CollectionTags.Add(commonTag); - series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build()); - - var series2 = new SeriesBuilder("Test 1").Build(); - series2.Metadata.CollectionTags.Add(commonTag); - library.Series.Add(series); - library.Series.Add(series2); - _unitOfWork.LibraryRepository.Add(library); - await _unitOfWork.CommitAsync(); - - Assert.Equal(2, series.Metadata.CollectionTags.Count); - Assert.Single(series2.Metadata.CollectionTags); - - // Delete both series - _unitOfWork.SeriesRepository.Remove(series); - _unitOfWork.SeriesRepository.Remove(series2); - - await _unitOfWork.CommitAsync(); - - // Validate that both tags exist - Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); - - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); - } - - [Fact] - public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags() - { - var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); - var series = new SeriesBuilder("Test 1").Build(); - var commonTag = new CollectionTagBuilder("Tag 1").Build(); - series.Metadata.CollectionTags.Add(commonTag); - series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build()); - - var series2 = new SeriesBuilder("Test 1").Build(); - series2.Metadata.CollectionTags.Add(commonTag); - library.Series.Add(series); - library.Series.Add(series2); - _unitOfWork.LibraryRepository.Add(library); - await _unitOfWork.CommitAsync(); - - Assert.Equal(2, series.Metadata.CollectionTags.Count); - Assert.Single(series2.Metadata.CollectionTags); - - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - - // Validate that both tags exist - Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); - } - - #endregion + // #region RemoveTagsWithoutSeries + // + // [Fact] + // public async Task RemoveTagsWithoutSeries_ShouldRemoveTags() + // { + // var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); + // var series = new SeriesBuilder("Test 1").Build(); + // var commonTag = new AppUserCollectionBuilder("Tag 1").Build(); + // series.Metadata.CollectionTags.Add(commonTag); + // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build()); + // + // var series2 = new SeriesBuilder("Test 1").Build(); + // series2.Metadata.CollectionTags.Add(commonTag); + // library.Series.Add(series); + // library.Series.Add(series2); + // _unitOfWork.LibraryRepository.Add(library); + // await _unitOfWork.CommitAsync(); + // + // Assert.Equal(2, series.Metadata.CollectionTags.Count); + // Assert.Single(series2.Metadata.CollectionTags); + // + // // Delete both series + // _unitOfWork.SeriesRepository.Remove(series); + // _unitOfWork.SeriesRepository.Remove(series2); + // + // await _unitOfWork.CommitAsync(); + // + // // Validate that both tags exist + // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); + // + // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + // + // Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); + // } + // + // [Fact] + // public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags() + // { + // var library = new LibraryBuilder("Test", LibraryType.Manga).Build(); + // var series = new SeriesBuilder("Test 1").Build(); + // var commonTag = new AppUserCollectionBuilder("Tag 1").Build(); + // series.Metadata.CollectionTags.Add(commonTag); + // series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build()); + // + // var series2 = new SeriesBuilder("Test 1").Build(); + // series2.Metadata.CollectionTags.Add(commonTag); + // library.Series.Add(series); + // library.Series.Add(series2); + // _unitOfWork.LibraryRepository.Add(library); + // await _unitOfWork.CommitAsync(); + // + // Assert.Equal(2, series.Metadata.CollectionTags.Count); + // Assert.Single(series2.Metadata.CollectionTags); + // + // await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + // + // // Validate that both tags exist + // Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count()); + // } + // + // #endregion } diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index cd60ed579..ba06525a3 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -52,12 +52,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService throw new System.NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, Library library) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) { throw new System.NotImplementedException(); } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, Library library) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { throw new System.NotImplementedException(); } diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index cc00a4484..2ebee8d1d 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -167,53 +167,53 @@ public class CleanupServiceTests : AbstractDbTest } #endregion - #region DeleteTagCoverImages - - [Fact] - public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() - { - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData("")); - filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); - - // Delete all Series to reset state - await ResetDb(); - - // Add 2 series with cover images - - _context.Series.Add(new SeriesBuilder("Test 1") - .WithMetadata(new SeriesMetadataBuilder() - .WithCollectionTag(new CollectionTagBuilder("Something") - .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg") - .Build()) - .Build()) - .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg") - .WithLibraryId(1) - .Build()); - - _context.Series.Add(new SeriesBuilder("Test 2") - .WithMetadata(new SeriesMetadataBuilder() - .WithCollectionTag(new CollectionTagBuilder("Something") - .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg") - .Build()) - .Build()) - .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg") - .WithLibraryId(1) - .Build()); - - - 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 DeleteTagCoverImages + // + // [Fact] + // public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles() + // { + // var filesystem = CreateFileSystem(); + // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData("")); + // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData("")); + // filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData("")); + // + // // Delete all Series to reset state + // await ResetDb(); + // + // // Add 2 series with cover images + // + // _context.Series.Add(new SeriesBuilder("Test 1") + // .WithMetadata(new SeriesMetadataBuilder() + // .WithCollectionTag(new AppUserCollectionBuilder("Something") + // .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg") + // .Build()) + // .Build()) + // .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg") + // .WithLibraryId(1) + // .Build()); + // + // _context.Series.Add(new SeriesBuilder("Test 2") + // .WithMetadata(new SeriesMetadataBuilder() + // .WithCollectionTag(new AppUserCollectionBuilder("Something") + // .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg") + // .Build()) + // .Build()) + // .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg") + // .WithLibraryId(1) + // .Build()); + // + // + // 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 DeleteReadingListCoverImages [Fact] @@ -435,24 +435,26 @@ public class CleanupServiceTests : AbstractDbTest [Fact] public async Task CleanupDbEntries_RemoveTagsWithoutSeries() { - var c = new CollectionTag() + var s = new SeriesBuilder("Test") + .WithFormat(MangaFormat.Epub) + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test LIb").Build(); + _context.Series.Add(s); + + var c = new AppUserCollection() { Title = "Test Tag", NormalizedTitle = "Test Tag".ToNormalized(), + AgeRating = AgeRating.Unknown, + Items = new List() {s} }; - var s = new SeriesBuilder("Test") - .WithFormat(MangaFormat.Epub) - .WithMetadata(new SeriesMetadataBuilder().WithCollectionTag(c).Build()) - .Build(); - s.Library = new LibraryBuilder("Test LIb").Build(); - - _context.Series.Add(s); _context.AppUser.Add(new AppUser() { - UserName = "majora2007" + UserName = "majora2007", + Collections = new List() {c} }); - await _context.SaveChangesAsync(); var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, @@ -465,7 +467,7 @@ public class CleanupServiceTests : AbstractDbTest await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); + Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); } #endregion diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index c06767ed1..85e8391fe 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -3,13 +3,13 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; -using API.DTOs.CollectionTags; +using API.DTOs.Collection; using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; +using API.Services.Plus; using API.SignalR; -using API.Tests.Helpers; using NSubstitute; using Xunit; @@ -25,7 +25,7 @@ public class CollectionTagServiceTests : AbstractDbTest protected override async Task ResetDb() { - _context.CollectionTag.RemoveRange(_context.CollectionTag.ToList()); + _context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList()); _context.Library.RemoveRange(_context.Library.ToList()); await _unitOfWork.CommitAsync(); @@ -33,119 +33,148 @@ public class CollectionTagServiceTests : AbstractDbTest private async Task SeedSeries() { - if (_context.CollectionTag.Any()) return; + if (_context.AppUserCollection.Any()) return; + var s1 = new SeriesBuilder("Series 1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()).Build(); + var s2 = new SeriesBuilder("Series 2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()).Build(); _context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga) - .WithSeries(new SeriesBuilder("Series 1").Build()) - .WithSeries(new SeriesBuilder("Series 2").Build()) + .WithSeries(s1) + .WithSeries(s2) .Build()); - _context.CollectionTag.Add(new CollectionTagBuilder("Tag 1").Build()); - _context.CollectionTag.Add(new CollectionTagBuilder("Tag 2").WithIsPromoted(true).Build()); + var user = new AppUserBuilder("majora2007", "majora2007", Seed.DefaultThemes.First()).Build(); + user.Collections = new List() + { + new AppUserCollectionBuilder("Tag 1").WithItems(new []{s1}).Build(), + new AppUserCollectionBuilder("Tag 2").WithItems(new []{s1, s2}).WithIsPromoted(true).Build() + }; + _unitOfWork.UserRepository.Add(user); + await _unitOfWork.CommitAsync(); } - - [Fact] - public async Task TagExistsByName_ShouldFindTag() - { - await SeedSeries(); - Assert.True(await _service.TagExistsByName("Tag 1")); - Assert.True(await _service.TagExistsByName("tag 1")); - Assert.False(await _service.TagExistsByName("tag5")); - } + #region UpdateTag [Fact] public async Task UpdateTag_ShouldUpdateFields() { await SeedSeries(); - _context.CollectionTag.Add(new CollectionTagBuilder("UpdateTag_ShouldUpdateFields").WithId(3).WithIsPromoted(true).Build()); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldUpdateFields").WithIsPromoted(true).Build()); + _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - await _service.UpdateTag(new CollectionTagDto() + await _service.UpdateTag(new AppUserCollectionDto() { Title = "UpdateTag_ShouldUpdateFields", Id = 3, Promoted = true, Summary = "Test Summary", - }); + AgeRating = AgeRating.Unknown + }, 1); - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); Assert.NotNull(tag); Assert.True(tag.Promoted); - Assert.True(!string.IsNullOrEmpty(tag.Summary)); + Assert.False(string.IsNullOrEmpty(tag.Summary)); } + /// + /// UpdateTag should not change any title if non-Kavita source + /// [Fact] - public async Task AddTagToSeries_ShouldAddTagToAllSeries() + public async Task UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource() { await SeedSeries(); - var ids = new[] {1, 2}; - await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetTagAsync(1, CollectionTagIncludes.SeriesMetadata), ids); - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids); - Assert.Contains(metadatas.ElementAt(0).CollectionTags, t => t.Title.Equals("Tag 1")); - Assert.Contains(metadatas.ElementAt(1).CollectionTags, t => t.Title.Equals("Tag 1")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build()); + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "New Title", + Id = 3, + Promoted = true, + Summary = "Test Summary", + AgeRating = AgeRating.Unknown + }, 1); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3); + Assert.NotNull(tag); + Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title); + Assert.False(string.IsNullOrEmpty(tag.Summary)); + } + #endregion + + + #region RemoveTagFromSeries + + [Fact] + public async Task RemoveTagFromSeries_RemoveSeriesFromTag() + { + await SeedSeries(); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Tag 2 has 2 series + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + + await _service.RemoveTagFromSeries(tag, new[] {1}); + var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.Equal(2, userCollections!.Collections.Count); + Assert.Equal(1, tag.Items.Count); + Assert.Equal(2, tag.Items.First().Id); } + /// + /// Ensure the rating of the tag updates after a series change + /// [Fact] - public async Task RemoveTagFromSeries_ShouldRemoveMultiple() + public async Task RemoveTagFromSeries_RemoveSeriesFromTag_UpdatesRating() { await SeedSeries(); - var ids = new[] {1, 2}; - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(2, CollectionTagIncludes.SeriesMetadata); - await _service.AddTagToSeries(tag, ids); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Tag 2 has 2 series + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); await _service.RemoveTagFromSeries(tag, new[] {1}); - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1}); - - Assert.Single(metadatas); - Assert.Empty(metadatas.First().CollectionTags); - Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2})); + Assert.Equal(AgeRating.G, tag.AgeRating); } + /// + /// Should remove the tag when there are no items left on the tag + /// [Fact] - public async Task GetTagOrCreate_ShouldReturnNewTag() + public async Task RemoveTagFromSeries_RemoveSeriesFromTag_DeleteTagWhenNoSeriesLeft() { await SeedSeries(); - var tag = await _service.GetTagOrCreate(0, "GetTagOrCreate_ShouldReturnNewTag"); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Tag 1 has 1 series + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); Assert.NotNull(tag); - Assert.Equal(0, tag.Id); - } - - [Fact] - public async Task GetTagOrCreate_ShouldReturnExistingTag() - { - await SeedSeries(); - var tag = await _service.GetTagOrCreate(1, "Some new tag"); - Assert.NotNull(tag); - Assert.Equal(1, tag.Id); - Assert.Equal("Tag 1", tag.Title); - } - - [Fact] - public async Task RemoveTagsWithoutSeries_ShouldRemoveAbandonedEntries() - { - await SeedSeries(); - // Setup a tag with one series - var tag = await _service.GetTagOrCreate(0, "Tag with a series"); - await _unitOfWork.CommitAsync(); - - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1}); - tag.SeriesMetadatas.Add(metadatas.First()); - var tagId = tag.Id; - await _unitOfWork.CommitAsync(); - - // Validate it doesn't remove tags it shouldn't - await _service.RemoveTagsWithoutSeries(); - Assert.NotNull(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId)); await _service.RemoveTagFromSeries(tag, new[] {1}); - - // Validate it does remove tags it should - await _service.RemoveTagsWithoutSeries(); - Assert.Null(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId)); + var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(tag2); } + + #endregion + } diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index 03e97530c..04dc20522 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -54,14 +54,14 @@ internal class MockReadingItemService : IReadingItemService throw new NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, Library library) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) { - return _defaultParser.Parse(path, rootPath, libraryRoot, library.Type); + return _defaultParser.Parse(path, rootPath, libraryRoot, type); } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, Library library) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { - return _defaultParser.Parse(path, rootPath, libraryRoot, library.Type); + return _defaultParser.Parse(path, rootPath, libraryRoot, type); } } diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index f29bcb9b5..468c22681 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 996358c38..0ef875e06 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -768,7 +768,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}} }, - CollectionTags = new List() + }); Assert.True(success); @@ -777,46 +777,6 @@ public class SeriesServiceTests : AbstractDbTest Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); - - } - - [Fact] - public async Task UpdateSeriesMetadata_ShouldCreateNewTags_IfNoneExist() - { - await ResetDb(); - var s = new SeriesBuilder("Test") - .Build(); - s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - - _context.Series.Add(s); - await _context.SaveChangesAsync(); - - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto - { - SeriesMetadata = new SeriesMetadataDto - { - SeriesId = 1, - Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}}, - Tags = new List {new TagDto {Id = 0, Title = "New Tag"}}, - Characters = new List {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Character}}, - Colorists = new List {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Colorist}}, - Pencillers = new List {new PersonDto {Id = 0, Name = "Joe Shmo 2", Role = PersonRole.Penciller}}, - }, - CollectionTags = new List - { - new CollectionTagDto {Id = 0, Promoted = false, Summary = string.Empty, CoverImageLocked = false, Title = "New Collection"} - } - }); - - Assert.True(success); - - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); - Assert.NotNull(series.Metadata); - Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); - Assert.True(series.Metadata.People.All(g => g.Name is "Joe Shmo" or "Joe Shmo 2")); - Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(g => g.Title)); - Assert.Contains("New Collection", series.Metadata.CollectionTags.Select(g => g.Title)); - } [Fact] @@ -842,7 +802,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Genres = new List {new () {Id = 0, Title = "New Genre"}}, }, - CollectionTags = new List() + }); Assert.True(success); @@ -875,7 +835,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, }, - CollectionTags = new List() + }); Assert.True(success); @@ -911,7 +871,7 @@ public class SeriesServiceTests : AbstractDbTest Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, PublisherLocked = true }, - CollectionTags = new List() + }); Assert.True(success); @@ -944,7 +904,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, Publishers = new List(), }, - CollectionTags = new List() + }); Assert.True(success); @@ -978,7 +938,7 @@ public class SeriesServiceTests : AbstractDbTest Genres = new List {new () {Id = 1, Title = "Existing Genre"}}, GenresLocked = true }, - CollectionTags = new List() + }); Assert.True(success); @@ -1007,7 +967,7 @@ public class SeriesServiceTests : AbstractDbTest SeriesId = 1, ReleaseYear = 100, }, - CollectionTags = new List() + }); Assert.True(success); diff --git a/API/API.csproj b/API/API.csproj index 42f11e012..c045d6981 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -66,10 +66,10 @@ - + - + @@ -81,8 +81,8 @@ - - + + @@ -95,14 +95,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index de2cf0394..1be979a56 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -40,8 +40,14 @@ public static class PolicyConstants /// /// This is used explicitly for Demo Server. Not sure why it would be used in another fashion public const string ReadOnlyRole = "Read Only"; + /// + /// Ability to promote entities (Collections, Reading Lists, etc). + /// + public const string PromoteRole = "Promote"; + + public static readonly ImmutableArray ValidRoles = - ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole); + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole); } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index ab8c19d10..fc5e07378 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -363,7 +363,7 @@ public class AccountController : BaseApiController } // Validate no other users exist with this email - if (user.Email!.Equals(dto.Email)) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); + if (user.Email!.Equals(dto.Email)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); // Check if email is used by another user var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); @@ -386,8 +386,12 @@ public class AccountController : BaseApiController user.ConfirmationToken = token; await _userManager.UpdateAsync(user); + var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email); + _logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + if (!shouldEmailUser) { + _logger.LogInformation("Cannot email admin, email not setup or admin email invalid"); return Ok(new InviteUserResponse { EmailLink = string.Empty, @@ -399,9 +403,6 @@ public class AccountController : BaseApiController // Send a confirmation email try { - var emailLink = await _emailService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email); - _logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink); - if (!_emailService.IsValidEmail(user.Email)) { _logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email); @@ -839,6 +840,7 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(user.Id, "generic-user-email-update")); } user.ConfirmationToken = null; + user.EmailConfirmed = true; await _unitOfWork.CommitAsync(); diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 7a7d5b06a..3b9f8cdda 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,4 +1,6 @@ using System.Threading.Tasks; +using API.Data.ManualMigrations; +using API.DTOs.Progress; using API.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -28,4 +30,15 @@ public class AdminController : BaseApiController var users = await _userManager.GetUsersInRoleAsync("Admin"); return users.Count > 0; } + + /// + /// Set the progress information for a particular user + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("update-chapter-progress")] + public async Task> UpdateChapterProgress(UpdateUserProgressDto dto) + { + return Ok(await Task.FromResult(false)); + } } diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 26f6871d1..bfc6849d6 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; +using API.DTOs.Collection; using API.DTOs.CollectionTags; -using API.Entities.Metadata; +using API.Entities; using API.Extensions; +using API.Helpers.Builders; using API.Services; +using API.Services.Plus; using Kavita.Common; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; @@ -23,61 +27,50 @@ public class CollectionController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ICollectionTagService _collectionService; private readonly ILocalizationService _localizationService; + private readonly IExternalMetadataService _externalMetadataService; /// public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService, - ILocalizationService localizationService) + ILocalizationService localizationService, IExternalMetadataService externalMetadataService) { _unitOfWork = unitOfWork; _collectionService = collectionService; _localizationService = localizationService; + _externalMetadataService = externalMetadataService; } /// - /// Return a list of all collection tags on the server for the logged in user. + /// Returns all Collection tags for a given User /// /// [HttpGet] - public async Task>> GetAllTags() + public async Task>> GetAllTags(bool ownedOnly = false) { - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null) return Unauthorized(); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - if (isAdmin) - { - return Ok(await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()); - } - - return Ok(await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(user.Id)); + return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly)); } /// - /// Searches against the collection tags on the DB and returns matches that meet the search criteria. - /// Search strings will be cleaned of certain fields, like % + /// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned) /// - /// Search term + /// + /// /// - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("search")] - public async Task>> SearchTags(string? queryString) + [HttpGet("all-series")] + public async Task>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false) { - queryString ??= string.Empty; - queryString = queryString.Replace(@"%", string.Empty); - if (queryString.Length == 0) return await GetAllTags(); - - return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId())); + return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly)); } + /// /// Checks if a collection exists with the name /// /// If empty or null, will return true as that is invalid /// - [Authorize(Policy = "RequireAdminRole")] [HttpGet("name-exists")] public async Task> DoesNameExists(string name) { - return Ok(await _collectionService.TagExistsByName(name)); + return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId())); } /// @@ -86,13 +79,15 @@ public class CollectionController : BaseApiController /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] - public async Task UpdateTag(CollectionTagDto updatedTag) + public async Task UpdateTag(AppUserCollectionDto updatedTag) { try { - if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); + if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) + { + return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); + } } catch (KavitaException ex) { @@ -103,18 +98,94 @@ public class CollectionController : BaseApiController } /// - /// Adds a collection tag onto multiple Series. If tag id is 0, this will create a new tag. + /// Promote/UnPromote multiple collections in one go. Will only update the authenticated user's collections and will only work if the user has promotion role + /// + /// + /// + [HttpPost("promote-multiple")] + public async Task PromoteMultipleCollections(PromoteCollectionsDto dto) + { + // This needs to take into account owner as I can select other users cards + var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds); + var userId = User.GetUserId(); + + if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) + { + return BadRequest(await _localizationService.Translate(userId, "permission-denied")); + } + + foreach (var collection in collections) + { + if (collection.AppUserId != userId) continue; + collection.Promoted = dto.Promoted; + _unitOfWork.CollectionTagRepository.Update(collection); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + + /// + /// Promote/UnPromote multiple collections in one go + /// + /// + /// + [HttpPost("delete-multiple")] + public async Task DeleteMultipleCollections(PromoteCollectionsDto dto) + { + // This needs to take into account owner as I can select other users cards + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); + user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList(); + _unitOfWork.UserRepository.Update(user); + + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + /// + /// Adds multiple series to a collection. If tag id is 0, this will create a new tag. /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-for-series")] public async Task AddToMultipleSeries(CollectionTagBulkAddDto dto) { // Create a new tag and save - var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); - if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok(); + AppUserCollection? tag; + if (dto.CollectionTagId == 0) + { + tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build(); + user.Collections.Add(tag); + } + else + { + // Validate tag doesn't exist + tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId); + } + + if (tag == null) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists")); + } + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList()); + foreach (var s in series) + { + if (tag.Items.Contains(s)) continue; + tag.Items.Add(s); + } + _unitOfWork.UserRepository.Update(user); + if (await _unitOfWork.CommitAsync()) return Ok(); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } @@ -124,13 +195,12 @@ public class CollectionController : BaseApiController /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-series")] public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { try { - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updateSeriesForTagDto.Tag.Id, CollectionTagIncludes.SeriesMetadata); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(updateSeriesForTagDto.Tag.Id, CollectionIncludes.Series); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove)) @@ -145,27 +215,42 @@ public class CollectionController : BaseApiController } /// - /// Removes the collection tag from all Series it was attached to + /// Removes the collection tag from the user /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpDelete] public async Task DeleteTag(int tagId) { try { - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata); - if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); + if (user.Collections.All(c => c.Id != tagId)) + return BadRequest(await _localizationService.Translate(user.Id, "access-denied")); - if (await _collectionService.DeleteTag(tag)) + if (await _collectionService.DeleteTag(tagId, user)) + { return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted")); + } } - catch (Exception) + catch (Exception ex) { + await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } + + /// + /// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record, + /// fetch their Mal interest stacks (including restacks) + /// + /// + [HttpGet("mal-stacks")] + public async Task>> GetMalStacksForUser() + { + return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); + } } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 837ad999c..b7212c7f3 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -111,7 +111,7 @@ public class ImageController : BaseApiController } /// - /// Returns cover image for Collection Tag + /// Returns cover image for Collection /// /// /// @@ -121,6 +121,7 @@ public class ImageController : BaseApiController { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId)); if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) { diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index b4b86dccf..eb467ab9f 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -166,11 +166,36 @@ public class LibraryController : BaseApiController return Ok(_directoryService.ListDirectory(path)); } + /// + /// Return a specific library + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet] + public async Task> GetLibrary(int libraryId) + { + var username = User.GetUsername(); + if (string.IsNullOrEmpty(username)) return Unauthorized(); + + var cacheKey = CacheKey + username; + var result = await _libraryCacheProvider.GetAsync>(cacheKey); + if (result.HasValue) + { + return Ok(result.Value.FirstOrDefault(l => l.Id == libraryId)); + } + + var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList(); + await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); + _logger.LogDebug("Caching libraries for {Key}", cacheKey); + + return Ok(ret.Find(l => l.Id == libraryId)); + } + /// /// Return all libraries in the Server /// /// - [HttpGet] + [HttpGet("libraries")] public async Task>> GetLibraries() { var username = User.GetUsername(); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 95affd2e9..c6c9fb425 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -9,10 +9,12 @@ using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.OPDS; +using API.DTOs.Progress; using API.DTOs.Search; using API.Entities; using API.Entities.Enums; @@ -449,15 +451,13 @@ public class OpdsController : BaseApiController var userId = await GetUser(apiKey); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); - var (baseUrl, prefix) = await GetPrefix(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()) - : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId)); + var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true); + var (baseUrl, prefix) = await GetPrefix(); var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); @@ -466,12 +466,15 @@ public class OpdsController : BaseApiController 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, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/collections/{tag.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}") + ] })); return CreateXmlResult(SerializeXml(feed)); @@ -488,20 +491,9 @@ public class OpdsController : BaseApiController var (baseUrl, prefix) = await GetPrefix(); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); if (user == null) return Unauthorized(); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - IEnumerable tags; - if (isAdmin) - { - tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); - } - else - { - tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId); - } - - var tag = tags.SingleOrDefault(t => t.Id == collectionId); - if (tag == null) + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId); + if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted)) { return BadRequest("Collection does not exist or you don't have access"); } @@ -1131,7 +1123,9 @@ public class OpdsController : BaseApiController Id = mangaFile.Id.ToString(), Title = title, Extent = fileSize, - Summary = $"{fileType.Split("/")[1]} - {fileSize}", + Summary = $"File Type: {fileType.Split("/")[1]} - {fileSize}" + (string.IsNullOrWhiteSpace(chapter.Summary) + ? string.Empty + : $" Summary: {chapter.Summary}"), Format = mangaFile.Format.ToString(), Links = new List() { @@ -1287,7 +1281,7 @@ public class OpdsController : BaseApiController }; } - private string SerializeXml(Feed feed) + private string SerializeXml(Feed? feed) { if (feed == null) return string.Empty; using var sm = new StringWriter(); diff --git a/API/Controllers/PanelsController.cs b/API/Controllers/PanelsController.cs index c53b68f86..d6cdbee2f 100644 --- a/API/Controllers/PanelsController.cs +++ b/API/Controllers/PanelsController.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; +using API.DTOs.Progress; using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index dd2ec1575..6e870c6ea 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -7,8 +7,8 @@ using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; @@ -880,4 +880,21 @@ public class ReaderController : BaseApiController await _unitOfWork.CommitAsync(); return Ok(); } + + /// + /// Get all progress events for a given chapter + /// + /// + /// + [HttpGet("all-chapter-progress")] + public async Task>> GetProgressForChapter(int chapterId) + { + if (User.IsInRole(PolicyConstants.AdminRole)) + { + return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId)); + } + + return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId())); + + } } diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 9707bbf61..685f3e2a1 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController return Ok(user.AniListAccessToken); } + /// + /// Get the current user's MAL token & username + /// + /// + [HttpGet("mal-token")] + public async Task> GetMalToken() + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized(); + + return Ok(new MalUserInfoDto() + { + Username = user.MalUserName, + AccessToken = user.MalAccessToken + }); + } + /// /// Update the current user's AniList token /// @@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController return Ok(); } + /// + /// Update the current user's MAL token (Client ID) and Username + /// + /// + /// + [HttpPost("update-mal-token")] + public async Task UpdateMalToken(MalUserInfoDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized(); + + user.MalAccessToken = dto.AccessToken; + user.MalUserName = dto.Username; + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + /// /// Checks if the current Scrobbling token for the given Provider has expired for the current user /// diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 4ce7d282d..e01628dbd 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -58,7 +58,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); - if (!libraries.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); + if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index d4e1ed59b..802edebf2 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -221,18 +221,18 @@ public class ServerController : BaseApiController /// /// [HttpGet("jobs")] - public ActionResult> GetJobs() + public async Task>> GetJobs() { - var recurringJobs = JobStorage.Current.GetConnection().GetRecurringJobs().Select( - dto => - new JobDto() { - Id = dto.Id, - Title = dto.Id.Replace('-', ' '), - Cron = dto.Cron, - LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null - }); + var jobDtoTasks = JobStorage.Current.GetConnection().GetRecurringJobs().Select(async dto => + new JobDto() + { + Id = dto.Id, + Title = await _localizationService.Translate(User.GetUserId(), dto.Id), + Cron = dto.Cron, + LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null + }); - return Ok(recurringJobs); + return Ok(await Task.WhenAll(jobDtoTasks)); } /// diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index e0339309b..e7429a9b2 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -457,6 +457,7 @@ public class SettingsController : BaseApiController } } + /// /// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup. /// @@ -510,6 +511,7 @@ public class SettingsController : BaseApiController public async Task> TestEmailServiceUrl() { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); + if (string.IsNullOrEmpty(user?.Email)) return BadRequest("Your account has no email on record. Cannot email."); return Ok(await _emailService.SendTestEmail(user!.Email)); } } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 9654abef6..a003551a1 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -8,6 +8,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Plus; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -22,14 +23,16 @@ public class StatsController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; private readonly ILocalizationService _localizationService; + private readonly ILicenseService _licenseService; public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, - UserManager userManager, ILocalizationService localizationService) + UserManager userManager, ILocalizationService localizationService, ILicenseService licenseService) { _statService = statService; _unitOfWork = unitOfWork; _userManager = userManager; _localizationService = localizationService; + _licenseService = licenseService; } [HttpGet("user/{userId}/read")] @@ -181,6 +184,18 @@ public class StatsController : BaseApiController return Ok(_statService.GetWordsReadCountByYear(userId)); } - + /// + /// Returns for Kavita+ the number of Series that have been processed, errored, and not processed + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("kavitaplus-metadata-breakdown")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> GetKavitaPlusMetadataBreakdown() + { + if (!await _licenseService.HasActiveLicense()) + return BadRequest("This data is not available for non-Kavita+ servers"); + return Ok(await _statService.GetKavitaPlusMetadataBreakdown()); + } } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 81b3ea6fe..2430064c8 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Uploads; +using API.Entities.Enums; using API.Extensions; using API.Services; using API.SignalR; @@ -98,6 +99,7 @@ public class UploadController : BaseApiController try { var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); @@ -145,7 +147,7 @@ public class UploadController : BaseApiController try { - var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id); if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist")); var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); @@ -225,17 +227,14 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save")); } - private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0) + private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename) { - var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; - if (thumbnailSize > 0) - { - return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, - filename, encodeFormat, thumbnailSize); - } + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, - filename, encodeFormat); + filename, encodeFormat, coverImageSize.GetDimensions().Width); } /// @@ -326,8 +325,7 @@ public class UploadController : BaseApiController try { var filePath = await CreateThumbnail(uploadFileDto, - $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", - ImageService.LibraryThumbnailWidth); + $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index fdb6baa5d..1a1f37637 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -112,12 +112,23 @@ public class UsersController : BaseApiController existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode; existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries; existingPreferences.LayoutMode = preferencesDto.LayoutMode; - existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; + + existingPreferences.PdfTheme = preferencesDto.PdfTheme; + existingPreferences.PdfLayoutMode = preferencesDto.PdfLayoutMode; + existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; + existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; + + if (existingPreferences.Theme.Id != preferencesDto.Theme?.Id) + { + existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + } + + if (_localizationService.GetLocales().Contains(preferencesDto.Locale)) { existingPreferences.Locale = preferencesDto.Locale; diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index b80607b56..071a027f7 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController /// /// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2) /// + /// This will be removed in v0.8.x /// /// /// diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs new file mode 100644 index 000000000..62d786ca2 --- /dev/null +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -0,0 +1,39 @@ +using System; +using API.Entities.Enums; +using API.Services.Plus; + +namespace API.DTOs.Collection; +#nullable enable + +public class AppUserCollectionDto +{ + public int Id { get; init; } + public string Title { get; set; } = default!; + public string Summary { get; set; } = default!; + public bool Promoted { get; set; } + public AgeRating AgeRating { get; set; } + + /// + /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. + /// + public string? CoverImage { get; set; } = string.Empty; + public bool CoverImageLocked { get; set; } + + /// + /// Owner of the Collection + /// + public string? Owner { get; set; } + + /// + /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) + /// + public DateTime LastSyncUtc { get; set; } + /// + /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote + /// + public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + /// + /// For Non-Kavita sourced collections, the url to sync from + /// + public string? SourceUrl { get; set; } +} diff --git a/API/DTOs/Collection/DeleteCollectionsDto.cs b/API/DTOs/Collection/DeleteCollectionsDto.cs new file mode 100644 index 000000000..66bf257ba --- /dev/null +++ b/API/DTOs/Collection/DeleteCollectionsDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace API.DTOs.Collection; + +public class DeleteCollectionsDto +{ + public IList CollectionIds { get; set; } +} diff --git a/API/DTOs/Collection/MalStackDto.cs b/API/DTOs/Collection/MalStackDto.cs new file mode 100644 index 000000000..3144f6c72 --- /dev/null +++ b/API/DTOs/Collection/MalStackDto.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.Collection; + +/// +/// Represents an Interest Stack from MAL +/// +public class MalStackDto +{ + public required string Title { get; set; } + public required long StackId { get; set; } + public required string Url { get; set; } + public required string? Author { get; set; } + public required int SeriesCount { get; set; } + public required int RestackCount { get; set; } + /// + /// If an existing collection exists within Kavita + /// + /// This is filled out from Kavita and not Kavita+ + public int ExistingId { get; set; } +} diff --git a/API/DTOs/Collection/PromoteCollectionsDto.cs b/API/DTOs/Collection/PromoteCollectionsDto.cs new file mode 100644 index 000000000..2e2ab793b --- /dev/null +++ b/API/DTOs/Collection/PromoteCollectionsDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.Collection; + +public class PromoteCollectionsDto +{ + public IList CollectionIds { get; init; } + public bool Promoted { get; init; } +} diff --git a/API/DTOs/Progress/FullProgressDto.cs b/API/DTOs/Progress/FullProgressDto.cs new file mode 100644 index 000000000..7d0b47f60 --- /dev/null +++ b/API/DTOs/Progress/FullProgressDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace API.DTOs.Progress; + +/// +/// A full progress Record from the DB (not all data, only what's needed for API) +/// +public class FullProgressDto +{ + public int Id { get; set; } + public int ChapterId { get; set; } + public int PagesRead { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public int AppUserId { get; set; } + public string UserName { get; set; } +} diff --git a/API/DTOs/ProgressDto.cs b/API/DTOs/Progress/ProgressDto.cs similarity index 95% rename from API/DTOs/ProgressDto.cs rename to API/DTOs/Progress/ProgressDto.cs index 2a05360c4..9fc9010aa 100644 --- a/API/DTOs/ProgressDto.cs +++ b/API/DTOs/Progress/ProgressDto.cs @@ -1,7 +1,7 @@ using System; using System.ComponentModel.DataAnnotations; -namespace API.DTOs; +namespace API.DTOs.Progress; #nullable enable public class ProgressDto diff --git a/API/DTOs/Progress/UpdateUserProgressDto.cs b/API/DTOs/Progress/UpdateUserProgressDto.cs new file mode 100644 index 000000000..2aa77b04e --- /dev/null +++ b/API/DTOs/Progress/UpdateUserProgressDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace API.DTOs.Progress; +#nullable enable + +public class UpdateUserProgressDto +{ + public int PageNum { get; set; } + public DateTime LastModifiedUtc { get; set; } + public DateTime CreatedUtc { get; set; } +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index f8791b0d6..f4961ac27 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,6 +1,7 @@ using System; namespace API.DTOs.ReadingLists; +#nullable enable public class ReadingListDto { @@ -15,7 +16,7 @@ public class ReadingListDto /// /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. /// - public string CoverImage { get; set; } = string.Empty; + public string? CoverImage { get; set; } = string.Empty; /// /// Minimum Year the Reading List starts /// diff --git a/API/DTOs/Scrobbling/MalUserInfoDto.cs b/API/DTOs/Scrobbling/MalUserInfoDto.cs new file mode 100644 index 000000000..407639e2a --- /dev/null +++ b/API/DTOs/Scrobbling/MalUserInfoDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs.Scrobbling; + +/// +/// Information about a User's MAL connection +/// +public class MalUserInfoDto +{ + public required string Username { get; set; } + /// + /// This is actually the Client Id + /// + public required string AccessToken { get; set; } +} diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index eb47579f1..f7a622664 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.DTOs.Reader; @@ -13,7 +14,7 @@ public class SearchResultGroupDto { public IEnumerable Libraries { get; set; } = default!; public IEnumerable Series { get; set; } = default!; - public IEnumerable Collections { get; set; } = default!; + public IEnumerable Collections { get; set; } = default!; public IEnumerable ReadingLists { get; set; } = default!; public IEnumerable Persons { get; set; } = default!; public IEnumerable Genres { get; set; } = default!; diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index f9349bed1..3f344dff5 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.Entities.Enums; @@ -10,11 +9,6 @@ public class SeriesMetadataDto public int Id { get; set; } public string Summary { get; set; } = string.Empty; - /// - /// Collections the Series belongs to - /// - public ICollection CollectionTags { get; set; } = new List(); - /// /// Genres for the Series /// diff --git a/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs new file mode 100644 index 000000000..9ce44b6fa --- /dev/null +++ b/API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs @@ -0,0 +1,17 @@ +namespace API.DTOs.Statistics; + +public class KavitaPlusMetadataBreakdownDto +{ + /// + /// Total amount of Series + /// + public int TotalSeries { get; set; } + /// + /// Series on the Blacklist (errored or bad match) + /// + public int ErroredSeries { get; set; } + /// + /// Completed so far + /// + public int SeriesCompleted { get; set; } +} diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 43318fe0f..719a9459a 100644 --- a/API/DTOs/UpdateSeriesMetadataDto.cs +++ b/API/DTOs/UpdateSeriesMetadataDto.cs @@ -1,11 +1,6 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using API.DTOs.CollectionTags; - -namespace API.DTOs; +namespace API.DTOs; public class UpdateSeriesMetadataDto { public SeriesMetadataDto SeriesMetadata { get; set; } = default!; - public ICollection CollectionTags { get; set; } = default!; } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 41160e362..1221c73e5 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -152,4 +152,25 @@ public class UserPreferencesDto /// [Required] public string Locale { get; set; } + + /// + /// PDF Reader: Theme of the Reader + /// + [Required] + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + [Required] + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Layout Mode of the reader + /// + [Required] + public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; + /// + /// PDF Reader: Spread Mode of the reader + /// + [Required] + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index b4c95fe82..4af165249 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -36,6 +36,7 @@ public sealed class DataContext : IdentityDbContext ServerSetting { get; set; } = null!; public DbSet AppUserPreferences { get; set; } = null!; public DbSet SeriesMetadata { get; set; } = null!; + [Obsolete] public DbSet CollectionTag { get; set; } = null!; public DbSet AppUserBookmark { get; set; } = null!; public DbSet ReadingList { get; set; } = null!; @@ -64,6 +65,7 @@ public sealed class DataContext : IdentityDbContext ExternalRecommendation { get; set; } = null!; public DbSet ManualMigrationHistory { get; set; } = null!; public DbSet SeriesBlacklist { get; set; } = null!; + public DbSet AppUserCollection { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) @@ -149,6 +151,10 @@ public sealed class DataContext : IdentityDbContext s.ExternalSeriesMetadata) .HasForeignKey(em => em.SeriesId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.AgeRating) + .HasDefaultValue(AgeRating.Unknown); } #nullable enable diff --git a/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs new file mode 100644 index 000000000..93fc569e8 --- /dev/null +++ b/API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs @@ -0,0 +1,187 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Extensions; +using API.Helpers.Builders; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + + +/// +/// v0.8.0 migration to move loose leaf chapters into their own volume and retain user progress. +/// +public static class MigrateLooseLeafChapters +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLooseLeafChapters")) + { + return; + } + + logger.LogCritical( + "Running MigrateLooseLeafChapters migration - Please be patient, this may take some time. This is not an error"); + + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var extension = settings.EncodeMediaAs.GetExtension(); + + var progress = await dataContext.AppUserProgresses + .Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord + { + IsSpecial = c.IsSpecial, + AppUserId = p.AppUserId, + PagesRead = p.PagesRead, + Range = c.Range, + Number = c.Number, + MinNumber = c.MinNumber, + SeriesId = p.SeriesId, + VolumeId = p.VolumeId, + ProgressId = p.Id + }) + .Where(d => !d.IsSpecial) + .Join(dataContext.Volume, d => d.VolumeId, v => v.Id, (d, v) => new + { + ProgressRecord = d, + Volume = v + }) + .Where(d => d.Volume.Name == "0") + .ToListAsync(); + + // First, group all the progresses into different series + logger.LogCritical("Migrating {Count} progress events to new Volume structure for Loose leafs - This may take over 10 minutes depending on size of DB. Please wait", progress.Count); + var progressesGroupedBySeries = progress + .GroupBy(p => p.ProgressRecord.SeriesId); + + foreach (var seriesGroup in progressesGroupedBySeries) + { + // Get each series and move the loose leafs from the old volume to the new Volume + var seriesId = seriesGroup.Key; + + // Handle All Loose Leafs + var looseLeafsInSeries = seriesGroup + .Where(p => !p.ProgressRecord.IsSpecial) + .ToList(); + + // Get distinct Volumes by Id. For each one, create it then create the progress events + var distinctVolumes = looseLeafsInSeries.DistinctBy(d => d.Volume.Id); + foreach (var distinctVolume in distinctVolumes) + { + // Create a new volume for each series with the appropriate number (-100000) + var chapters = await dataContext.Chapter + .Where(c => c.VolumeId == distinctVolume.Volume.Id && !c.IsSpecial).ToListAsync(); + + var newVolume = new VolumeBuilder(Parser.LooseLeafVolume) + .WithSeriesId(seriesId) + .WithCreated(distinctVolume.Volume.Created) + .WithLastModified(distinctVolume.Volume.LastModified) + .Build(); + + newVolume.Pages = chapters.Sum(c => c.Pages); + newVolume.WordCount = chapters.Sum(c => c.WordCount); + newVolume.MinHoursToRead = chapters.Sum(c => c.MinHoursToRead); + newVolume.MaxHoursToRead = chapters.Sum(c => c.MaxHoursToRead); + newVolume.AvgHoursToRead = chapters.Sum(c => c.AvgHoursToRead); + dataContext.Volume.Add(newVolume); + await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId + + // Migrate the progress event to the new volume + var oldVolumeProgresses = await dataContext.AppUserProgresses + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + foreach (var oldProgress in oldVolumeProgresses) + { + oldProgress.VolumeId = newVolume.Id; + } + + + logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + chapters.Count, distinctVolume.Volume.Id, newVolume.Id); + + // Move the loose leaf chapters from the old volume to the new Volume + foreach (var chapter in chapters) + { + // Update the VolumeId on the existing progress event + chapter.VolumeId = newVolume.Id; + + // We need to migrate cover images as well + //UpdateCoverImage(directoryService, logger, chapter, extension, newVolume); + } + + + var oldVolumeBookmarks = await dataContext.AppUserBookmark + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + logger.LogInformation("Moving {Count} existing Bookmarks from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + oldVolumeBookmarks.Count, distinctVolume.Volume.Id, newVolume.Id); + foreach (var bookmark in oldVolumeBookmarks) + { + bookmark.VolumeId = newVolume.Id; + } + + + var oldVolumePersonalToC = await dataContext.AppUserTableOfContent + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + oldVolumePersonalToC.Count, distinctVolume.Volume.Id, newVolume.Id); + foreach (var pToc in oldVolumePersonalToC) + { + pToc.VolumeId = newVolume.Id; + } + + var oldVolumeReadingListItems = await dataContext.ReadingListItem + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + oldVolumeReadingListItems.Count, distinctVolume.Volume.Id, newVolume.Id); + foreach (var readingListItem in oldVolumeReadingListItems) + { + readingListItem.VolumeId = newVolume.Id; + } + + + await dataContext.SaveChangesAsync(); + } + } + + // Save changes after processing all series + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateLooseLeafChapters", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateLooseLeafChapters migration - Completed. This is not an error"); + } + + private static void UpdateCoverImage(IDirectoryService directoryService, ILogger logger, Chapter chapter, + string extension, Volume newVolume) + { + var existingCover = ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId) + extension; + var newCover = ImageService.GetChapterFormat(chapter.Id, newVolume.Id) + extension; + try + { + if (!chapter.CoverImageLocked) + { + // First rename existing cover + File.Copy(Path.Join(directoryService.CoverImageDirectory, existingCover), Path.Join(directoryService.CoverImageDirectory, newCover)); + chapter.CoverImage = newCover; + } + } catch (Exception ex) + { + logger.LogError(ex, "Unable to rename {OldCover} to {NewCover}, this cover will need manual refresh", existingCover, newCover); + } + } +} diff --git a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs b/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs index c9c173eea..4e22abfb8 100644 --- a/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs +++ b/API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs @@ -3,7 +3,9 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Entities; +using API.Extensions; using API.Helpers.Builders; +using API.Services; using API.Services.Tasks.Scanner.Parser; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; @@ -21,6 +23,7 @@ public class UserProgressCsvRecord public float MinNumber { get; set; } public int SeriesId { get; set; } public int VolumeId { get; set; } + public int ProgressId { get; set; } } /// @@ -28,7 +31,7 @@ public class UserProgressCsvRecord /// public static class MigrateMixedSpecials { - public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) { if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateMixedSpecials")) { @@ -39,13 +42,13 @@ public static class MigrateMixedSpecials "Running ManualMigrateMixedSpecials migration - Please be patient, this may take some time. This is not an error"); // First, group all the progresses into different series - // Get each series and move the specials from old volume to the new Volume() - // Create a new progress event from existing and store the Id of existing progress event to delete it - // Save per series + var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var extension = settings.EncodeMediaAs.GetExtension(); + var progress = await dataContext.AppUserProgresses .Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord { @@ -56,10 +59,12 @@ public static class MigrateMixedSpecials Number = c.Number, MinNumber = c.MinNumber, SeriesId = p.SeriesId, - VolumeId = p.VolumeId + VolumeId = p.VolumeId, + ProgressId = p.Id }) .Where(d => d.IsSpecial || d.Number == "0") - .Join(dataContext.Volume, d => d.VolumeId, v => v.Id, (d, v) => new + .Join(dataContext.Volume, d => d.VolumeId, v => v.Id, + (d, v) => new { ProgressRecord = d, Volume = v @@ -68,18 +73,19 @@ public static class MigrateMixedSpecials .ToListAsync(); // First, group all the progresses into different series - logger.LogCritical("Migrating {Count} progress events to new Volume structure - This may take over 10 minutes depending on size of DB. Please wait", progress.Count); + logger.LogCritical("Migrating {Count} progress events to new Volume structure for Specials - This may take over 10 minutes depending on size of DB. Please wait", progress.Count); var progressesGroupedBySeries = progress.GroupBy(p => p.ProgressRecord.SeriesId); foreach (var seriesGroup in progressesGroupedBySeries) { // Get each series and move the specials from the old volume to the new Volume var seriesId = seriesGroup.Key; + + // Handle All Specials var specialsInSeries = seriesGroup .Where(p => p.ProgressRecord.IsSpecial) .ToList(); - // Get distinct Volumes by Id. For each one, create it then create the progress events var distinctVolumes = specialsInSeries.DistinctBy(d => d.Volume.Id); foreach (var distinctVolume in distinctVolumes) @@ -90,29 +96,72 @@ public static class MigrateMixedSpecials var newVolume = new VolumeBuilder(Parser.SpecialVolume) .WithSeriesId(seriesId) - .WithChapters(chapters) + .WithCreated(distinctVolume.Volume.Created) + .WithLastModified(distinctVolume.Volume.LastModified) .Build(); + + newVolume.Pages = chapters.Sum(c => c.Pages); + newVolume.WordCount = chapters.Sum(c => c.WordCount); + newVolume.MinHoursToRead = chapters.Sum(c => c.MinHoursToRead); + newVolume.MaxHoursToRead = chapters.Sum(c => c.MaxHoursToRead); + newVolume.AvgHoursToRead = chapters.Sum(c => c.AvgHoursToRead); + dataContext.Volume.Add(newVolume); await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId // Migrate the progress event to the new volume - distinctVolume.ProgressRecord.VolumeId = newVolume.Id; + var oldVolumeProgresses = await dataContext.AppUserProgresses + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + foreach (var oldProgress in oldVolumeProgresses) + { + oldProgress.VolumeId = newVolume.Id; + } logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", chapters.Count, distinctVolume.Volume.Id, newVolume.Id); - // Move the special chapters from the old volume to the new Volume - var specialChapters = await dataContext.Chapter - .Where(c => c.VolumeId == distinctVolume.ProgressRecord.VolumeId && c.IsSpecial) - .ToListAsync(); - foreach (var specialChapter in specialChapters) + // Move the special chapters from the old volume to the new Volume + foreach (var specialChapter in chapters) { // Update the VolumeId on the existing progress event specialChapter.VolumeId = newVolume.Id; + + //UpdateCoverImage(directoryService, logger, specialChapter, extension, newVolume); } + + var oldVolumeBookmarks = await dataContext.AppUserBookmark + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + logger.LogInformation("Moving {Count} existing Bookmarks from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + oldVolumeBookmarks.Count, distinctVolume.Volume.Id, newVolume.Id); + foreach (var bookmark in oldVolumeBookmarks) + { + bookmark.VolumeId = newVolume.Id; + } + + + var oldVolumePersonalToC = await dataContext.AppUserTableOfContent + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + oldVolumePersonalToC.Count, distinctVolume.Volume.Id, newVolume.Id); + foreach (var pToc in oldVolumePersonalToC) + { + pToc.VolumeId = newVolume.Id; + } + + var oldVolumeReadingListItems = await dataContext.ReadingListItem + .Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync(); + logger.LogInformation("Moving {Count} existing Personal ToC from Volume Id {OldVolumeId} to New Volume {NewVolumeId}", + oldVolumeReadingListItems.Count, distinctVolume.Volume.Id, newVolume.Id); + foreach (var readingListItem in oldVolumeReadingListItems) + { + readingListItem.VolumeId = newVolume.Id; + } + await dataContext.SaveChangesAsync(); } + + } // Save changes after processing all series @@ -121,10 +170,6 @@ public static class MigrateMixedSpecials await dataContext.SaveChangesAsync(); } - // Update all Volumes with Name as "0" -> Special - logger.LogCritical("Updating all Volumes with Name 0 to SpecialNumber"); - - dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() { @@ -137,4 +182,25 @@ public static class MigrateMixedSpecials logger.LogCritical( "Running ManualMigrateMixedSpecials migration - Completed. This is not an error"); } + + private static void UpdateCoverImage(IDirectoryService directoryService, ILogger logger, Chapter specialChapter, + string extension, Volume newVolume) + { + // We need to migrate cover images as well + var existingCover = ImageService.GetChapterFormat(specialChapter.Id, specialChapter.VolumeId) + extension; + var newCover = ImageService.GetChapterFormat(specialChapter.Id, newVolume.Id) + extension; + try + { + + if (!specialChapter.CoverImageLocked) + { + // First rename existing cover + File.Copy(Path.Join(directoryService.CoverImageDirectory, existingCover), Path.Join(directoryService.CoverImageDirectory, newCover)); + specialChapter.CoverImage = newCover; + } + } catch (Exception ex) + { + logger.LogError(ex, "Unable to rename {OldCover} to {NewCover}, this cover will need manual refresh", existingCover, newCover); + } + } } diff --git a/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs new file mode 100644 index 000000000..7204bd0d3 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateCollectionTagToUserCollections.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Extensions.QueryExtensions; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 refactored User Collections +/// +public static class MigrateCollectionTagToUserCollections +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateCollectionTagToUserCollections")) + { + return; + } + + logger.LogCritical( + "Running MigrateCollectionTagToUserCollections migration - Please be patient, this may take some time. This is not an error"); + + // Find the first user that is an admin + var defaultAdmin = await unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); + if (defaultAdmin == null) + { + await CompleteMigration(dataContext, logger); + return; + } + + // For all collectionTags, move them over to said user + var existingCollections = await dataContext.CollectionTag + .OrderBy(c => c.NormalizedTitle) + .Includes(CollectionTagIncludes.SeriesMetadataWithSeries) + .ToListAsync(); + foreach (var existingCollectionTag in existingCollections) + { + var collection = new AppUserCollection() + { + Title = existingCollectionTag.Title, + NormalizedTitle = existingCollectionTag.Title.Normalize(), + CoverImage = existingCollectionTag.CoverImage, + CoverImageLocked = existingCollectionTag.CoverImageLocked, + Promoted = existingCollectionTag.Promoted, + AgeRating = AgeRating.Unknown, + Summary = existingCollectionTag.Summary, + Items = existingCollectionTag.SeriesMetadatas.Select(s => s.Series).ToList() + }; + + collection.AgeRating = await unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(collection.Items.Select(s => s.Id)); + defaultAdmin.Collections.Add(collection); + } + unitOfWork.UserRepository.Update(defaultAdmin); + + await unitOfWork.CommitAsync(); + + await CompleteMigration(dataContext, logger); + } + + private static async Task CompleteMigration(DataContext dataContext, ILogger logger) + { + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateCollectionTagToUserCollections", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateCollectionTagToUserCollections migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/MigrateMangaFilePath.cs new file mode 100644 index 000000000..ccf9aa773 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateMangaFilePath.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn. +/// +public static class MigrateMangaFilePath +{ + public static async Task Migrate(DataContext dataContext, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateMangaFilePath")) + { + return; + } + + logger.LogCritical( + "Running MigrateMangaFilePath migration - Please be patient, this may take some time. This is not an error"); + + + foreach(var file in dataContext.MangaFile) + { + file.FilePath = Parser.NormalizePath(file.FilePath); + } + + await dataContext.SaveChangesAsync(); + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateMangaFilePath", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateMangaFilePath migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/MigrateProgressExport.cs b/API/Data/ManualMigrations/MigrateProgressExport.cs new file mode 100644 index 000000000..2482939c0 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateProgressExport.cs @@ -0,0 +1,123 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Services; +using CsvHelper; +using CsvHelper.Configuration.Attributes; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public class ProgressExport +{ + [Name("Library Id")] + public int LibraryId { get; set; } + + [Name("Library Name")] + public string LibraryName { get; set; } + + [Name("Series Name")] + public string SeriesName { get; set; } + + [Name("Volume Number")] + public string VolumeRange { get; set; } + + [Name("Volume LookupName")] + public string VolumeLookupName { get; set; } + + [Name("Chapter Number")] + public string ChapterRange { get; set; } + + [Name("FileName")] + public string MangaFileName { get; set; } + + [Name("FilePath")] + public string MangaFilePath { get; set; } + + [Name("AppUser Name")] + public string AppUserName { get; set; } + + [Name("AppUser Id")] + public int AppUserId { get; set; } + + [Name("Pages Read")] + public int PagesRead { get; set; } + + [Name("BookScrollId")] + public string BookScrollId { get; set; } + + [Name("Progress Created")] + public DateTime Created { get; set; } + + [Name("Progress LastModified")] + public DateTime LastModified { get; set; } +} + +/// +/// v0.8.0 - Progress is extracted and saved in a csv +/// +public static class MigrateProgressExport +{ + public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger logger) + { + try + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateProgressExport")) + { + return; + } + + logger.LogCritical( + "Running MigrateProgressExport migration - Please be patient, this may take some time. This is not an error"); + + var data = await dataContext.AppUserProgresses + .Join(dataContext.Series, progress => progress.SeriesId, series => series.Id, (progress, series) => new { progress, series }) + .Join(dataContext.Volume, ps => ps.progress.VolumeId, volume => volume.Id, (ps, volume) => new { ps.progress, ps.series, volume }) + .Join(dataContext.Chapter, psv => psv.progress.ChapterId, chapter => chapter.Id, (psv, chapter) => new { psv.progress, psv.series, psv.volume, chapter }) + .Join(dataContext.MangaFile, psvc => psvc.chapter.Id, mangaFile => mangaFile.ChapterId, (psvc, mangaFile) => new { psvc.progress, psvc.series, psvc.volume, psvc.chapter, mangaFile }) + .Join(dataContext.AppUser, psvcm => psvcm.progress.AppUserId, appUser => appUser.Id, (psvcm, appUser) => new + { + LibraryId = psvcm.series.LibraryId, + LibraryName = psvcm.series.Library.Name, + SeriesName = psvcm.series.Name, + VolumeRange = psvcm.volume.MinNumber + "-" + psvcm.volume.MaxNumber, + VolumeLookupName = psvcm.volume.Name, + ChapterRange = psvcm.chapter.Range, + MangaFileName = psvcm.mangaFile.FileName, + MangaFilePath = psvcm.mangaFile.FilePath, + AppUserName = appUser.UserName, + AppUserId = appUser.Id, + PagesRead = psvcm.progress.PagesRead, + BookScrollId = psvcm.progress.BookScrollId, + ProgressCreated = psvcm.progress.Created, + ProgressLastModified = psvcm.progress.LastModified + }).ToListAsync(); + + + // Write the mapped data to a CSV file + await using var writer = new StreamWriter(Path.Join(directoryService.ConfigDirectory, "progress_export.csv")); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(data); + + logger.LogCritical( + "Running MigrateProgressExport migration - Completed. This is not an error"); + } + catch (Exception ex) + { + // On new installs, the db isn't setup yet, so this has nothing to do + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateProgressExport", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + } +} diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/MigrateWantToReadExport.cs index eb788f1e8..95a86c370 100644 --- a/API/Data/ManualMigrations/MigrateWantToReadExport.cs +++ b/API/Data/ManualMigrations/MigrateWantToReadExport.cs @@ -20,6 +20,7 @@ public static class MigrateWantToReadExport { try { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadExport")) { return; diff --git a/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs b/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs new file mode 100644 index 000000000..ee182676d --- /dev/null +++ b/API/Data/Migrations/20240321173812_UserMalToken.Designer.cs @@ -0,0 +1,2904 @@ +// +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("20240321173812_UserMalToken")] + partial class UserMalToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.3"); + + 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("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .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("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + 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.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .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.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .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.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + 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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + 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("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + 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.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + 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.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .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", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .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("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240321173812_UserMalToken.cs b/API/Data/Migrations/20240321173812_UserMalToken.cs new file mode 100644 index 000000000..f1b1d3caa --- /dev/null +++ b/API/Data/Migrations/20240321173812_UserMalToken.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class UserMalToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MalAccessToken", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MalUserName", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MalAccessToken", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "MalUserName", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/20240328130057_PdfSettings.Designer.cs b/API/Data/Migrations/20240328130057_PdfSettings.Designer.cs new file mode 100644 index 000000000..cba2d534f --- /dev/null +++ b/API/Data/Migrations/20240328130057_PdfSettings.Designer.cs @@ -0,0 +1,2916 @@ +// +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("20240328130057_PdfSettings")] + partial class PdfSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.3"); + + 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("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .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("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + 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.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .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.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .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.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + 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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + 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("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + 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.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + 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.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .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", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .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("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240328130057_PdfSettings.cs b/API/Data/Migrations/20240328130057_PdfSettings.cs new file mode 100644 index 000000000..699875968 --- /dev/null +++ b/API/Data/Migrations/20240328130057_PdfSettings.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PdfSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PdfLayoutMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PdfScrollMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PdfSpreadMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PdfTheme", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PdfLayoutMode", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "PdfScrollMode", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "PdfSpreadMode", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "PdfTheme", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs b/API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs new file mode 100644 index 000000000..5527a0fbb --- /dev/null +++ b/API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs @@ -0,0 +1,3019 @@ +// +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("20240331172900_UserBasedCollections")] + partial class UserBasedCollections + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.3"); + + 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("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .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("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .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.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + 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("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + 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.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", 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("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .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.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .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.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + 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("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + 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("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .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.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + 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.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + 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("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + 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.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .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", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .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.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + 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.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + 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("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .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("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20240331172900_UserBasedCollections.cs b/API/Data/Migrations/20240331172900_UserBasedCollections.cs new file mode 100644 index 000000000..c5a376bd8 --- /dev/null +++ b/API/Data/Migrations/20240331172900_UserBasedCollections.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class UserBasedCollections : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserCollection", + 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), + Summary = table.Column(type: "TEXT", nullable: true), + Promoted = table.Column(type: "INTEGER", nullable: false), + CoverImage = table.Column(type: "TEXT", nullable: true), + CoverImageLocked = table.Column(type: "INTEGER", nullable: false), + AgeRating = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false), + LastSyncUtc = table.Column(type: "TEXT", nullable: false), + Source = table.Column(type: "INTEGER", nullable: false), + SourceUrl = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserCollection", x => x.Id); + table.ForeignKey( + name: "FK_AppUserCollection_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppUserCollectionSeries", + columns: table => new + { + CollectionsId = table.Column(type: "INTEGER", nullable: false), + ItemsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserCollectionSeries", x => new { x.CollectionsId, x.ItemsId }); + table.ForeignKey( + name: "FK_AppUserCollectionSeries_AppUserCollection_CollectionsId", + column: x => x.CollectionsId, + principalTable: "AppUserCollection", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppUserCollectionSeries_Series_ItemsId", + column: x => x.ItemsId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserCollection_AppUserId", + table: "AppUserCollection", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserCollectionSeries_ItemsId", + table: "AppUserCollectionSeries", + column: "ItemsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserCollectionSeries"); + + migrationBuilder.DropTable( + name: "AppUserCollection"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 7a251ffbd..24c5e35da 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -97,6 +97,12 @@ namespace API.Data.Migrations b.Property("LockoutEnd") .HasColumnType("TEXT"); + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + b.Property("NormalizedEmail") .HasMaxLength(256) .HasColumnType("TEXT"); @@ -183,6 +189,66 @@ namespace API.Data.Migrations b.ToTable("AppUserBookmark"); }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => { b.Property("Id") @@ -349,6 +415,18 @@ namespace API.Data.Migrations b.Property("PageSplitOption") .HasColumnType("INTEGER"); + b.Property("PdfLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + b.Property("PromptForDownloadSize") .HasColumnType("INTEGER"); @@ -1900,6 +1978,21 @@ namespace API.Data.Migrations b.ToTable("Volume"); }); + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + modelBuilder.Entity("AppUserLibrary", b => { b.Property("AppUsersId") @@ -2160,6 +2253,17 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2608,6 +2712,21 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("AppUserLibrary", b => { b.HasOne("API.Entities.AppUser", null) @@ -2818,6 +2937,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("Collections"); + b.Navigation("DashboardStreams"); b.Navigation("Devices"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 1e9aec77b..3b065f2e0 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -5,8 +5,10 @@ using System.Text; using System.Threading.Tasks; using API.Data.ManualMigrations; using API.DTOs; +using API.DTOs.Progress; using API.Entities; using API.Entities.Enums; +using API.Extensions.QueryExtensions; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -36,6 +38,7 @@ public interface IAppUserProgressRepository Task GetLatestProgressForSeries(int seriesId, int userId); Task GetFirstProgressForSeries(int seriesId, int userId); Task UpdateAllProgressThatAreMoreThanChapterPages(); + Task> GetUserProgressForChapter(int chapterId, int userId = 0); } #nullable disable public class AppUserProgressRepository : IAppUserProgressRepository @@ -233,6 +236,33 @@ public class AppUserProgressRepository : IAppUserProgressRepository await _context.Database.ExecuteSqlRawAsync(batchSql); } + /// + /// + /// + /// + /// If 0, will pull all records + /// + public async Task> GetUserProgressForChapter(int chapterId, int userId = 0) + { + return await _context.AppUserProgresses + .WhereIf(userId > 0, p => p.AppUserId == userId) + .Where(p => p.ChapterId == chapterId) + .Include(p => p.AppUser) + .Select(p => new FullProgressDto() + { + AppUserId = p.AppUserId, + ChapterId = p.ChapterId, + PagesRead = p.PagesRead, + Id = p.Id, + Created = p.Created, + CreatedUtc = p.CreatedUtc, + LastModified = p.LastModified, + LastModifiedUtc = p.LastModifiedUtc, + UserName = p.AppUser.UserName + }) + .ToListAsync(); + } + #nullable enable public async Task GetUserProgressAsync(int chapterId, int userId) { diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a7c942734..5a45c117e 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -3,43 +3,60 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data.Misc; -using API.DTOs.CollectionTags; +using API.DTOs.Collection; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Extensions.QueryExtensions.Filtering; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable + [Flags] public enum CollectionTagIncludes { None = 1, SeriesMetadata = 2, + SeriesMetadataWithSeries = 4 +} + +[Flags] +public enum CollectionIncludes +{ + None = 1, + Series = 2, } public interface ICollectionTagRepository { - void Add(CollectionTag tag); - void Remove(CollectionTag tag); - Task> GetAllTagDtosAsync(); - Task> SearchTagDtosAsync(string searchQuery, int userId); + void Remove(AppUserCollection tag); Task GetCoverImageAsync(int collectionTagId); - Task> GetAllPromotedTagDtosAsync(int userId); - Task GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None); - void Update(CollectionTag tag); - Task RemoveTagsWithoutSeries(); - Task> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None); + Task GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None); + void Update(AppUserCollection tag); + Task RemoveCollectionsWithoutSeries(); + + Task> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None); + /// + /// Returns all of the user's collections with the option of other user's promoted + /// + /// + /// + /// + Task> GetCollectionDtosAsync(int userId, bool includePromoted = false); + Task> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false); - Task> GetAllTagsByNamesAsync(IEnumerable normalizedTitles, - CollectionTagIncludes includes = CollectionTagIncludes.None); Task> GetAllCoverImagesAsync(); - Task TagExists(string title); - Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); + Task CollectionExists(string title, int userId); + Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task> GetRandomCoverImagesAsync(int collectionId); + Task> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None); + Task UpdateCollectionAgeRating(AppUserCollection tag); + Task> GetCollectionsByIds(IEnumerable tags, CollectionIncludes includes = CollectionIncludes.None); } public class CollectionTagRepository : ICollectionTagRepository { @@ -52,17 +69,12 @@ public class CollectionTagRepository : ICollectionTagRepository _mapper = mapper; } - public void Add(CollectionTag tag) + public void Remove(AppUserCollection tag) { - _context.CollectionTag.Add(tag); + _context.AppUserCollection.Remove(tag); } - public void Remove(CollectionTag tag) - { - _context.CollectionTag.Remove(tag); - } - - public void Update(CollectionTag tag) + public void Update(AppUserCollection tag) { _context.Entry(tag).State = EntityState.Modified; } @@ -70,38 +82,53 @@ public class CollectionTagRepository : ICollectionTagRepository /// /// Removes any collection tags without any series /// - public async Task RemoveTagsWithoutSeries() + public async Task RemoveCollectionsWithoutSeries() { - var tagsToDelete = await _context.CollectionTag - .Include(c => c.SeriesMetadatas) - .Where(c => c.SeriesMetadatas.Count == 0) + var tagsToDelete = await _context.AppUserCollection + .Include(c => c.Items) + .Where(c => c.Items.Count == 0) .AsSplitQuery() .ToListAsync(); + _context.RemoveRange(tagsToDelete); return await _context.SaveChangesAsync(); } - public async Task> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None) + public async Task> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None) { - return await _context.CollectionTag + return await _context.AppUserCollection .OrderBy(c => c.NormalizedTitle) .Includes(includes) .ToListAsync(); } - public async Task> GetAllTagsByNamesAsync(IEnumerable normalizedTitles, CollectionTagIncludes includes = CollectionTagIncludes.None) + public async Task> GetCollectionDtosAsync(int userId, bool includePromoted = false) { - return await _context.CollectionTag - .Where(c => normalizedTitles.Contains(c.NormalizedTitle)) - .OrderBy(c => c.NormalizedTitle) - .Includes(includes) + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.AppUserCollection + .Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted)) + .WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating) + .OrderBy(uc => uc.Title) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.AppUserCollection + .Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted)) + .Where(uc => uc.Items.Any(s => s.Id == seriesId)) + .WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating) + .OrderBy(uc => uc.Title) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } public async Task GetCoverImageAsync(int collectionTagId) { - return await _context.CollectionTag + return await _context.AppUserCollection .Where(c => c.Id == collectionTagId) .Select(c => c.CoverImage) .SingleOrDefaultAsync(); @@ -109,23 +136,30 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetAllCoverImagesAsync() { - return (await _context.CollectionTag + return await _context.AppUserCollection .Select(t => t.CoverImage) .Where(t => !string.IsNullOrEmpty(t)) - .ToListAsync())!; + .ToListAsync(); } - public async Task TagExists(string title) + /// + /// If any tag exists for that given user's collections + /// + /// + /// + /// + public async Task CollectionExists(string title, int userId) { var normalized = title.ToNormalized(); - return await _context.CollectionTag + return await _context.AppUserCollection + .Where(uc => uc.AppUserId == userId) .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) + public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); - return await _context.CollectionTag + return await _context.AppUserCollection .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension)) .ToListAsync(); } @@ -133,44 +167,50 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetRandomCoverImagesAsync(int collectionId) { var random = new Random(); - var data = await _context.CollectionTag + var data = await _context.AppUserCollection .Where(t => t.Id == collectionId) - .SelectMany(t => t.SeriesMetadatas) - .Select(sm => sm.Series.CoverImage) + .SelectMany(uc => uc.Items.Select(series => series.CoverImage)) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); + return data .OrderBy(_ => random.Next()) .Take(4) .ToList(); } - public async Task> GetAllTagDtosAsync() + public async Task> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None) { - - return await _context.CollectionTag - .OrderBy(c => c.NormalizedTitle) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) + return await _context.AppUserCollection + .Where(c => c.AppUserId == userId) + .Includes(includes) .ToListAsync(); } - public async Task> GetAllPromotedTagDtosAsync(int userId) + public async Task UpdateCollectionAgeRating(AppUserCollection tag) { - var userRating = await GetUserAgeRestriction(userId); - return await _context.CollectionTag - .Where(c => c.Promoted) - .RestrictAgainstAgeRestriction(userRating) - .OrderBy(c => c.NormalizedTitle) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) + var maxAgeRating = await _context.AppUserCollection + .Where(t => t.Id == tag.Id) + .SelectMany(uc => uc.Items.Select(s => s.Metadata)) + .Select(sm => sm.AgeRating) + .MaxAsync(); + tag.AgeRating = maxAgeRating; + await _context.SaveChangesAsync(); + } + + public async Task> GetCollectionsByIds(IEnumerable tags, CollectionIncludes includes = CollectionIncludes.None) + { + return await _context.AppUserCollection + .Where(c => tags.Contains(c.Id)) + .Includes(includes) + .AsSplitQuery() .ToListAsync(); } - public async Task GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None) + public async Task GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None) { - return await _context.CollectionTag + return await _context.AppUserCollection .Where(c => c.Id == tagId) .Includes(includes) .AsSplitQuery() @@ -190,16 +230,12 @@ public class CollectionTagRepository : ICollectionTagRepository .SingleAsync(); } - public async Task> SearchTagDtosAsync(string searchQuery, int userId) + public async Task> SearchTagDtosAsync(string searchQuery, int userId) { var userRating = await GetUserAgeRestriction(userId); - return await _context.CollectionTag - .Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%") - || EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%")) - .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.NormalizedTitle) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) + return await _context.AppUserCollection + .Search(searchQuery, userId, userRating) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 9d6d9f694..651a6c642 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -7,6 +7,7 @@ using API.Constants; using API.Data.Misc; using API.Data.Scanner; using API.DTOs; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Filtering; @@ -141,7 +142,7 @@ public interface ISeriesRepository MangaFormat format); Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); Task>> GetFolderPathMap(int libraryId); - Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); + Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); /// /// This is only used for /// @@ -342,10 +343,7 @@ public class SeriesRepository : ISeriesRepository return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync(); } - return new List() - { - libraryId - }; + return [libraryId]; } public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery) @@ -362,12 +360,9 @@ public class SeriesRepository : ISeriesRepository .ToList(); result.Libraries = await _context.Library - .Where(l => libraryIds.Contains(l.Id)) - .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) - .IsRestricted(QueryContext.Search) - .AsSplitQuery() - .OrderBy(l => l.Name.ToLower()) + .Search(searchQuery, userId, libraryIds) .Take(maxRecords) + .OrderBy(l => l.Name.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -419,53 +414,33 @@ public class SeriesRepository : ISeriesRepository result.ReadingLists = await _context.ReadingList - .Where(rl => rl.AppUserId == userId || rl.Promoted) - .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) - .RestrictAgainstAgeRestriction(userRating) - .AsSplitQuery() - .OrderBy(r => r.NormalizedTitle) + .Search(searchQuery, userId, userRating) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Collections = await _context.CollectionTag - .Where(c => (EF.Functions.Like(c.Title, $"%{searchQuery}%")) - || (EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%"))) - .Where(c => c.Promoted || isAdmin) - .RestrictAgainstAgeRestriction(userRating) - .OrderBy(s => s.NormalizedTitle) - .AsSplitQuery() + result.Collections = await _context.AppUserCollection + .Search(searchQuery, userId, userRating) .Take(maxRecords) .OrderBy(c => c.NormalizedTitle) - .ProjectTo(_mapper.ConfigurationProvider) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Persons = await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%"))) - .AsSplitQuery() - .Distinct() - .OrderBy(p => p.NormalizedName) + .SearchPeople(searchQuery, seriesIds) .Take(maxRecords) + .OrderBy(t => t.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Genres = await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) - .AsSplitQuery() - .Distinct() - .OrderBy(t => t.NormalizedTitle) + .SearchGenres(searchQuery, seriesIds) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); result.Tags = await _context.SeriesMetadata - .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) - .AsSplitQuery() - .Distinct() - .OrderBy(t => t.NormalizedTitle) + .SearchTags(searchQuery, seriesIds) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -740,6 +715,7 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); } + public async Task AddSeriesModifiers(int userId, IList series) { var userProgress = await _context.AppUserProgresses @@ -968,6 +944,20 @@ public class SeriesRepository : ISeriesRepository out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter); + IList collectionSeries = []; + if (hasCollectionTagFilter) + { + collectionSeries = await _context.AppUserCollection + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(uc => filter.CollectionTags.Contains(uc.Id)) + .SelectMany(uc => uc.Items) + .RestrictAgainstAgeRestriction(userRating) + .Select(s => s.Id) + .Distinct() + .ToListAsync(); + } + + var query = _context.Series .AsNoTracking() // This new style can handle any filterComparision coming from the user @@ -979,7 +969,7 @@ public class SeriesRepository : ISeriesRepository .HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating) .HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus) .HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags) - .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags) + .HasCollectionTags(hasCollectionTagFilter, FilterComparison.Contains, filter.Tags, collectionSeries) .HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres) .HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!) .HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0) @@ -1045,6 +1035,8 @@ public class SeriesRepository : ISeriesRepository .Select(u => u.CollapseSeriesRelationships) .SingleOrDefaultAsync(); + + query ??= _context.Series .AsNoTracking(); @@ -1062,6 +1054,9 @@ public class SeriesRepository : ISeriesRepository query = ApplyWantToReadFilter(filter, query, userId); + query = await ApplyCollectionFilter(filter, query, userId, userRating); + + query = BuildFilterQuery(userId, filter, query); @@ -1078,6 +1073,50 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery(), filter.LimitTo); } + private async Task> ApplyCollectionFilter(FilterV2Dto filter, IQueryable query, int userId, AgeRestriction userRating) + { + var collectionStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.CollectionTags); + if (collectionStmt == null) return query; + + var value = (IList) FilterFieldValueConverter.ConvertValue(collectionStmt.Field, collectionStmt.Value); + + if (value.Count == 0) + { + return query; + } + + var collectionSeries = await _context.AppUserCollection + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(uc => value.Contains(uc.Id)) + .SelectMany(uc => uc.Items) + .RestrictAgainstAgeRestriction(userRating) + .Select(s => s.Id) + .Distinct() + .ToListAsync(); + + if (collectionStmt.Comparison != FilterComparison.MustContains) + return query.HasCollectionTags(true, collectionStmt.Comparison, value, collectionSeries); + + var collectionSeriesTasks = value.Select(async collectionId => + { + return await _context.AppUserCollection + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(uc => uc.Id == collectionId) + .SelectMany(uc => uc.Items) + .RestrictAgainstAgeRestriction(userRating) + .Select(s => s.Id) + .ToListAsync(); + }); + + var collectionSeriesLists = await Task.WhenAll(collectionSeriesTasks); + + // Find the common series among all collections + var commonSeries = collectionSeriesLists.Aggregate((common, next) => common.Intersect(next).ToList()); + + // Filter the original query based on the common series + return query.Where(s => commonSeries.Contains(s.Id)); + } + private IQueryable ApplyWantToReadFilter(FilterV2Dto filter, IQueryable query, int userId) { var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead); @@ -1175,7 +1214,6 @@ public class SeriesRepository : ISeriesRepository FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList) value), FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId), FilterField.Tags => query.HasTags(true, statement.Comparison, (IList) value), - FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList) value), FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList) value), @@ -1190,6 +1228,9 @@ public class SeriesRepository : ISeriesRepository FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value), FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList) value), + FilterField.CollectionTags => + // This is handled in the code before this as it's handled in a more general, combined manner + query, FilterField.Libraries => // This is handled in the code before this as it's handled in a more general, combined manner query, @@ -1241,7 +1282,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesMetadata(int seriesId) { - var metadataDto = await _context.SeriesMetadata + return await _context.SeriesMetadata .Where(metadata => metadata.SeriesId == seriesId) .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) @@ -1250,42 +1291,20 @@ public class SeriesRepository : ISeriesRepository .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() - .OrderBy(t => t.Title.ToLower()) - .AsSplitQuery() - .ToListAsync(); - } - - 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)) - .AsSplitQuery() - .AsNoTracking() - .Select(library => library.Id) - .ToList(); + var userLibraries = _context.Library.GetUserLibraries(userId); - var query = _context.CollectionTag + var query = _context.AppUserCollection .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))) + .Include(c => c.Items) + .SelectMany(c => c.Items.Where(s => userLibraries.Contains(s.LibraryId))) .OrderBy(s => s.LibraryId) .ThenBy(s => s.SortName.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() - .AsNoTracking(); + .AsSplitQuery(); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } @@ -2072,18 +2091,20 @@ public class SeriesRepository : ISeriesRepository } /// - /// Returns the highest Age Rating for a list of Series + /// Returns the highest Age Rating for a list of Series. Defaults to /// /// /// - public async Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds) + public async Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds) { - return await _context.Series + var ret = await _context.Series .Where(s => seriesIds.Contains(s.Id)) .Include(s => s.Metadata) .Select(s => s.Metadata.AgeRating) .OrderBy(s => s) .LastOrDefaultAsync(); + if (ret == null) return AgeRating.Unknown; + return ret; } /// diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 9515a3f11..07723bf1b 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -38,7 +38,8 @@ public enum AppUserIncludes SmartFilters = 1024, DashboardStreams = 2048, SideNavStreams = 4096, - ExternalSources = 8192 // 2^13 + ExternalSources = 8192, + Collections = 16384 // 2^14 } public interface IUserRepository @@ -57,6 +58,7 @@ public interface IUserRepository Task> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true); Task> GetAdminUsersAsync(); Task IsUserAdminAsync(AppUser? user); + Task> GetRoles(int userId); Task GetUserRatingAsync(int seriesId, int userId); Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); Task GetPreferencesAsync(string username); @@ -78,7 +80,7 @@ public interface IUserRepository Task HasAccessToSeries(int userId, int seriesId); Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByConfirmationToken(string token); - Task GetDefaultAdminUser(); + Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None); Task> GetSeriesWithRatings(int userId); Task> GetSeriesWithReviews(int userId); Task HasHoldOnSeries(int userId, int seriesId); @@ -298,11 +300,13 @@ public class UserRepository : IUserRepository /// Returns the first admin account created /// /// - public async Task GetDefaultAdminUser() + public async Task GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None) { - return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)) + return await _context.AppUser + .Includes(includes) + .Where(u => u.UserRoles.Any(r => r.Role.Name == PolicyConstants.AdminRole)) .OrderBy(u => u.Created) - .First(); + .FirstAsync(); } public async Task> GetSeriesWithRatings(int userId) @@ -482,7 +486,7 @@ public class UserRepository : IUserRepository public async Task> GetAdminUsersAsync() { - return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); + return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)).OrderBy(u => u.CreatedUtc); } public async Task IsUserAdminAsync(AppUser? user) @@ -491,6 +495,14 @@ public class UserRepository : IUserRepository return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); } + public async Task> GetRoles(int userId) + { + var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (user == null || _userManager == null) return ArraySegment.Empty; // userManager is null on Unit Tests only + + return await _userManager.GetRolesAsync(user); + } + public async Task GetUserRatingAsync(int seriesId, int userId) { return await _context.AppUserRating diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index f87531e8a..2e6f42d3d 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -29,6 +29,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// public ICollection ReadingLists { get; set; } = null!; /// + /// Collections associated with this user + /// + public ICollection Collections { get; set; } = null!; + /// /// A list of Series the user want's to read /// public ICollection WantToRead { get; set; } = null!; @@ -63,6 +67,15 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// Requires Kavita+ Subscription public string? AniListAccessToken { get; set; } + /// + /// The Username of the MAL user + /// + public string? MalUserName { get; set; } + /// + /// The Client ID for the user's MAL account. User should create a client on MAL for this. + /// + public string? MalAccessToken { get; set; } + /// /// A list of Series the user doesn't want scrobbling for /// diff --git a/API/Entities/AppUserCollection.cs b/API/Entities/AppUserCollection.cs new file mode 100644 index 000000000..6ae0bca1c --- /dev/null +++ b/API/Entities/AppUserCollection.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; +using API.Entities.Interfaces; +using API.Services.Plus; + + +namespace API.Entities; + +/// +/// Represents a Collection of Series for a given User +/// +public class AppUserCollection : IEntityDate +{ + public int Id { get; set; } + public required string Title { get; set; } + /// + /// A normalized string used to check if the collection already exists in the DB + /// + public required string NormalizedTitle { get; set; } + public string? Summary { get; set; } + /// + /// Reading lists that are promoted are only done by admins + /// + public bool Promoted { get; set; } + /// + /// Path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string? CoverImage { get; set; } + public bool CoverImageLocked { get; set; } + /// + /// The highest age rating from all Series within the collection + /// + public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + public ICollection Items { get; set; } = null!; + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } + + // Sync stuff for Kavita+ + /// + /// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections) + /// + public DateTime LastSyncUtc { get; set; } + /// + /// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote + /// + public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita; + /// + /// For Non-Kavita sourced collections, the url to sync from + /// + public string? SourceUrl { get; set; } + + + // Relationship + public AppUser AppUser { get; set; } = null!; + public int AppUserId { get; set; } +} diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 640ecc1ea..09defda3f 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -7,6 +7,9 @@ namespace API.Entities; public class AppUserPreferences { public int Id { get; set; } + + #region MangaReader + /// /// Manga Reader Option: What direction should the next/prev page buttons go /// @@ -51,6 +54,11 @@ public class AppUserPreferences /// Manga Reader Option: Should swiping trigger pagination /// public bool SwipeToPaginate { get; set; } + + #endregion + + #region EpubReader + /// /// Book Reader Option: Override extra Margin /// @@ -75,17 +83,11 @@ public class AppUserPreferences /// Book Reader Option: What direction should the next/prev page buttons go /// public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight; - /// /// Book Reader Option: Defines the writing styles vertical/horizontal /// public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal; /// - /// UI Site Global Setting: The UI theme the user should use. - /// - /// Should default to Dark - public required SiteTheme Theme { get; set; } = Seed.DefaultThemes[0]; - /// /// Book Reader Option: The color theme to decorate the book contents /// /// Should default to Dark @@ -101,6 +103,37 @@ public class AppUserPreferences /// /// Defaults to false public bool BookReaderImmersiveMode { get; set; } = false; + #endregion + + #region PdfReader + + /// + /// PDF Reader: Theme of the Reader + /// + public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark; + /// + /// PDF Reader: Scroll mode of the reader + /// + public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical; + /// + /// PDF Reader: Layout Mode of the reader + /// + public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple; + /// + /// PDF Reader: Spread Mode of the reader + /// + public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None; + + + #endregion + + #region Global + + /// + /// UI Site Global Setting: The UI theme the user should use. + /// + /// Should default to Dark + public required SiteTheme Theme { get; set; } = Seed.DefaultThemes[0]; /// /// Global Site Option: If the UI should layout items as Cards or List items /// @@ -132,6 +165,8 @@ public class AppUserPreferences /// public string Locale { get; set; } + #endregion + public AppUser AppUser { get; set; } = null!; public int AppUserId { get; set; } } diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index c972af78a..edbd25aa7 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -7,7 +7,7 @@ namespace API.Entities; /// /// Represents the progress a single user has on a given Chapter. /// -public class AppUserProgress : IEntityDate +public class AppUserProgress { /// /// Id of Entity @@ -59,4 +59,10 @@ public class AppUserProgress : IEntityDate /// User this progress belongs to /// public int AppUserId { get; set; } + + public void MarkModified() + { + LastModified = DateTime.Now; + LastModifiedUtc = DateTime.UtcNow; + } } diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index 2594a9772..5370176de 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities.Metadata; +using API.Services.Plus; using Microsoft.EntityFrameworkCore; namespace API.Entities; @@ -7,6 +9,7 @@ namespace API.Entities; /// /// Represents a user entered field that is used as a tagging and grouping mechanism /// +[Obsolete("Use AppUserCollection instead")] [Index(nameof(Id), nameof(Promoted), IsUnique = true)] public class CollectionTag { @@ -41,6 +44,21 @@ public class CollectionTag public ICollection SeriesMetadatas { get; set; } = null!; + /// + /// Is this Collection tag managed by another system, like Kavita+ + /// + //public bool IsManaged { get; set; } = false; + + /// + /// The last time this Collection was Synchronized. Only applicable for Managed Tags. + /// + //public DateTime LastSynchronized { get; set; } + + /// + /// Who created this Collection (Kavita, or external services) + /// + //public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita; + /// /// Not Used due to not using concurrency update /// diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index c9e04238b..05f9de611 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -39,5 +39,4 @@ public enum LibraryType /// [Description("Generic")] Generic = 6, - } diff --git a/API/Entities/Enums/UserPreferences/PdfBookMode.cs b/API/Entities/Enums/UserPreferences/PdfBookMode.cs new file mode 100644 index 000000000..5946e17c5 --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfBookMode.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum PdfLayoutMode +{ + /// + /// Multiple pages render stacked (normal pdf experience) + /// + [Description("Multiple")] + Multiple = 0, + // [Description("Single")] + // Single = 1, + /// + /// A book mode where page turns are animated and layout is side-by-side + /// + [Description("Book")] + Book = 2, + // [Description("Infinite Scroll")] + // InfiniteScroll = 3 +} diff --git a/API/Entities/Enums/UserPreferences/PdfScrollMode.cs b/API/Entities/Enums/UserPreferences/PdfScrollMode.cs new file mode 100644 index 000000000..93cc5bd2e --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfScrollMode.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +/// +/// Enum values match PdfViewer's enums +/// +public enum PdfScrollMode +{ + [Description("Vertical")] + Vertical = 0, + [Description("Horizontal")] + Horizontal = 1, + // [Description("Wrapped")] + // Wrapped = 2, + /// + /// Single page view (tap to pagninate) + /// + [Description("Page")] + Page = 3 +} diff --git a/API/Entities/Enums/UserPreferences/PdfSpreadMode.cs b/API/Entities/Enums/UserPreferences/PdfSpreadMode.cs new file mode 100644 index 000000000..412239d4a --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfSpreadMode.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum PdfSpreadMode +{ + [Description("None")] + None = 0, + [Description("Odd")] + Odd = 1, + [Description("Even")] + Even = 2 +} diff --git a/API/Entities/Enums/UserPreferences/PdfTheme.cs b/API/Entities/Enums/UserPreferences/PdfTheme.cs new file mode 100644 index 000000000..0efe1dfde --- /dev/null +++ b/API/Entities/Enums/UserPreferences/PdfTheme.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace API.Entities.Enums.UserPreferences; + +public enum PdfTheme +{ + [Description("Dark")] + Dark = 0, + [Description("Light")] + Light = 1 +} diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index ac9cd15b9..a2a7f7722 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -14,6 +14,7 @@ public class SeriesMetadata : IHasConcurrencyToken public string Summary { get; set; } = string.Empty; + [Obsolete("Use AppUserCollection instead")] public ICollection CollectionTags { get; set; } = new List(); public ICollection Genres { get; set; } = new List(); diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 4f6a23115..d42d47507 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Extensions; namespace API.Entities; @@ -105,6 +106,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate public ICollection Ratings { get; set; } = null!; public ICollection Progress { get; set; } = null!; + public ICollection Collections { get; set; } = null!; /// /// Relations to other Series, like Sequels, Prequels, etc @@ -114,6 +116,8 @@ public class Series : IEntityDate, IHasReadTimeEstimate public ICollection RelationOf { get; set; } = null!; + + // Relationships public List Volumes { get; set; } = null!; public Library Library { get; set; } = null!; @@ -131,4 +135,12 @@ public class Series : IEntityDate, IHasReadTimeEstimate LastChapterAdded = DateTime.Now; LastChapterAddedUtc = DateTime.UtcNow; } + + public bool MatchesSeriesByName(string nameNormalized, string localizedNameNormalized) + { + return NormalizedName == nameNormalized || + NormalizedLocalizedName == nameNormalized || + NormalizedName == localizedNameNormalized || + NormalizedLocalizedName == localizedNameNormalized; + } } diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index db707a5f9..5456a6e16 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using API.Entities; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Scanner.Parser; namespace API.Extensions; @@ -24,6 +25,7 @@ public static class ChapterListExtensions /// Gets a single chapter (or null if doesn't exist) where Range matches the info.Chapters property. If the info /// is then, the filename is used to search against Range or if filename exists within Files of said Chapter. /// + /// This uses GetNumberTitle() to calculate the Range to compare against the info.Chapters /// /// /// @@ -31,9 +33,12 @@ public static class ChapterListExtensions { var normalizedPath = Parser.NormalizePath(info.FullFilePath); var specialTreatment = info.IsSpecialInfo(); - return specialTreatment + // NOTE: This can fail to find the chapter when Range is "1.0" as the chapter will store it as "1" hence why we need to emulate a Chapter + var fakeChapter = new ChapterBuilder(info.Chapters, info.Chapters).Build(); + fakeChapter.UpdateFrom(info); + return specialTreatment ? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath)) - : chapters.FirstOrDefault(c => c.Range == info.Chapters); + : chapters.FirstOrDefault(c => c.Range == fakeChapter.GetNumberTitle()); } /// diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs new file mode 100644 index 000000000..d30bafbfe --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Linq; +using API.Data.Misc; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Metadata; +using Microsoft.EntityFrameworkCore; + +namespace API.Extensions.QueryExtensions.Filtering; + +public static class SearchQueryableExtensions +{ + public static IQueryable Search(this IQueryable queryable, + string searchQuery, int userId, AgeRestriction userRating) + { + return queryable + .Where(uc => uc.Promoted || uc.AppUserId == userId) + .Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%") + || EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%")) + .RestrictAgainstAgeRestriction(userRating) + .OrderBy(s => s.NormalizedTitle); + } + + public static IQueryable Search(this IQueryable queryable, + string searchQuery, int userId, AgeRestriction userRating) + { + return queryable + .Where(rl => rl.AppUserId == userId || rl.Promoted) + .Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%")) + .RestrictAgainstAgeRestriction(userRating) + .OrderBy(s => s.NormalizedTitle); + } + + public static IQueryable Search(this IQueryable queryable, + string searchQuery, int userId, IEnumerable libraryIds) + { + return queryable + .Where(l => libraryIds.Contains(l.Id)) + .Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%")) + .IsRestricted(QueryContext.Search) + .AsSplitQuery() + .OrderBy(l => l.Name.ToLower()); + } + + public static IQueryable SearchPeople(this IQueryable queryable, + string searchQuery, IEnumerable seriesIds) + { + return queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%"))) + .AsSplitQuery() + .Distinct() + .OrderBy(p => p.NormalizedName); + } + + public static IQueryable SearchGenres(this IQueryable queryable, + string searchQuery, IEnumerable seriesIds) + { + return queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) + .Distinct() + .OrderBy(t => t.NormalizedTitle); + } + + public static IQueryable SearchTags(this IQueryable queryable, + string searchQuery, IEnumerable seriesIds) + { + return queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%"))) + .AsSplitQuery() + .Distinct() + .OrderBy(t => t.NormalizedTitle); + } +} diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index 4a04d29a8..ce1a9700b 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -551,25 +551,26 @@ public static class SeriesFilter } public static IQueryable HasCollectionTags(this IQueryable queryable, bool condition, - FilterComparison comparison, IList collectionTags) + FilterComparison comparison, IList collectionTags, IList collectionSeries) { if (!condition || collectionTags.Count == 0) return queryable; + switch (comparison) { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); + return queryable.Where(s => collectionSeries.Contains(s.Id)); case FilterComparison.NotContains: case FilterComparison.NotEqual: - return queryable.Where(s => !s.Metadata.CollectionTags.Any(t => collectionTags.Contains(t.Id))); + return queryable.Where(s => !collectionSeries.Contains(s.Id)); case FilterComparison.MustContains: - // Deconstruct and do a Union of a bunch of where statements since this doesn't translate + // // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(collectionTags.Select(gId => queryable.Where(s => s.Metadata.CollectionTags.Any(p => p.Id == gId)))); + queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.GreaterThan: diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs index 4913c4059..efc4bc670 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs @@ -31,7 +31,7 @@ public static class SeriesSort SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, sortOptions), SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, sortOptions), SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id && p.AppUserId == userId) - .Select(p => p.LastModified) + .Select(p => p.LastModified) // TODO: Migrate this to UTC .Max(), sortOptions), SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings .Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), sortOptions), diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index be10abb9e..8cb0aed01 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -19,6 +19,23 @@ public static class IncludesExtensions queryable = queryable.Include(c => c.SeriesMetadatas); } + if (includes.HasFlag(CollectionTagIncludes.SeriesMetadataWithSeries)) + { + queryable = queryable.Include(c => c.SeriesMetadatas).ThenInclude(s => s.Series); + } + + return queryable.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable queryable, + CollectionIncludes includes) + { + if (includes.HasFlag(CollectionIncludes.Series)) + { + queryable = queryable.Include(c => c.Items); + } + + return queryable.AsSplitQuery(); } @@ -164,7 +181,9 @@ public static class IncludesExtensions if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) { - query = query.Include(u => u.UserPreferences); + query = query + .Include(u => u.UserPreferences) + .ThenInclude(p => p.Theme); } if (includeFlags.HasFlag(AppUserIncludes.WantToRead)) @@ -204,6 +223,12 @@ public static class IncludesExtensions query = query.Include(u => u.ExternalSources); } + if (includeFlags.HasFlag(AppUserIncludes.Collections)) + { + query = query.Include(u => u.Collections) + .ThenInclude(c => c.Items); + } + return query.AsSplitQuery(); } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 8101c9d35..1d42723cc 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System; +using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; @@ -24,6 +25,7 @@ public static class RestrictByAgeExtensions return q; } + [Obsolete] public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -38,6 +40,20 @@ public static class RestrictByAgeExtensions sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.Items.All(sm => + sm.Metadata.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.Items.All(sm => + sm.Metadata.AgeRating <= restriction.AgeRating && sm.Metadata.AgeRating > AgeRating.Unknown)); + } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index f439af9ef..99edd0f17 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -3,6 +3,7 @@ using System.Linq; using API.Data.Migrations; using API.DTOs; using API.DTOs.Account; +using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Dashboard; using API.DTOs.Device; @@ -10,6 +11,7 @@ using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; @@ -52,6 +54,8 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)); CreateMap(); CreateMap(); CreateMap(); @@ -140,10 +144,6 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom( src => src.Genres.OrderBy(p => p.NormalizedTitle))) - .ForMember(dest => dest.CollectionTags, - opt => - opt.MapFrom( - src => src.CollectionTags.OrderBy(p => p.NormalizedTitle))) .ForMember(dest => dest.Tags, opt => opt.MapFrom( diff --git a/API/Helpers/Builders/AppUserCollectionBuilder.cs b/API/Helpers/Builders/AppUserCollectionBuilder.cs new file mode 100644 index 000000000..0fcdc0058 --- /dev/null +++ b/API/Helpers/Builders/AppUserCollectionBuilder.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Services.Plus; + +namespace API.Helpers.Builders; + +public class AppUserCollectionBuilder : IEntityBuilder +{ + private readonly AppUserCollection _collection; + public AppUserCollection Build() => _collection; + + public AppUserCollectionBuilder(string title, bool promoted = false) + { + title = title.Trim(); + _collection = new AppUserCollection() + { + Id = 0, + NormalizedTitle = title.ToNormalized(), + Title = title, + Promoted = promoted, + Summary = string.Empty, + AgeRating = AgeRating.Unknown, + Source = ScrobbleProvider.Kavita, + Items = new List() + }; + } + + public AppUserCollectionBuilder WithSource(ScrobbleProvider provider) + { + _collection.Source = provider; + return this; + } + + + public AppUserCollectionBuilder WithSummary(string summary) + { + _collection.Summary = summary; + return this; + } + + public AppUserCollectionBuilder WithIsPromoted(bool promoted) + { + _collection.Promoted = promoted; + return this; + } + + public AppUserCollectionBuilder WithItem(Series series) + { + _collection.Items ??= new List(); + _collection.Items.Add(series); + return this; + } + + public AppUserCollectionBuilder WithItems(IEnumerable series) + { + _collection.Items ??= new List(); + foreach (var s in series) + { + _collection.Items.Add(s); + } + + return this; + } + + public AppUserCollectionBuilder WithCoverImage(string cover) + { + _collection.CoverImage = cover; + return this; + } +} diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index 6b0621e57..0467a620c 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using API.Entities; using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; @@ -36,7 +35,7 @@ public class ChapterBuilder : IEntityBuilder var specialTitle = specialTreatment ? Parser.RemoveExtensionIfSupported(info.Filename) : info.Chapters; var builder = new ChapterBuilder(Parser.DefaultChapter); - return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)) + return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)!) .WithRange(specialTreatment ? info.Filename : info.Chapters) .WithTitle((specialTreatment && info.Format == MangaFormat.Epub) ? info.Title diff --git a/API/Helpers/Builders/CollectionTagBuilder.cs b/API/Helpers/Builders/CollectionTagBuilder.cs deleted file mode 100644 index e46720d79..000000000 --- a/API/Helpers/Builders/CollectionTagBuilder.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using API.Entities; -using API.Entities.Metadata; -using API.Extensions; - -namespace API.Helpers.Builders; - -public class CollectionTagBuilder : IEntityBuilder -{ - private readonly CollectionTag _collectionTag; - public CollectionTag Build() => _collectionTag; - - public CollectionTagBuilder(string title, bool promoted = false) - { - title = title.Trim(); - _collectionTag = new CollectionTag() - { - Id = 0, - NormalizedTitle = title.ToNormalized(), - Title = title, - Promoted = promoted, - Summary = string.Empty, - SeriesMetadatas = new List() - }; - } - - public CollectionTagBuilder WithId(int id) - { - _collectionTag.Id = id; - return this; - } - - public CollectionTagBuilder WithSummary(string summary) - { - _collectionTag.Summary = summary; - return this; - } - - public CollectionTagBuilder WithIsPromoted(bool promoted) - { - _collectionTag.Promoted = promoted; - return this; - } - - public CollectionTagBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata) - { - _collectionTag.SeriesMetadatas ??= new List(); - _collectionTag.SeriesMetadatas.Add(seriesMetadata); - return this; - } - - public CollectionTagBuilder WithCoverImage(string cover) - { - _collectionTag.CoverImage = cover; - return this; - } -} diff --git a/API/Helpers/Builders/MangaFileBuilder.cs b/API/Helpers/Builders/MangaFileBuilder.cs index 584de4398..5387a3349 100644 --- a/API/Helpers/Builders/MangaFileBuilder.cs +++ b/API/Helpers/Builders/MangaFileBuilder.cs @@ -15,7 +15,7 @@ public class MangaFileBuilder : IEntityBuilder { _mangaFile = new MangaFile() { - FilePath = filePath, + FilePath = Parser.NormalizePath(filePath), Format = format, Pages = pages, LastModified = File.GetLastWriteTime(filePath), diff --git a/API/Helpers/Builders/VolumeBuilder.cs b/API/Helpers/Builders/VolumeBuilder.cs index 724bd894a..8d98844aa 100644 --- a/API/Helpers/Builders/VolumeBuilder.cs +++ b/API/Helpers/Builders/VolumeBuilder.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Data; using API.Entities; @@ -75,4 +76,18 @@ public class VolumeBuilder : IEntityBuilder _volume.CoverImage = cover; return this; } + + public VolumeBuilder WithCreated(DateTime created) + { + _volume.Created = created; + _volume.CreatedUtc = created.ToUniversalTime(); + return this; + } + + public VolumeBuilder WithLastModified(DateTime lastModified) + { + _volume.LastModified = lastModified; + _volume.LastModifiedUtc = lastModified.ToUniversalTime(); + return this; + } } diff --git a/API/Helpers/OrderableHelper.cs b/API/Helpers/OrderableHelper.cs index d936eb588..3313ca658 100644 --- a/API/Helpers/OrderableHelper.cs +++ b/API/Helpers/OrderableHelper.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities; namespace API.Helpers; @@ -46,6 +47,7 @@ public static class OrderableHelper public static void ReorderItems(List items, int readingListItemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); var item = items.Find(r => r.Id == readingListItemId); if (item != null) { diff --git a/API/I18N/en.json b/API/I18N/en.json index f24e76d9d..bee5c0ee0 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -40,6 +40,8 @@ "invalid-username": "Invalid username", "critical-email-migration": "There was an issue during email migration. Contact support", "email-not-enabled": "Email is not enabled on this server. You cannot perform this action.", + "account-email-invalid": "The email on file for the admin account is not a valid email. Cannot send test email.", + "email-settings-invalid": "Email settings missing information. Ensure all email settings are saved.", "chapter-doesnt-exist": "Chapter does not exist", "file-missing": "File was not found in book", @@ -200,8 +202,19 @@ "volume-num": "Volume {0}", "book-num": "Book {0}", "issue-num": "Issue {0}{1}", - "chapter-num": "Chapter {0}" - + "chapter-num": "Chapter {0}", + "check-updates": "Check Updates", + "license-check": "License Check", + "process-scrobbling-events": "Process Scrobbling Events", + "report-stats": "Report Stats", + "check-scrobbling-tokens": "Check Scrobbling Tokens", + "cleanup": "Cleanup", + "process-processed-scrobbling-events": "Process Processed Scrobbling Events", + "remove-from-want-to-read": "Want to Read Cleanup", + "scan-libraries": "Scan Libraries", + "kavita+-data-refresh": "Kavita+ Data Refresh", + "backup": "Backup", + "update-yearly-stats": "Update Yearly Stats" } diff --git a/API/Program.cs b/API/Program.cs index 548e57859..51b09c1c7 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -88,7 +88,7 @@ public class Program } // Apply Before manual migrations that need to run before actual migrations - try + if (isDbCreated) { Task.Run(async () => { @@ -96,17 +96,22 @@ public class Program logger.LogInformation("Running Migrations"); // v0.7.14 - await MigrateWantToReadExport.Migrate(context, directoryService, logger); + try + { + await MigrateWantToReadExport.Migrate(context, directoryService, logger); + } + catch (Exception ex) + { + /* Swallow */ + } await unitOfWork.CommitAsync(); logger.LogInformation("Running Migrations - complete"); }).GetAwaiter() .GetResult(); } - catch (Exception ex) - { - logger.LogCritical(ex, "An error occurred during migration"); - } + + await context.Database.MigrateAsync(); diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 873545742..120cbf3f7 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -353,7 +353,15 @@ public class ArchiveService : IArchiveService { var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name)); progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); - ExtractArchive(path, tempPath); + if (Tasks.Scanner.Parser.Parser.IsArchive(path)) + { + ExtractArchive(path, tempPath); + } + else + { + _directoryService.CopyFileToDirectory(path, tempPath); + } + count++; } } @@ -392,7 +400,7 @@ public class ArchiveService : IArchiveService return false; } - if (Tasks.Scanner.Parser.Parser.IsArchive(archivePath) || Tasks.Scanner.Parser.Parser.IsEpub(archivePath)) return true; + if (Tasks.Scanner.Parser.Parser.IsArchive(archivePath)) return true; _logger.LogWarning("Archive {ArchivePath} is not a valid archive", archivePath); return false; diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 9d50b1fe9..ceb609a70 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -382,7 +382,7 @@ public class BookService : IBookService } } - var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); + var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link[@href]"); if (styleNodes != null) { foreach (var styleLinks in styleNodes) @@ -781,7 +781,7 @@ public class BookService : IBookService /// public ParserInfo? ParseInfo(string filePath) { - if (!Parser.IsEpub(filePath)) return null; + if (!Parser.IsEpub(filePath) || !_directoryService.FileSystem.File.Exists(filePath)) return null; try { @@ -848,7 +848,7 @@ public class BookService : IBookService Format = MangaFormat.Epub, Filename = Path.GetFileName(filePath), Title = specialName?.Trim() ?? string.Empty, - FullFilePath = filePath, + FullFilePath = Parser.NormalizePath(filePath), IsSpecial = false, Series = series.Trim(), SeriesSort = series.Trim(), @@ -870,7 +870,7 @@ public class BookService : IBookService Format = MangaFormat.Epub, Filename = Path.GetFileName(filePath), Title = epubBook.Title.Trim(), - FullFilePath = filePath, + FullFilePath = Parser.NormalizePath(filePath), IsSpecial = false, Series = epubBook.Title.Trim(), Volumes = Parser.LooseLeafVolume, diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index b024d687a..645cffcfa 100644 --- a/API/Services/CollectionTagService.cs +++ b/API/Services/CollectionTagService.cs @@ -1,13 +1,12 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; -using API.Data.Repositories; -using API.DTOs.CollectionTags; +using API.DTOs.Collection; using API.Entities; -using API.Entities.Metadata; -using API.Helpers.Builders; +using API.Extensions; +using API.Services.Plus; using API.SignalR; using Kavita.Common; @@ -16,15 +15,9 @@ namespace API.Services; public interface ICollectionTagService { - Task TagExistsByName(string name); - Task DeleteTag(CollectionTag tag); - Task UpdateTag(CollectionTagDto dto); - Task AddTagToSeries(CollectionTag? tag, IEnumerable seriesIds); - Task RemoveTagFromSeries(CollectionTag? tag, IEnumerable seriesIds); - Task GetTagOrCreate(int tagId, string title); - void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata); - CollectionTag CreateTag(string title); - Task RemoveTagsWithoutSeries(); + Task DeleteTag(int tagId, AppUser user); + Task UpdateTag(AppUserCollectionDto dto, int userId); + Task RemoveTagFromSeries(AppUserCollection? tag, IEnumerable seriesIds); } @@ -39,37 +32,44 @@ public class CollectionTagService : ICollectionTagService _eventHub = eventHub; } - /// - /// Checks if a collection exists with the name - /// - /// If empty or null, will return true as that is invalid - /// - public async Task TagExistsByName(string name) + public async Task DeleteTag(int tagId, AppUser user) { - if (string.IsNullOrEmpty(name.Trim())) return true; - return await _unitOfWork.CollectionTagRepository.TagExists(name); - } + var collectionTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(tagId); + if (collectionTag == null) return true; + + user.Collections.Remove(collectionTag); + + if (!_unitOfWork.HasChanges()) return true; - public async Task DeleteTag(CollectionTag tag) - { - _unitOfWork.CollectionTagRepository.Remove(tag); return await _unitOfWork.CommitAsync(); } - public async Task UpdateTag(CollectionTagDto dto) + + public async Task UpdateTag(AppUserCollectionDto dto, int userId) { - var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(dto.Id); + var existingTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(dto.Id); if (existingTag == null) throw new KavitaException("collection-doesnt-exist"); + if (existingTag.AppUserId != userId) throw new KavitaException("access-denied"); var title = dto.Title.Trim(); if (string.IsNullOrEmpty(title)) throw new KavitaException("collection-tag-title-required"); - if (!title.Equals(existingTag.Title) && await TagExistsByName(dto.Title)) + + // Ensure the title doesn't exist on the user's account already + if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, userId)) throw new KavitaException("collection-tag-duplicate"); - existingTag.SeriesMetadatas ??= new List(); - existingTag.Title = title; - existingTag.NormalizedTitle = Tasks.Scanner.Parser.Parser.Normalize(dto.Title); - existingTag.Promoted = dto.Promoted; + existingTag.Items ??= new List(); + if (existingTag.Source == ScrobbleProvider.Kavita) + { + existingTag.Title = title; + existingTag.NormalizedTitle = dto.Title.ToNormalized(); + } + + var roles = await _unitOfWork.UserRepository.GetRoles(userId); + if (roles.Contains(PolicyConstants.AdminRole) || roles.Contains(PolicyConstants.PromoteRole)) + { + existingTag.Promoted = dto.Promoted; + } existingTag.CoverImageLocked = dto.CoverImageLocked; _unitOfWork.CollectionTagRepository.Update(existingTag); @@ -96,89 +96,31 @@ public class CollectionTagService : ICollectionTagService } /// - /// Adds a set of Series to a Collection + /// Removes series from Collection tag. Will recalculate max age rating. /// - /// A full Tag + /// /// /// - public async Task AddTagToSeries(CollectionTag? tag, IEnumerable seriesIds) + public async Task RemoveTagFromSeries(AppUserCollection? tag, IEnumerable seriesIds) { if (tag == null) return false; - var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(seriesIds); - foreach (var metadata in metadatas) - { - AddTagToSeriesMetadata(tag, metadata); - } - if (!_unitOfWork.HasChanges()) return true; - return await _unitOfWork.CommitAsync(); - } + tag.Items ??= new List(); + tag.Items = tag.Items.Where(s => !seriesIds.Contains(s.Id)).ToList(); - /// - /// Adds a collection tag to a SeriesMetadata - /// - /// Does not commit - /// - /// - /// - public void AddTagToSeriesMetadata(CollectionTag? tag, SeriesMetadata metadata) - { - if (tag == null) return; - metadata.CollectionTags ??= new List(); - if (metadata.CollectionTags.Any(t => t.NormalizedTitle.Equals(tag.NormalizedTitle, StringComparison.InvariantCulture))) return; - - metadata.CollectionTags.Add(tag); - if (metadata.Id != 0) - { - _unitOfWork.SeriesMetadataRepository.Update(metadata); - } - } - - public async Task RemoveTagFromSeries(CollectionTag? tag, IEnumerable seriesIds) - { - if (tag == null) return false; - tag.SeriesMetadatas ??= new List(); - foreach (var seriesIdToRemove in seriesIds) - { - tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove)); - } - - - if (tag.SeriesMetadatas.Count == 0) + if (tag.Items.Count == 0) { _unitOfWork.CollectionTagRepository.Remove(tag); } if (!_unitOfWork.HasChanges()) return true; - return await _unitOfWork.CommitAsync(); - } + var result = await _unitOfWork.CommitAsync(); + if (tag.Items.Count > 0) + { + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag); + } - /// - /// Tries to fetch the full tag, else returns a new tag. Adds to tracking but does not commit - /// - /// - /// - /// - public async Task GetTagOrCreate(int tagId, string title) - { - return await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata) ?? CreateTag(title); - } - - /// - /// This just creates the entity and adds to tracking. Use for checks of duplication. - /// - /// - /// - public CollectionTag CreateTag(string title) - { - var tag = new CollectionTagBuilder(title).Build(); - _unitOfWork.CollectionTagRepository.Add(tag); - return tag; - } - - public async Task RemoveTagsWithoutSeries() - { - return await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries() > 0; + return result; } } diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 5e54e2170..0a8ba6404 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -51,16 +51,19 @@ public class EmailService : IEmailService private readonly IUnitOfWork _unitOfWork; private readonly IDirectoryService _directoryService; private readonly IHostEnvironment _environment; + private readonly ILocalizationService _localizationService; private const string TemplatePath = @"{0}.html"; private const string LocalHost = "localhost:4200"; - public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IHostEnvironment environment) + public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, + IHostEnvironment environment, ILocalizationService localizationService) { _logger = logger; _unitOfWork = unitOfWork; _directoryService = directoryService; _environment = environment; + _localizationService = localizationService; } /// @@ -75,9 +78,18 @@ public class EmailService : IEmailService }; var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!IsValidEmail(adminEmail) || !settings.IsEmailSetup()) + if (!IsValidEmail(adminEmail)) { - result.ErrorMessage = "You need to fill in more information in settings and ensure your account has a valid email to send a test email"; + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + result.ErrorMessage = await _localizationService.Translate(defaultAdmin.Id, "account-email-invalid"); + result.Successful = false; + return result; + } + + if (!settings.IsEmailSetup()) + { + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + result.ErrorMessage = await _localizationService.Translate(defaultAdmin.Id, "email-settings-invalid"); result.Successful = false; return result; } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index af46505c7..dd2108d98 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -107,6 +107,7 @@ public class MetadataService : IMetadataService _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), null, volume.Created, forceUpdate)) return Task.FromResult(false); + // For cover selection, chapters need to try for issue 1 first, then fallback to first sort order volume.Chapters ??= new List(); @@ -117,7 +118,6 @@ public class MetadataService : IMetadataService if (firstChapter == null) return Task.FromResult(false); } - volume.CoverImage = firstChapter.CoverImage; _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); @@ -278,7 +278,7 @@ public class MetadataService : IMetadataService await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index f3ad603be..f2478f68e 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Collection; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -61,6 +62,8 @@ public interface IExternalMetadataService /// /// Task GetNewSeriesData(int seriesId, LibraryType libraryType); + + Task> GetStacksForUser(int userId); } public class ExternalMetadataService : IExternalMetadataService @@ -70,7 +73,8 @@ public class ExternalMetadataService : IExternalMetadataService private readonly IMapper _mapper; private readonly ILicenseService _licenseService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); - public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create(LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine); + public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create + (LibraryType.Comic, LibraryType.Book, LibraryType.Image, LibraryType.ComicVine); private readonly SeriesDetailPlusDto _defaultReturn = new() { Recommendations = null, @@ -137,12 +141,15 @@ public class ExternalMetadataService : IExternalMetadataService public async Task ForceKavitaPlusRefresh(int seriesId) { if (!await _licenseService.HasActiveLicense()) return; - // Remove from Blacklist if applicable var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); if (!IsPlusEligible(libraryType)) return; + + // Remove from Blacklist if applicable await _unitOfWork.ExternalSeriesMetadataRepository.RemoveFromBlacklist(seriesId); + var metadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); if (metadata == null) return; + metadata.ValidUntilUtc = DateTime.UtcNow.Subtract(_externalSeriesMetadataCache); await _unitOfWork.CommitAsync(); } @@ -170,10 +177,50 @@ public class ExternalMetadataService : IExternalMetadataService // Prefetch SeriesDetail data await GetSeriesDetailPlus(seriesId, libraryType); - // TODO: Fetch Series Metadata + // TODO: Fetch Series Metadata (Summary, etc) } + public async Task> GetStacksForUser(int userId) + { + if (!await _licenseService.HasActiveLicense()) return ArraySegment.Empty; + + // See if this user has Mal account on record + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.MalUserName) || string.IsNullOrEmpty(user.MalAccessToken)) + { + _logger.LogInformation("User is attempting to fetch MAL Stacks, but missing information on their account"); + return ArraySegment.Empty; + } + try + { + _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); + + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}") + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-license-key", license) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .GetJsonAsync>(); + + if (result == null) + { + return ArraySegment.Empty; + } + + return result; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Fetching Kavita+ for MAL Stacks for user {UserName} failed", user.MalUserName); + return ArraySegment.Empty; + } + } + /// /// Retrieves Metadata about a Recommended External Series /// diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index c3ff607cc..f440548ca 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -94,6 +94,7 @@ public class ScrobblingService : IScrobblingService ScrobbleProvider.AniList }; + private const string UnknownSeriesErrorMessage = "Series cannot be matched for Scrobbling"; private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling"; @@ -332,15 +333,7 @@ public class ScrobblingService : IScrobblingService await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId), Format = LibraryTypeHelper.GetFormat(series.Library.Type), }; - // NOTE: Not sure how to handle scrobbling specials or handling sending loose leaf volumes - if (evt.VolumeNumber is Parser.SpecialVolumeNumber) - { - evt.VolumeNumber = 0; - } - if (evt.VolumeNumber is Parser.DefaultChapterNumber) - { - evt.VolumeNumber = 0; - } + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); _logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId); @@ -440,22 +433,25 @@ public class ScrobblingService : IScrobblingService // Might want to log this under ScrobbleError if (response.ErrorMessage != null && response.ErrorMessage.Contains("Too Many Requests")) { - _logger.LogInformation("Hit Too many requests, sleeping to regain requests"); + _logger.LogInformation("Hit Too many requests, sleeping to regain requests and retrying"); await Task.Delay(TimeSpan.FromMinutes(10)); - } else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized")) + return await PostScrobbleUpdate(data, license, evt); + } + if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unauthorized")) { _logger.LogCritical("Kavita+ responded with Unauthorized. Please check your subscription"); await _licenseService.HasActiveLicense(true); evt.IsErrored = true; evt.ErrorDetails = "Kavita+ subscription no longer active"; throw new KavitaException("Kavita+ responded with Unauthorized. Please check your subscription"); - } else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid")) + } + if (response.ErrorMessage != null && response.ErrorMessage.Contains("Access token is invalid")) { evt.IsErrored = true; evt.ErrorDetails = AccessTokenErrorMessage; throw new KavitaException("Access token is invalid"); } - else if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series")) + if (response.ErrorMessage != null && response.ErrorMessage.Contains("Unknown Series")) { // Log the Series name and Id in ScrobbleErrors _logger.LogInformation("Kavita+ was unable to match the series"); @@ -490,10 +486,6 @@ public class ScrobblingService : IScrobblingService evt.IsErrored = true; evt.ErrorDetails = "Review was unable to be saved due to upstream requirements"; } - - evt.IsErrored = true; - _logger.LogError("Scrobbling failed due to {ErrorMessage}: {SeriesName}", response.ErrorMessage, data.SeriesName); - throw new KavitaException($"Scrobbling failed due to {response.ErrorMessage}: {data.SeriesName}"); } return response.RateLeft; @@ -827,6 +819,20 @@ public class ScrobblingService : IScrobblingService try { var data = await createEvent(evt); + // We need to handle the encoding and changing it to the old one until we can update the API layer to handle these + // which could happen in v0.8.3 + if (data.VolumeNumber is Parser.SpecialVolumeNumber) + { + data.VolumeNumber = 0; + } + if (data.VolumeNumber is Parser.DefaultChapterNumber) + { + data.VolumeNumber = 0; + } + if (data.ChapterNumber is Parser.DefaultChapterNumber) + { + data.ChapterNumber = 0; + } userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt); evt.IsProcessed = true; evt.ProcessDateUtc = DateTime.UtcNow; @@ -871,6 +877,7 @@ public class ScrobblingService : IScrobblingService } } + private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent) { var userProviders = GetUserProviders(readEvent.AppUser); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index d8c62a245..f3adc93fc 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -9,6 +9,7 @@ using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Progress; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; @@ -76,7 +77,7 @@ public class ReaderService : IReaderService public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) { - return Tasks.Scanner.Parser.Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}")); + return Parser.NormalizePath(Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}")); } /// @@ -133,7 +134,11 @@ public class ReaderService : IReaderService VolumeId = chapter.VolumeId, SeriesId = seriesId, ChapterId = chapter.Id, - LibraryId = series.LibraryId + LibraryId = series.LibraryId, + Created = DateTime.Now, + CreatedUtc = DateTime.UtcNow, + LastModified = DateTime.Now, + LastModifiedUtc = DateTime.UtcNow }); } else @@ -143,6 +148,8 @@ public class ReaderService : IReaderService userProgress.VolumeId = chapter.VolumeId; } + userProgress?.MarkModified(); + await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId, chapter.VolumeId, chapter.Id, chapter.Pages)); @@ -176,6 +183,7 @@ public class ReaderService : IReaderService userProgress.PagesRead = 0; userProgress.SeriesId = seriesId; userProgress.VolumeId = chapter.VolumeId; + userProgress.MarkModified(); await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, userProgress.SeriesId, userProgress.VolumeId, userProgress.ChapterId, 0)); @@ -265,7 +273,11 @@ public class ReaderService : IReaderService SeriesId = progressDto.SeriesId, ChapterId = progressDto.ChapterId, LibraryId = progressDto.LibraryId, - BookScrollId = progressDto.BookScrollId + BookScrollId = progressDto.BookScrollId, + Created = DateTime.Now, + CreatedUtc = DateTime.UtcNow, + LastModified = DateTime.Now, + LastModifiedUtc = DateTime.UtcNow }); _unitOfWork.UserRepository.Update(userWithProgress); } @@ -279,6 +291,8 @@ public class ReaderService : IReaderService _unitOfWork.AppUserProgressRepository.Update(userProgress); } + userProgress?.MarkModified(); + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 2545c031b..34360efa5 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -1,6 +1,5 @@ using System; using API.Data.Metadata; -using API.Entities; using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; @@ -14,7 +13,7 @@ public interface IReadingItemService int GetNumberOfPages(string filePath, MangaFormat format); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); - ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, Library library); + ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type); } public class ReadingItemService : IReadingItemService @@ -29,7 +28,6 @@ public class ReadingItemService : IReadingItemService private readonly ImageParser _imageParser; private readonly BookParser _bookParser; private readonly PdfParser _pdfParser; - private readonly GenericLibraryParser _genericLibraryParser; public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, IDirectoryService directoryService, ILogger logger) @@ -40,12 +38,12 @@ public class ReadingItemService : IReadingItemService _directoryService = directoryService; _logger = logger; - _comicVineParser = new ComicVineParser(directoryService); _imageParser = new ImageParser(directoryService); - _bookParser = new BookParser(directoryService, bookService, _basicParser); - _pdfParser = new PdfParser(directoryService); _basicParser = new BasicParser(directoryService, _imageParser); - _genericLibraryParser = new GenericLibraryParser(directoryService); + _bookParser = new BookParser(directoryService, bookService, _basicParser); + _comicVineParser = new ComicVineParser(directoryService); + _pdfParser = new PdfParser(directoryService); + } /// @@ -74,16 +72,24 @@ public class ReadingItemService : IReadingItemService /// Path of a file /// /// Library type to determine parsing to perform - public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, Library library) + public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { - var info = Parse(path, rootPath, libraryRoot, library); - if (info == null) + try { - _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); + var info = Parse(path, rootPath, libraryRoot, type); + if (info == null) + { + _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); + return null; + } + + return info; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when parsing file {FilePath}", path); return null; } - - return info; } /// @@ -169,31 +175,27 @@ public class ReadingItemService : IReadingItemService /// /// /// - private ParserInfo? Parse(string path, string rootPath, string libraryRoot, Library library) + private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type) { - if (_comicVineParser.IsApplicable(path, library.Type)) + if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, library.Type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); } - if (_imageParser.IsApplicable(path, library.Type)) + if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, library.Type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); } - if (_bookParser.IsApplicable(path, library.Type)) + if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, library.Type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); } - if (_genericLibraryParser.IsApplicable(path, library.Type)) + if (_pdfParser.IsApplicable(path, type)) { - return _genericLibraryParser.Parse(path, rootPath, libraryRoot, library.Type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); } - if (_pdfParser.IsApplicable(path, library.Type)) + if (_basicParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, library.Type, GetComicInfo(path)); - } - if (_basicParser.IsApplicable(path, library.Type)) - { - return _basicParser.Parse(path, rootPath, libraryRoot, library.Type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); } return null; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index e6cd2b3e3..151b4cdd6 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -115,12 +115,6 @@ public class SeriesService : ISeriesService if (series == null) return false; series.Metadata ??= new SeriesMetadataBuilder() - .WithCollectionTags(updateSeriesMetadataDto.CollectionTags.Select(dto => - new CollectionTagBuilder(dto.Title) - .WithId(dto.Id) - .WithSummary(dto.Summary) - .WithIsPromoted(dto.Promoted) - .Build()).ToList()) .Build(); if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating) @@ -163,28 +157,16 @@ public class SeriesService : ISeriesService series.Metadata.WebLinks = string.Empty; } else { - series.Metadata.WebLinks = string.Join(",", updateSeriesMetadataDto.SeriesMetadata?.WebLinks - .Split(",") + series.Metadata.WebLinks = string.Join(',', updateSeriesMetadataDto.SeriesMetadata?.WebLinks + .Split(',') .Where(s => !string.IsNullOrEmpty(s)) .Select(s => s.Trim())! ); } - if (updateSeriesMetadataDto.CollectionTags.Count > 0) - { - var allCollectionTags = (await _unitOfWork.CollectionTagRepository - .GetAllTagsByNamesAsync(updateSeriesMetadataDto.CollectionTags.Select(t => Parser.Normalize(t.Title)))).ToList(); - series.Metadata.CollectionTags ??= new List(); - UpdateCollectionsList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, tag => - { - series.Metadata.CollectionTags.Add(tag); - }); - } - - if (updateSeriesMetadataDto.SeriesMetadata?.Genres != null && - updateSeriesMetadataDto.SeriesMetadata.Genres.Any()) + updateSeriesMetadataDto.SeriesMetadata.Genres.Count != 0) { var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(updateSeriesMetadataDto.SeriesMetadata.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); series.Metadata.Genres ??= new List(); @@ -320,12 +302,6 @@ public class SeriesService : ISeriesService _logger.LogError(ex, "There was an issue cleaning up DB entries. This may happen if Komf is spamming updates. Nightly cleanup will work"); } - if (updateSeriesMetadataDto.CollectionTags == null) return true; - foreach (var tag in updateSeriesMetadataDto.CollectionTags) - { - await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection, - MessageFactory.SeriesAddedToCollectionEvent(tag.Id, seriesId), false); - } return true; } catch (Exception ex) @@ -337,46 +313,6 @@ public class SeriesService : ISeriesService return false; } - - private static void UpdateCollectionsList(ICollection? tags, Series series, IReadOnlyCollection allTags, - Action handleAdd) - { - // TODO: Move UpdateCollectionsList to a helper so we can easily test - if (tags == null) return; - // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different - var existingTags = series.Metadata.CollectionTags.ToList(); - foreach (var existing in existingTags) - { - if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) - { - // Remove tag - series.Metadata.CollectionTags.Remove(existing); - } - } - - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in tags) - { - var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title); - if (existingTag != null) - { - if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title)) - { - handleAdd(existingTag); - } - } - else - { - // Add new tag - handleAdd(new CollectionTagBuilder(tag.Title) - .WithId(tag.Id) - .WithSummary(tag.Summary) - .WithIsPromoted(tag.Promoted) - .Build()); - } - } - } - /// /// /// @@ -461,7 +397,7 @@ public class SeriesService : ISeriesService } await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); _taskScheduler.CleanupChapters(allChapterIds.ToArray()); return true; } @@ -578,6 +514,13 @@ public class SeriesService : ISeriesService return !chapter.IsSpecial && chapter.MinNumber.IsNot(Parser.DefaultChapterNumber); } + /// + /// Should the volume be included and if so, this renames + /// + /// + /// + /// + /// public static bool RenameVolumeName(VolumeDto volume, LibraryType libraryType, string volumeLabel = "Volume") { if (libraryType is LibraryType.Book or LibraryType.LightNovel) diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 5788c9e12..ea49f353d 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -9,6 +9,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -33,6 +34,7 @@ public interface IStatisticService IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); + Task GetKavitaPlusMetadataBreakdown(); } /// @@ -531,6 +533,29 @@ public class StatisticService : IStatisticService p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); } + public async Task GetKavitaPlusMetadataBreakdown() + { + // We need to count number of Series that have an external series record + // Then count how many series are blacklisted + // Then get total count of series that are Kavita+ eligible + var plusLibraries = await _context.Library + .Where(l => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(l.Type)) + .Select(l => l.Id) + .ToListAsync(); + + var countOfBlacklisted = await _context.SeriesBlacklist.CountAsync(); + var totalSeries = await _context.Series.Where(s => plusLibraries.Contains(s.LibraryId)).CountAsync(); + var seriesWithMetadata = await _context.ExternalSeriesMetadata.CountAsync(); + + return new KavitaPlusMetadataBreakdownDto() + { + TotalSeries = totalSeries, + ErroredSeries = countOfBlacklisted, + SeriesCompleted = seriesWithMetadata + }; + + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 83f9eee67..c9ae918ec 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -336,7 +336,7 @@ public class TaskScheduler : ITaskScheduler _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true)); // When we do a scan, force cache to re-unpack in case page numbers change - BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheAndTempDirectories()); + BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); } public void TurnOnScrobbling(int userId = 0) diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index 765d3ca73..60e0e8dc3 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -104,8 +104,13 @@ public class BackupService : IBackupService _directoryService.ExistOrCreate(tempDirectory); _directoryService.ClearDirectory(tempDirectory); + await SendProgress(0.1F, "Copying config files"); _directoryService.CopyFilesToDirectory( - _backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)).ToList(), tempDirectory); + _backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)), tempDirectory); + + // Copy any csv's as those are used for manual migrations + _directoryService.CopyFilesToDirectory( + _directoryService.GetFilesWithCertainExtensions(_directoryService.ConfigDirectory, @"\.csv"), tempDirectory); await SendProgress(0.2F, "Copying logs"); CopyLogsToBackupDirectory(tempDirectory); diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 3aaa2c837..4faf59e6c 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -20,6 +20,7 @@ public interface ICleanupService Task Cleanup(); Task CleanupDbEntries(); void CleanupCacheAndTempDirectories(); + void CleanupCacheDirectory(); Task DeleteSeriesCoverImages(); Task DeleteChapterCoverImages(); Task DeleteTagCoverImages(); @@ -106,7 +107,7 @@ public class CleanupService : ICleanupService await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); await _unitOfWork.ReadingListRepository.RemoveReadingListsWithoutSeries(); } @@ -178,6 +179,23 @@ public class CleanupService : ICleanupService _logger.LogInformation("Cache and temp directory purged"); } + public void CleanupCacheDirectory() + { + _logger.LogInformation("Performing cleanup of Cache directories"); + _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 configured total backups. If all backups are older than total backups days, only the latest is kept. /// diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 00dbe135c..4d5f17cb9 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -148,14 +148,14 @@ public class LibraryWatcher : ILibraryWatcher private void OnChanged(object sender, FileSystemEventArgs e) { - _logger.LogDebug("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); + _logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); if (e.ChangeType != WatcherChangeTypes.Changed) return; BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)))); } private void OnCreated(object sender, FileSystemEventArgs e) { - _logger.LogDebug("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); + _logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name))); } @@ -167,7 +167,7 @@ public class LibraryWatcher : ILibraryWatcher private void OnDeleted(object sender, FileSystemEventArgs e) { var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); if (!isDirectory) return; - _logger.LogDebug("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); + _logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true)); } @@ -285,10 +285,10 @@ public class LibraryWatcher : ILibraryWatcher var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList(); _logger.LogTrace("[LibraryWatcher] Root Folders: {RootFolders}", rootFolder); - if (!rootFolder.Any()) return string.Empty; + if (rootFolder.Count == 0) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. - return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); + return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index a5aafc36e..83eef8709 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -9,6 +9,7 @@ using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; using API.SignalR; +using ExCSS; using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; @@ -166,13 +167,18 @@ public class ParseScannedFiles } normalizedPath = Parser.Parser.NormalizePath(folderPath); + var libraryRoot = + library.Folders.FirstOrDefault(f => + Parser.Parser.NormalizePath(folderPath).Contains(Parser.Parser.NormalizePath(f.Path)))?.Path ?? + folderPath; + if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) { result.Add(new ScanResult() { Files = ArraySegment.Empty, Folder = folderPath, - LibraryRoot = folderPath, + LibraryRoot = libraryRoot, HasChanged = false }); } @@ -181,7 +187,7 @@ public class ParseScannedFiles { Files = _directoryService.ScanFiles(folderPath, fileExtensions), Folder = folderPath, - LibraryRoot = folderPath, + LibraryRoot = libraryRoot, HasChanged = true }); @@ -309,6 +315,7 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); var processedScannedSeries = new List(); + //var processedScannedSeries = new ConcurrentBag(); foreach (var folderPath in folders) { try @@ -317,45 +324,15 @@ public class ParseScannedFiles foreach (var scanResult in scanResults) { - // scanResult is updated with the parsed infos - await ProcessScanResult(scanResult, seriesPaths, library); - - // We now have all the parsed infos from the scan result, perform any merging that is necessary and post processing steps - var scannedSeries = new ConcurrentDictionary>(); - - // Merge any series together (like Nagatoro/nagator.cbz, japanesename.cbz) -> Nagator series - MergeLocalizedSeriesWithSeries(scanResult.ParserInfos); - - // Combine everything into scannedSeries - foreach (var info in scanResult.ParserInfos) - { - try - { - TrackSeries(scannedSeries, info); - } - catch (Exception ex) - { - _logger.LogError(ex, - "[ScannerService] There was an exception that occurred during tracking {FilePath}. Skipping this file", - info?.FullFilePath); - } - } - - foreach (var series in scannedSeries.Keys) - { - if (scannedSeries[series].Count <= 0) continue; - - UpdateSortOrder(scannedSeries, series); - - processedScannedSeries.Add(new ScannedSeriesResult() - { - HasChanged = scanResult.HasChanged, - ParsedSeries = series, - ParsedInfos = scannedSeries[series] - }); - } + await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); } + // This reduced a 1.1k series networked scan by a little more than 1 hour, but the order series were added to Kavita was not alphabetical + // await Task.WhenAll(scanResults.Select(async scanResult => + // { + // await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); + // })); + } catch (ArgumentException ex) { @@ -365,10 +342,52 @@ public class ParseScannedFiles await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", library.Name, ProgressEventType.Ended)); - return processedScannedSeries; + return processedScannedSeries.ToList(); } + private async Task ParseAndTrackSeries(Library library, IDictionary> seriesPaths, ScanResult scanResult, + List processedScannedSeries) + { + // scanResult is updated with the parsed infos + await ProcessScanResult(scanResult, seriesPaths, library); // NOTE: This may be able to be parallelized + + // We now have all the parsed infos from the scan result, perform any merging that is necessary and post processing steps + var scannedSeries = new ConcurrentDictionary>(); + + // Merge any series together (like Nagatoro/nagator.cbz, japanesename.cbz) -> Nagator series + MergeLocalizedSeriesWithSeries(scanResult.ParserInfos); + + // Combine everything into scannedSeries + foreach (var info in scanResult.ParserInfos) + { + try + { + TrackSeries(scannedSeries, info); + } + catch (Exception ex) + { + _logger.LogError(ex, + "[ScannerService] There was an exception that occurred during tracking {FilePath}. Skipping this file", + info?.FullFilePath); + } + } + + foreach (var series in scannedSeries.Keys) + { + if (scannedSeries[series].Count <= 0) continue; + + UpdateSortOrder(scannedSeries, series); + + processedScannedSeries.Add(new ScannedSeriesResult() + { + HasChanged = scanResult.HasChanged, + ParsedSeries = series, + ParsedInfos = scannedSeries[series] + }); + } + } + /// /// For a given ScanResult, sets the ParserInfos on the result /// @@ -397,21 +416,18 @@ public class ParseScannedFiles var folder = result.Folder; var libraryRoot = result.LibraryRoot; - // When processing files for a folder and we do enter, we need to parse the information and combine parser infos - // NOTE: We might want to move the merge step later in the process, like return and combine. _logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, folder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent($"{files.Count} files in {folder}", library.Name, ProgressEventType.Updated)); if (files.Count == 0) { - _logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", folder); - result.ParserInfos = ArraySegment.Empty; + _logger.LogInformation("[ScannerService] {Folder} is empty or is no longer in this location", folder); return; } // Multiple Series can exist within a folder. We should instead put these infos on the result and perform merging above IList infos = files - .Select(file => _readingItemService.ParseFile(file, folder, libraryRoot, library)) + .Select(file => _readingItemService.ParseFile(file, folder, libraryRoot, library.Type)) .Where(info => info != null) .ToList()!; @@ -430,22 +446,44 @@ public class ParseScannedFiles var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList(); IList chapters; var specialTreatment = infos.TrueForAll(info => info.IsSpecial); + var hasAnySpMarker = infos.Exists(info => info.SpecialIndex > 0); + var counter = 0f; - if (specialTreatment) + if (specialTreatment && hasAnySpMarker) { chapters = infos .OrderBy(info => info.SpecialIndex) .ToList(); + + foreach (var chapter in chapters) + { + chapter.IssueOrder = counter; + counter++; + } + return; } - else + + + // If everything is a special but we don't have any SpecialIndex, then order naturally and use 0, 1, 2 + if (specialTreatment) { chapters = infos - .OrderByNatural(info => info.Chapters) + .OrderByNatural(info => Parser.Parser.RemoveExtensionIfSupported(info.Filename)!) .ToList(); + + foreach (var chapter in chapters) + { + chapter.IssueOrder = counter; + counter++; + } + return; } + chapters = infos + .OrderByNatural(info => info.Chapters) + .ToList(); - var counter = 0f; + counter = 0f; var prevIssue = string.Empty; foreach (var chapter in chapters) { diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 459c93fb7..0ecb869da 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System.IO; +using System.IO; +using System.Collections.Generic; using API.Data.Metadata; using API.Entities.Enums; @@ -29,7 +29,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag Filename = Path.GetFileName(filePath), Format = Parser.ParseFormat(filePath), Title = Parser.RemoveExtensionIfSupported(fileName), - FullFilePath = filePath, + FullFilePath = Parser.NormalizePath(filePath), Series = string.Empty, ComicInfo = comicInfo }; @@ -97,6 +97,11 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag // Patch in other information from ComicInfo UpdateFromComicInfo(ret); + if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) + { + ret.IsSpecial = true; + } + // v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number if (ret.IsSpecial) { diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index b72bd7e20..1a9dca4b4 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -12,6 +12,8 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer var info = bookService.ParseInfo(filePath); if (info == null) return null; + info.ComicInfo = comicInfo; + // This catches when original library type is Manga/Comic and when parsing with non if (Parser.ParseVolume(info.Series) != Parser.LooseLeafVolume) // Shouldn't this be info.Volume != DefaultVolume? { @@ -22,6 +24,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series))) { + // NOTE: I'm not sure the comment is true. I've never seen this triggered // This is likely a light novel for which we can set series from parsed title info.Series = Parser.ParseSeries(info.Title); info.Volumes = Parser.ParseVolume(info.Title); @@ -30,6 +33,12 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer { var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); info.Merge(info2); + if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series) + .Equals(Parser.LooseLeafVolume)) + { + // Override the Series name so it groups appropriately + info.Series = info2.Series; + } } } diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index 0b46ad473..f6859710a 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -32,7 +32,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser Filename = Path.GetFileName(filePath), Format = Parser.ParseFormat(filePath), Title = Parser.RemoveExtensionIfSupported(fileName)!, - FullFilePath = filePath, + FullFilePath = Parser.NormalizePath(filePath), Series = string.Empty, ComicInfo = comicInfo, Chapters = Parser.ParseComicChapter(fileName), @@ -100,4 +100,33 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser { return type == LibraryType.ComicVine; } + + private new static void UpdateFromComicInfo(ParserInfo info) + { + if (info.ComicInfo == null) return; + + if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) + { + info.Volumes = info.ComicInfo.Volume; + } + if (string.IsNullOrEmpty(info.LocalizedSeries) && !string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) + { + info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); + } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) + { + info.Chapters = info.ComicInfo.Number; + if (info.IsSpecial && Parser.DefaultChapter != info.Chapters) + { + info.IsSpecial = false; + info.Volumes = $"{Parser.SpecialVolumeNumber}"; + } + } + + // Patch is SeriesSort from ComicInfo + if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) + { + info.SeriesSort = info.ComicInfo.TitleSort.Trim(); + } + } } diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 4aca1a77f..88b1baf36 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -27,6 +27,7 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau /// /// Root folder /// Allows different Regex to be used for parsing. + /// Allows different Regex to be used for parsing. /// ComicInfo if present (for epub it si always present) /// The regex for the Generic Parser /// or null if Series was empty @@ -111,21 +112,29 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau { info.Volumes = info.ComicInfo.Volume; } - if (string.IsNullOrEmpty(info.Series) && !string.IsNullOrEmpty(info.ComicInfo.Series)) + if (!string.IsNullOrEmpty(info.ComicInfo.Series)) { info.Series = info.ComicInfo.Series.Trim(); } - if (string.IsNullOrEmpty(info.LocalizedSeries) && !string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) + if (!string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) { info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); } + + if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format)) + { + info.IsSpecial = true; + info.Chapters = Parser.DefaultChapter; + info.Volumes = Parser.SpecialVolume; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) { info.Chapters = info.ComicInfo.Number; if (info.IsSpecial && Parser.DefaultChapter != info.Chapters) { info.IsSpecial = false; - info.Volumes = $"{Parser.SpecialVolumeNumber}"; + info.Volumes = Parser.SpecialVolume; } } @@ -134,6 +143,7 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau { info.SeriesSort = info.ComicInfo.TitleSort.Trim(); } + } public abstract bool IsApplicable(string filePath, LibraryType type); diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs index a2e08dea9..c2bc0c683 100644 --- a/API/Services/Tasks/Scanner/Parser/ImageParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System.IO; +using System.IO; +using System.Collections.Generic; using API.Data.Metadata; using API.Entities.Enums; @@ -23,7 +23,7 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir ComicInfo = comicInfo, Format = MangaFormat.Image, Filename = Path.GetFileName(filePath), - FullFilePath = filePath, + FullFilePath = Parser.NormalizePath(filePath), Title = fileName, }; ParseFromFallbackFolders(filePath, libraryRoot, LibraryType.Image, ref ret); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 29d2283d6..8c5aaaf84 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -121,6 +121,10 @@ public static class Parser private static readonly Regex[] MangaVolumeRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Dance in the Vampire Bund v16-17 new Regex( @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", @@ -194,6 +198,10 @@ public static class Parser private static readonly Regex[] MangaSeriesRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Russian Volume: Том n -> Volume n, Тома n -> Volume new Regex( @"(?.+?)Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", @@ -232,7 +240,7 @@ public static class Parser RegexTimeout), // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] new Regex( - @"(?.*)( - )(?:v|vo|c|chapters)\d", + @"(?.+?)( - )(?:v|vo|c|chapters)\d", MatchOptions, RegexTimeout), // Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip new Regex( @@ -368,6 +376,10 @@ public static class Parser private static readonly Regex[] ComicSeriesRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Russian Volume: Том n -> Volume n, Тома n -> Volume new Regex( @"(?.+?)Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", @@ -456,6 +468,10 @@ public static class Parser private static readonly Regex[] ComicVolumeRegex = new[] { + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( @"^(?.+?)(?: |_)(t|v)(?" + NumberRange + @")", @@ -492,6 +508,10 @@ public static class Parser private static readonly Regex[] ComicChapterRegex = new[] { + // Thai Volume: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n + new Regex( + @"(บทที่|ตอนที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), // Batman & Wildcat (1 of 3) new Regex( @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", @@ -557,6 +577,10 @@ public static class Parser private static readonly Regex[] MangaChapterRegex = new[] { + // Thai Chapter: บทที่ n -> Chapter n, ตอนที่ n -> Chapter n, เล่ม n -> Volume n, เล่มที่ n -> Volume n + new Regex( + @"(?((เล่ม|เล่มที่))?(\s|_)?\.?\d+)(\s|_)(บทที่|ตอนที่)\.?(\s|_)?(?\d+)", + MatchOptions, RegexTimeout), // Historys Strongest Disciple Kenichi_v11_c90-98.zip, ...c90.5-100.5 new Regex( @"(\b|_)(c|ch)(\.?\s?)(?(\d+(\.\d)?)(-c?\d+(\.\d)?)?)", diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index aa203887b..9b359528e 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System.IO; +using System.IO; +using System.Collections.Generic; using API.Data.Metadata; using API.Entities.Enums; @@ -16,7 +16,7 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc Filename = Path.GetFileName(filePath), Format = Parser.ParseFormat(filePath), Title = Parser.RemoveExtensionIfSupported(fileName)!, - FullFilePath = filePath, + FullFilePath = Parser.NormalizePath(filePath), Series = string.Empty, ComicInfo = comicInfo, Chapters = type == LibraryType.Comic @@ -24,6 +24,11 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc : Parser.ParseChapter(fileName) }; + if (type == LibraryType.Book) + { + ret.Chapters = Parser.DefaultChapter; + } + ret.Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName); ret.Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName); diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 180c6a040..5a5b8037c 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Metadata; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -80,7 +81,14 @@ public class ProcessSeries : IProcessSeries /// public async Task Prime() { - await _tagManagerService.Prime(); + try + { + await _tagManagerService.Prime(); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Unable to prime tag manager. Scan cannot proceed. Report to Kavita dev"); + } } /// @@ -196,15 +204,16 @@ public class ProcessSeries : IProcessSeries // Process reading list after commit as we need to commit per list - BackgroundJob.Enqueue(() => _readingListService.CreateReadingListsFromSeries(library.Id, series.Id)); + await _readingListService.CreateReadingListsFromSeries(library.Id, series.Id); if (seriesAdded) { // See if any recommendations can link up to the series and pre-fetch external metadata for the series _logger.LogInformation("Linking up External Recommendations new series (if applicable)"); - BackgroundJob.Enqueue(() => - _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type)); + // BackgroundJob.Enqueue(() => + // _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type)); + await _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type); await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false); @@ -225,9 +234,11 @@ public class ProcessSeries : IProcessSeries var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); await _metadataService.GenerateCoversForSeries(series, settings.EncodeMediaAs, settings.CoverImageSize); - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate)); + // BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate)); + await _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate); } + private async Task ReportDuplicateSeriesLookup(Library library, ParserInfo firstInfo, Exception ex) { var seriesCollisions = await _unitOfWork.SeriesRepository.GetAllSeriesByAnyName(firstInfo.LocalizedSeries, string.Empty, library.Id, firstInfo.Format); @@ -361,12 +372,26 @@ public class ProcessSeries : IProcessSeries if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections) { + // Get the default admin to associate these tags to + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); + if (defaultAdmin == null) return; + _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { - var t = await _tagManagerService.GetCollectionTag(collection); - if (t == null) continue; - _collectionTagService.AddTagToSeriesMetadata(t, series.Metadata); + var t = await _tagManagerService.GetCollectionTag(collection, defaultAdmin); + if (t.Item1 == null) continue; + + var tag = t.Item1; + + // Check if the Series is already on the tag + if (tag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName))) + { + continue; + } + + tag.Items.Add(series); + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag); } } @@ -574,7 +599,7 @@ public class ProcessSeries : IProcessSeries { // TODO: Push this to UI in some way if (!ex.Message.Equals("Sequence contains more than one matching element")) throw; - _logger.LogCritical("[ScannerService] Kavita found corrupted volume entries on {SeriesName}. Please delete the series from Kavita via UI and rescan", series.Name); + _logger.LogCritical(ex, "[ScannerService] Kavita found corrupted volume entries on {SeriesName}. Please delete the series from Kavita via UI and rescan", series.Name); throw new KavitaException( $"Kavita found corrupted volume entries on {series.Name}. Please delete the series from Kavita via UI and rescan"); } @@ -691,14 +716,15 @@ public class ProcessSeries : IProcessSeries { 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); + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", + existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); } 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)) + .Where(f => parsedInfos.Any(p => Parser.Parser.NormalizePath(p.FullFilePath) == Parser.Parser.NormalizePath(f.FilePath))) .OrderByNatural(f => f.FilePath).ToList(); existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); } @@ -717,6 +743,7 @@ public class ProcessSeries : IProcessSeries existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format); existingFile.Extension = fileInfo.Extension.ToLowerInvariant(); existingFile.FileName = Parser.Parser.RemoveExtensionIfSupported(existingFile.FilePath); + existingFile.FilePath = Parser.Parser.NormalizePath(existingFile.FilePath); existingFile.Bytes = fileInfo.Length; // We skip updating DB here with last modified time so that metadata refresh can do it } diff --git a/API/Services/Tasks/Scanner/TagManagerService.cs b/API/Services/Tasks/Scanner/TagManagerService.cs index ed7b80a22..722804f90 100644 --- a/API/Services/Tasks/Scanner/TagManagerService.cs +++ b/API/Services/Tasks/Scanner/TagManagerService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -28,7 +29,7 @@ public interface ITagManagerService Task GetGenre(string genre); Task GetTag(string tag); Task GetPerson(string name, PersonRole role); - Task GetCollectionTag(string name); + Task> GetCollectionTag(string? tag, AppUser userWithCollections); } /// @@ -41,7 +42,7 @@ public class TagManagerService : ITagManagerService private Dictionary _genres; private Dictionary _tags; private Dictionary _people; - private Dictionary _collectionTags; + private Dictionary _collectionTags; private readonly SemaphoreSlim _genreSemaphore = new SemaphoreSlim(1, 1); private readonly SemaphoreSlim _tagSemaphore = new SemaphoreSlim(1, 1); @@ -57,18 +58,22 @@ public class TagManagerService : ITagManagerService public void Reset() { - _genres = new Dictionary(); - _tags = new Dictionary(); - _people = new Dictionary(); - _collectionTags = new Dictionary(); + _genres = []; + _tags = []; + _people = []; + _collectionTags = []; } public async Task Prime() { _genres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToDictionary(t => t.NormalizedTitle); _tags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToDictionary(t => t.NormalizedTitle); - _people = (await _unitOfWork.PersonRepository.GetAllPeople()).ToDictionary(GetPersonKey); - _collectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync(CollectionTagIncludes.SeriesMetadata)) + _people = (await _unitOfWork.PersonRepository.GetAllPeople()) + .GroupBy(GetPersonKey) + .Select(g => g.First()) + .ToDictionary(GetPersonKey); + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser()!; + _collectionTags = (await _unitOfWork.CollectionTagRepository.GetCollectionsForUserAsync(defaultAdmin.Id, CollectionIncludes.Series)) .ToDictionary(t => t.NormalizedTitle); } @@ -180,28 +185,30 @@ public class TagManagerService : ITagManagerService /// /// /// - public async Task GetCollectionTag(string tag) + public async Task> GetCollectionTag(string? tag, AppUser userWithCollections) { - if (string.IsNullOrEmpty(tag)) return null; + if (string.IsNullOrEmpty(tag)) return Tuple.Create(null, false); await _collectionTagSemaphore.WaitAsync(); + AppUserCollection? result; try { - if (_collectionTags.TryGetValue(tag.ToNormalized(), out var result)) + if (_collectionTags.TryGetValue(tag.ToNormalized(), out result)) { - return result; + return Tuple.Create(result, false); } // We need to create a new Genre - result = new CollectionTagBuilder(tag).Build(); - _unitOfWork.CollectionTagRepository.Add(result); + result = new AppUserCollectionBuilder(tag).Build(); + userWithCollections.Collections.Add(result); + _unitOfWork.UserRepository.Update(userWithCollections); await _unitOfWork.CommitAsync(); _collectionTags.Add(result.NormalizedTitle, result); - return result; } finally { _collectionTagSemaphore.Release(); } + return Tuple.Create(result, true); } } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index ab486c7d3..ddc319e70 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -325,7 +325,7 @@ public class ScannerService : IScannerService await _metadataService.RemoveAbandonedMetadataKeys(); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(existingChapterIdsToClean)); - BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)); + BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory)); } private void TrackFoundSeriesAndFiles(Dictionary> parsedSeries, IList seenSeries) @@ -485,7 +485,8 @@ public class ScannerService : IScannerService public async Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSingleScan = true) { var sw = Stopwatch.StartNew(); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, + LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); var libraryFolderPaths = library!.Folders.Select(fp => fp.Path).ToList(); if (!await CheckMounts(library.Name, libraryFolderPaths)) return; @@ -501,47 +502,16 @@ public class ScannerService : IScannerService } - var totalFiles = 0; - var parsedSeries = new Dictionary>(); - var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, forceUpdate); + var parsedSeries = new Dictionary>(); TrackFoundSeriesAndFiles(parsedSeries, processedSeries); // We need to remove any keys where there is no actual parser info - var toProcess = parsedSeries.Keys - .Where(k => parsedSeries[k].Any() && !string.IsNullOrEmpty(parsedSeries[k][0].Filename)) - .ToList(); + var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime); - if (toProcess.Count > 0) - { - // This grabs all the shared entities, like tags, genre, people. To be solved later in this refactor on how to not have blocking access. - await _processSeries.Prime(); - } - - var tasks = new List(); - foreach (var pSeries in toProcess) - { - totalFiles += parsedSeries[pSeries].Count; - tasks.Add(_processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate)); - } - - await Task.WhenAll(tasks); - - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended)); - - _logger.LogInformation("[ScannerService] Finished file scan in {ScanAndUpdateTime} milliseconds. Updating database", scanElapsedTime); - - var time = DateTime.Now; - foreach (var folderPath in library.Folders) - { - folderPath.UpdateLastScanned(time); - } - - library.UpdateLastScanned(time); + UpdateLastScanned(library); _unitOfWork.LibraryRepository.Update(library); @@ -565,28 +535,7 @@ public class ScannerService : IScannerService totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name); } - try - { - // Could I delete anything in a Library's Series where the LastScan date is before scanStart? - // NOTE: This implementation is expensive - _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan"); - var removedSeries = await _unitOfWork.SeriesRepository.RemoveSeriesNotInList(parsedSeries.Keys.ToList(), library.Id); - _logger.LogDebug("[ScannerService] Found {Count} series that needs to be removed: {SeriesList}", - removedSeries.Count, removedSeries.Select(s => s.Name)); - _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan - complete"); - - await _unitOfWork.CommitAsync(); - - foreach (var s in removedSeries) - { - await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, - MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false); - } - } - catch (Exception ex) - { - _logger.LogCritical(ex, "[ScannerService] There was an issue deleting series for cleanup. Please check logs and rescan"); - } + await RemoveSeriesNotFound(parsedSeries, library); } else { @@ -597,7 +546,77 @@ public class ScannerService : IScannerService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, string.Empty)); await _metadataService.RemoveAbandonedMetadataKeys(); - BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)); + BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory)); + } + + private async Task RemoveSeriesNotFound(Dictionary> parsedSeries, Library library) + { + try + { + // Could I delete anything in a Library's Series where the LastScan date is before scanStart? + // NOTE: This implementation is expensive + _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan"); + var removedSeries = await _unitOfWork.SeriesRepository.RemoveSeriesNotInList(parsedSeries.Keys.ToList(), library.Id); + _logger.LogDebug("[ScannerService] Found {Count} series that needs to be removed: {SeriesList}", + removedSeries.Count, removedSeries.Select(s => s.Name)); + _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan - complete"); + + await _unitOfWork.CommitAsync(); + + foreach (var s in removedSeries) + { + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false); + } + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] There was an issue deleting series for cleanup. Please check logs and rescan"); + } + } + + private async Task ProcessParsedSeries(bool forceUpdate, Dictionary> parsedSeries, Library library, long scanElapsedTime) + { + var toProcess = parsedSeries.Keys + .Where(k => parsedSeries[k].Any() && !string.IsNullOrEmpty(parsedSeries[k][0].Filename)) + .ToList(); + + if (toProcess.Count > 0) + { + // This grabs all the shared entities, like tags, genre, people. To be solved later in this refactor on how to not have blocking access. + await _processSeries.Prime(); + } + + var totalFiles = 0; + //var tasks = new List(); + foreach (var pSeries in toProcess) + { + totalFiles += parsedSeries[pSeries].Count; + //tasks.Add(_processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate)); + // We can't do Task.WhenAll because of concurrency issues. + await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate); + } + + //await Task.WhenAll(tasks); + + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended)); + + _logger.LogInformation("[ScannerService] Finished file scan in {ScanAndUpdateTime} milliseconds. Updating database", scanElapsedTime); + + return totalFiles; + } + + private static void UpdateLastScanned(Library library) + { + var time = DateTime.Now; + foreach (var folderPath in library.Folders) + { + folderPath.UpdateLastScanned(time); + } + + library.UpdateLastScanned(time); } private async Task>> ScanFiles(Library library, IEnumerable dirs, diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index ce45cdb28..48602a12e 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -134,7 +134,7 @@ public class StatsService : IStatsService HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), - NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), + NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()).Count(), NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), OPDSEnabled = serverSettings.EnableOpds, NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), diff --git a/API/Startup.cs b/API/Startup.cs index 740e59af5..3fc336e15 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -255,9 +255,13 @@ public class Startup // v0.8.0 await MigrateVolumeLookupName.Migrate(dataContext, unitOfWork, logger); await MigrateChapterNumber.Migrate(dataContext, logger); - await MigrateMixedSpecials.Migrate(dataContext, unitOfWork, logger); + await MigrateProgressExport.Migrate(dataContext, directoryService, logger); + await MigrateMixedSpecials.Migrate(dataContext, unitOfWork, directoryService, logger); + await MigrateLooseLeafChapters.Migrate(dataContext, unitOfWork, directoryService, logger); await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger); await MigrateChapterRange.Migrate(dataContext, unitOfWork, logger); + await MigrateMangaFilePath.Migrate(dataContext, logger); + await MigrateCollectionTagToUserCollections.Migrate(dataContext, unitOfWork, logger); // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index a72749400..0c6352d06 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -1,8 +1,8 @@ { "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, - "IpAddresses": "", + "IpAddresses": "0.0.0.0,::", "BaseUrl": "/", - "Cache": 90, + "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/API/config/templates/EmailChange.html b/API/config/templates/EmailChange.html index f5d661294..7a960aea9 100644 --- a/API/config/templates/EmailChange.html +++ b/API/config/templates/EmailChange.html @@ -270,7 +270,7 @@ @@ -278,7 +278,7 @@ -

If the button above does not work, please find the link here: {{Link}}

+

If the button above does not work, please find the link here: {{Link}}

@@ -312,19 +312,19 @@
- Discord + Discord   - Reddit + Reddit   - Github + Github   - Open Collective + Open Collective
diff --git a/API/config/templates/EmailConfirm.html b/API/config/templates/EmailConfirm.html index dff300dc6..4aa4f701c 100644 --- a/API/config/templates/EmailConfirm.html +++ b/API/config/templates/EmailConfirm.html @@ -35,7 +35,7 @@ @import url('https://fonts.googleapis.com/css2?family=Spartan:wght@500;700&display=swap'); /* What it does: Remove spaces around the email design added by some email clients. */ /* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */ - + html, body { margin: 0 auto !important; @@ -44,53 +44,53 @@ width: 100% !important; } /* What it does: Stops email clients resizing small text. */ - + * { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; } /* What it does: Centers email on Android 4.4 */ - + div[style*="margin: 16px 0"] { margin: 0 !important; } /* What it does: Stops Outlook from adding extra spacing to tables. */ - + table, td { mso-table-lspace: 0pt !important; mso-table-rspace: 0pt !important; } /* What it does: Fixes webkit padding issue. Fix for Yahoo mail table alignment bug. Applies table-layout to the first 2 tables then removes for anything nested deeper. */ - + table { border-spacing: 0 !important; border-collapse: collapse !important; table-layout: fixed !important; margin: 0 auto !important; } - + table table table { table-layout: auto; } - + i { color: #fff; font-size: 26px; } /* What it does: Uses a better rendering method when resizing images in IE. */ - + img { -ms-interpolation-mode: bicubic; } /* What it does: A work-around for email clients meddling in triggered links. */ - + *[x-apple-data-detectors], /* iOS */ - + .x-gmail-data-detectors, /* Gmail */ - + .x-gmail-data-detectors *, .aBn { border-bottom: 0 !important; @@ -103,25 +103,25 @@ line-height: inherit !important; } /* What it does: Prevents Gmail from displaying an download button on large, non-linked images. */ - + .a6S { display: none !important; opacity: 0.01 !important; } /* If the above doesn't work, add a .g-img class to any image in question. */ - + img.g-img + div { display: none !important; } /* What it does: Prevents underlining the button text in Windows 10 */ - + .button-link { text-decoration: none !important; } /* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */ /* Create one of these media queries for each additional viewport size you'd like to fix */ /* Thanks to Eric Lepetit @ericlepetitsf) for help troubleshooting */ - + @media only screen and (min-device-width: 375px) and (max-device-width: 413px) { /* iPhone 6 and 6+ */ .email-container { @@ -132,12 +132,12 @@