diff --git a/.browserslistrc b/.browserslistrc index 427441dc9..6784945a5 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -8,10 +8,4 @@ # You can see what browsers were selected by your queries by running: # npx browserslist -last 1 Chrome version -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -Firefox ESR -not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. +defaults \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index c24677846..c82009e40 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,7 @@ # Editor configuration, see https://editorconfig.org root = true + [*] charset = utf-8 indent_style = space @@ -22,3 +23,7 @@ indent_size = 2 [*.csproj] indent_size = 2 + +[*.cs] +# Disable SonarLint warning S1075 (Don't use hardcoded url) +dotnet_diagnostic.S1075.severity = none diff --git a/.github/DISCUSSION_TEMPLATE/ideas.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml index 728b5b497..845d3e3f3 100644 --- a/.github/DISCUSSION_TEMPLATE/ideas.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,68 +1,48 @@ -title: "[Kavita] Idea Submission" -labels: ["Idea Submission"] +title: "[Kavita] Idea / Feature Submission" +labels: + - "Idea Submission" body: - type: markdown attributes: value: | - ## 🌟 Idea Submission for Kavita 🌟 - - This is a template for submitting your ideas to enhance Kavita. Please fill out the details below, and let's make Kavita even better together! + ## Idea Submission for Kavita 💡 + Please fill out the details below, and let's make Kavita even better together! + - type: textarea id: idea-description attributes: label: Idea Description - description: "Describe your idea in detail." value: | - [Include a brief overview of your idea] - - - type: markdown - attributes: - value: | - **Why I Think This Is Important:** - - [Provide context on why you believe this idea is valuable or necessary for Kavita users] - - - type: markdown - attributes: - value: | - **How You Can Contribute:** - - 1. **Upvote if You Agree:** - - If you resonate with my idea, please upvote it! This helps us gauge community interest. - - 2. **Leave Your Thoughts:** - - Feel free to leave comments with your opinions, suggestions, or even constructive critiques. - - Let's work together to shape the future of Kavita! 🌟 - - - type: input - id: duration-of-use - attributes: - label: Duration of Using Kavita - description: "How long have you been using Kavita?" - validations: - required: true - + Go into as much detail as possible to explain why your idea should be added to Kavita. Try to present some use cases and examples of how it would help other users. The more detail you have the better. + - type: dropdown id: idea-category attributes: label: Idea Category options: + - API - Feature Enhancement - User Experience - Performance Improvement - description: "Select the category that best fits your idea." + - Web UI + description: "What area would your idea help with?" validations: required: true - + + - type: input + id: duration-of-use + attributes: + label: Duration of Using Kavita + description: "How long have you been using Kavita?" + - type: checkboxes attributes: - label: Agreement + label: Before submitting options: - - label: "I agree that this is solely for submitting ideas, and I will search for existing ideas before posting." + - label: "I've already searched for existing ideas before posting." required: true - + - type: markdown attributes: value: | diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 627edd9ed..cdd72de1c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,10 +25,10 @@ body: - type: dropdown id: version attributes: - label: Kavita Version Number - Don't see your version number listed? Then your install is out of date. Please update and see if your issue still persists. + label: Kavita Version Number - If you don't see your version number listed, please update Kavita and see if your issue still persists. multiple: false options: - - 0.7.14 - Stable + - 0.8.6.2 - Stable - Nightly Testing Branch validations: required: true @@ -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..044864734 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -10,23 +10,23 @@ 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 + dotnet-version: 9.0.x - name: Install Swashbuckle CLI shell: powershell - run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli + run: dotnet tool install -g Swashbuckle.AspNetCore.Cli - 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..b919030b0 100644 --- a/.github/workflows/canary-workflow.yml +++ b/.github/workflows/canary-workflow.yml @@ -9,14 +9,14 @@ on: jobs: build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 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 @@ -24,16 +24,16 @@ jobs: version: name: Bump version needs: [ build ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 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 + dotnet-version: 9.0.x - name: Bump versions uses: SiqiLu/dotnet-bump-version@2.0.0 @@ -45,7 +45,7 @@ jobs: canary: name: Build Canary Docker needs: [ build, version ] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -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,38 +96,38 @@ 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 + dotnet-version: 9.0.x - name: Install Swashbuckle CLI - run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli + run: dotnet tool install -g Swashbuckle.AspNetCore.Cli - 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..7ce4276bc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,7 +13,7 @@ name: "CodeQL" on: push: - branches: [ "develop", "main" ] + branches: [ "develop"] pull_request: # The branches below must be a subset of the branches above branches: [ "develop" ] @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'csharp', 'javascript-typescript', 'python' ] + language: [ 'csharp', 'javascript-typescript' ] # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both @@ -46,15 +46,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install Swashbuckle CLI - shell: bash - run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -68,7 +69,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -81,6 +82,6 @@ jobs: dotnet build Kavita.sln - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/develop-workflow.yml b/.github/workflows/develop-workflow.yml index dff82c01e..006127645 100644 --- a/.github/workflows/develop-workflow.yml +++ b/.github/workflows/develop-workflow.yml @@ -2,15 +2,12 @@ name: Nightly Workflow on: push: - branches: ['!release/**'] - pull_request: branches: [ 'develop', '!release/**' ] - types: [ closed ] workflow_dispatch: jobs: debug: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Debug Info run: | @@ -20,15 +17,15 @@ jobs: echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release') + runs-on: ubuntu-24.04 + 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 @@ -36,17 +33,17 @@ jobs: version: name: Bump version needs: [ build ] - runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release') + runs-on: ubuntu-24.04 + 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 + dotnet-version: 9.0.x - name: Bump versions uses: majora2007/dotnet-bump-version@v0.0.10 @@ -58,8 +55,8 @@ jobs: develop: name: Build Nightly Docker needs: [ build, version ] - runs-on: ubuntu-latest - if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release') + runs-on: ubuntu-24.04 + if: github.ref == 'refs/heads/develop' permissions: packages: write contents: read @@ -92,18 +89,18 @@ 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' - npm install --legacy-peer-deps + npm ci echo 'Building UI' npm run prod @@ -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,49 +126,63 @@ 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 + dotnet-version: 9.0.x - name: Install Swashbuckle CLI - run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli + run: dotnet tool install -g Swashbuckle.AspNetCore.Cli - run: ./monorepo-build.sh - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 + if: ${{ github.repository_owner == 'Kareadita' }} 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: Extract metadata (tags, labels) for Docker + id: docker_meta_nightly + uses: docker/metadata-action@v5 + with: + tags: | + type=raw,value=nightly + type=raw,value=nightly-${{ steps.parse-version.outputs.VERSION }} + images: | + name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }} + name=ghcr.io/${{ github.repository }} - name: Build and push id: docker_build - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true - tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} + tags: ${{ steps.docker_meta_nightly.outputs.tags }} + labels: ${{ steps.docker_meta_nightly.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} - name: Notify Discord uses: rjstone/discord-webhook-notify@v1 + if: ${{ github.repository_owner == 'Kareadita' }} with: severity: info description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} diff --git a/.github/workflows/openapi-gen.yml b/.github/workflows/openapi-gen.yml new file mode 100644 index 000000000..45446d045 --- /dev/null +++ b/.github/workflows/openapi-gen.yml @@ -0,0 +1,68 @@ +name: Generate OpenAPI Documentation + +on: + push: + branches: [ 'develop', '!release/**' ] + paths: + - '**/*.cs' + - '**/*.csproj' + pull_request: + branches: [ 'develop', '!release/**' ] + workflow_dispatch: + +jobs: + generate-openapi: + runs-on: ubuntu-latest + # Only run on direct pushes to develop, not PRs + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.repository_owner == 'Kareadita' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Install dependencies + run: dotnet restore + + - name: Build project + run: dotnet build API/API.csproj --configuration Debug + + - name: Get Swashbuckle version + id: swashbuckle-version + run: | + VERSION=$(grep -o '> $GITHUB_OUTPUT + echo "Found Swashbuckle.AspNetCore version: $VERSION" + + - name: Install matching Swashbuckle CLI tool + run: | + dotnet new tool-manifest --force + dotnet tool install Swashbuckle.AspNetCore.Cli --version ${{ steps.swashbuckle-version.outputs.VERSION }} + + - name: Generate OpenAPI file + run: dotnet swagger tofile --output openapi.json API/bin/Debug/net9.0/API.dll v1 + + - name: Check for changes + id: git-check + run: | + git add openapi.json + git diff --staged --quiet openapi.json || echo "has_changes=true" >> $GITHUB_OUTPUT + + - name: Commit and push if changed + if: steps.git-check.outputs.has_changes == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + git commit -m "Update OpenAPI documentation" openapi.json + + # Pull latest changes with rebase to avoid merge commits + git pull --rebase origin develop + + git push + env: + GITHUB_TOKEN: ${{ secrets.REPO_GHA_PAT }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7482deb0b..51589221f 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -1,15 +1,13 @@ name: Validate PR Body on: - push: - branches: '**' pull_request: branches: [ main, develop, canary ] types: [synchronize] jobs: check_pr: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Extract branch name shell: bash diff --git a/.github/workflows/release-workflow.yml b/.github/workflows/release-workflow.yml index ca1314e8b..757ce1075 100644 --- a/.github/workflows/release-workflow.yml +++ b/.github/workflows/release-workflow.yml @@ -10,7 +10,7 @@ on: jobs: debug: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Debug Info run: | @@ -20,21 +20,21 @@ jobs: echo "Matches Develop: ${{ github.ref == 'refs/heads/develop' }}" if_merged: if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - run: | echo The PR was merged build: name: Upload Kavita.Common for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 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 @@ -43,7 +43,7 @@ jobs: name: Build Stable and Nightly Docker if Release needs: [ build ] if: github.event.pull_request.merged == true && contains(github.head_ref, 'release') - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -58,38 +58,25 @@ jobs: - name: Parse PR body id: parse-body run: | - body="${{ steps.findPr.outputs.body }}" - body=${body//\'/} - body=${body//'%'/'%25'} - body=${body//$'\n'/'%0A'} - body=${body//$'\r'/'%0D'} - body=${body//$'`'/'%60'} - body=${body//$'>'/'%3E'} - - if [[ ${#body} -gt 1870 ]] ; then - body=${body:0:1870} - body="${body}...and much more. - - Read full changelog: https://github.com/Kareadita/Kavita/releases/latest" - fi + body="Read full changelog: https://github.com/Kareadita/Kavita/releases/latest" echo $body 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' - npm install --legacy-peer-deps + npm ci echo 'Building UI' npm run prod @@ -100,7 +87,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,72 +104,79 @@ jobs: id: parse-version - name: Compile dotnet app - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x + dotnet-version: 9.0.x - name: Install Swashbuckle CLI - run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli + run: dotnet tool install -g Swashbuckle.AspNetCore.Cli - run: ./monorepo-build.sh - name: Login to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 + if: ${{ github.repository_owner == 'Kareadita' }} 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: Extract metadata (tags, labels) for Docker + id: docker_meta_stable + uses: docker/metadata-action@v5 + with: + tags: | + type=raw,value=latest + type=raw,value=${{ steps.parse-version.outputs.VERSION }} + images: | + name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }} + name=ghcr.io/${{ github.repository }} - name: Build and push stable id: docker_build_stable - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true - tags: jvmilazz0/kavita:latest, jvmilazz0/kavita:${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:latest, ghcr.io/kareadita/kavita:${{ steps.parse-version.outputs.VERSION }} + tags: ${{ steps.docker_meta_stable.outputs.tags }} + labels: ${{ steps.docker_meta_stable.outputs.labels }} + + - name: Extract metadata (tags, labels) for Docker + id: docker_meta_nightly + uses: docker/metadata-action@v5 + with: + tags: | + type=raw,value=nightly + type=raw,value=nightly-${{ steps.parse-version.outputs.VERSION }} + images: | + name=jvmilazz0/kavita,enable=${{ github.repository_owner == 'Kareadita' }} + name=ghcr.io/${{ github.repository }} - name: Build and push nightly id: docker_build_nightly - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: true - tags: jvmilazz0/kavita:nightly, jvmilazz0/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}, ghcr.io/kareadita/kavita:nightly, ghcr.io/kareadita/kavita:nightly-${{ steps.parse-version.outputs.VERSION }} + tags: ${{ steps.docker_meta_nightly.outputs.tags }} + labels: ${{ steps.docker_meta_nightly.outputs.labels }} - name: Image digest run: echo ${{ steps.docker_build_stable.outputs.digest }} - name: Image digest run: echo ${{ steps.docker_build_nightly.outputs.digest }} - - - name: Notify Discord - uses: rjstone/discord-webhook-notify@v1 - with: - severity: info - description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} - details: '${{ steps.findPr.outputs.body }}' - text: <@&939225192553644133> A new stable build has been released. - webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }} - - - name: Notify Discord - uses: rjstone/discord-webhook-notify@v1 - with: - severity: info - description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }} - details: '${{ steps.findPr.outputs.body }}' - text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker. - webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }} diff --git a/.gitignore b/.gitignore index bb124fc7f..1cffb441d 100644 --- a/.gitignore +++ b/.gitignore @@ -513,6 +513,7 @@ UI/Web/dist/ /API/config/stats/ /API/config/bookmarks/ /API/config/favicons/ +/API/config/cache-long/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal @@ -520,9 +521,11 @@ 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/ +API/config/images/* API/config/stats/* API/config/stats/app_stats.json API/config/pre-metadata/ @@ -533,3 +536,11 @@ UI/Web/.vscode/settings.json /API.Tests/Services/Test Data/ArchiveService/CoverImages/output/* UI/Web/.angular/ BenchmarkDotNet.Artifacts + + +API.Tests/Services/Test Data/ImageService/**/*_output* +API.Tests/Services/Test Data/ImageService/**/*_baseline* +API.Tests/Services/Test Data/ImageService/**/*.html + + +API.Tests/Services/Test Data/ScannerService/ScanTests/**/* 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.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index ebc913fe1..38ec425fe 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 Exe @@ -10,9 +10,9 @@ - - - + + + diff --git a/API.Benchmark/ArchiveServiceBenchmark.cs b/API.Benchmark/ArchiveServiceBenchmark.cs index 9ef8e237b..ccb44d517 100644 --- a/API.Benchmark/ArchiveServiceBenchmark.cs +++ b/API.Benchmark/ArchiveServiceBenchmark.cs @@ -32,7 +32,7 @@ public class ArchiveServiceBenchmark public ArchiveServiceBenchmark() { _directoryService = new DirectoryService(null, new FileSystem()); - _imageService = new ImageService(null, _directoryService, Substitute.For()); + _imageService = new ImageService(null, _directoryService); _archiveService = new ArchiveService(new NullLogger(), _directoryService, _imageService, Substitute.For()); } diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs index 3b08bbcdf..511d250aa 100644 --- a/API.Benchmark/TestBenchmark.cs +++ b/API.Benchmark/TestBenchmark.cs @@ -49,7 +49,7 @@ public class TestBenchmark private static void SortSpecialChapters(IEnumerable volumes) { - foreach (var v in volumes.Where(vDto => vDto.MinNumber == 0)) + foreach (var v in volumes.WhereNotLooseLeaf()) { v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList(); } diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 5287a124a..3a4867ec4 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -1,22 +1,22 @@ - net8.0 + net9.0 false - - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -28,7 +28,7 @@ - + diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index 18f0669cd..77f978e7f 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -1,6 +1,5 @@ -using System.Collections.Generic; +using System; using System.Data.Common; -using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -10,6 +9,7 @@ using API.Helpers; using API.Helpers.Builders; using API.Services; using AutoMapper; +using Hangfire; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -18,36 +18,33 @@ using NSubstitute; namespace API.Tests; -public abstract class AbstractDbTest +public abstract class AbstractDbTest : AbstractFsTest , IDisposable { protected readonly DbConnection _connection; protected readonly DataContext _context; protected readonly IUnitOfWork _unitOfWork; - - - protected const string CacheDirectory = "C:/kavita/config/cache/"; - protected const string CoverImageDirectory = "C:/kavita/config/covers/"; - protected const string BackupDirectory = "C:/kavita/config/backups/"; - protected const string LogDirectory = "C:/kavita/config/logs/"; - protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - protected const string SiteThemeDirectory = "C:/kavita/config/themes/"; - protected const string TempDirectory = "C:/kavita/config/temp/"; - protected const string DataDirectory = "C:/data/"; + protected readonly IMapper _mapper; protected AbstractDbTest() { - var contextOptions = new DbContextOptionsBuilder() + var contextOptions = new DbContextOptionsBuilder() .UseSqlite(CreateInMemoryDatabase()) + .EnableSensitiveDataLogging() .Options; + _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; _context = new DataContext(contextOptions); + + _context.Database.EnsureCreated(); // Ensure DB schema is created + Task.Run(SeedDb).GetAwaiter().GetResult(); var config = new MapperConfiguration(cfg => cfg.AddProfile()); - var mapper = config.CreateMapper(); + _mapper = config.CreateMapper(); - _unitOfWork = new UnitOfWork(_context, mapper, null); + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _unitOfWork = new UnitOfWork(_context, _mapper, null); } private static DbConnection CreateInMemoryDatabase() @@ -60,47 +57,66 @@ public abstract class AbstractDbTest private async Task SeedDb() { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); + try + { + await _context.Database.EnsureCreatedAsync(); + var filesystem = CreateFileSystem(); - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); + await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; + var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); + setting.Value = CacheDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); + setting.Value = BackupDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); - setting.Value = BookmarkDirectory; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync(); + setting.Value = BookmarkDirectory; - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); - setting.Value = "10"; + setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync(); + setting.Value = "10"; - _context.ServerSetting.Update(setting); + _context.ServerSetting.Update(setting); - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; + + _context.Library.Add(new LibraryBuilder("Manga") + .WithAllowMetadataMatching(true) + .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) + .Build()); + + await _context.SaveChangesAsync(); + + await Seed.SeedMetadataSettings(_context); + + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[SeedDb] Error: {ex.Message}"); + return false; + } } protected abstract Task ResetDb(); - protected static MockFileSystem CreateFileSystem() + public void Dispose() { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory(SiteThemeDirectory); - fileSystem.AddDirectory(LogDirectory); - fileSystem.AddDirectory(TempDirectory); - fileSystem.AddDirectory(DataDirectory); + _context.Dispose(); + _connection.Dispose(); + } - return fileSystem; + /// + /// Add a role to an existing User. Commits. + /// + /// + /// + protected async Task AddUserWithRole(int userId, string roleName) + { + var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() }; + + await _context.Roles.AddAsync(role); + await _context.UserRoles.AddAsync(new AppUserRole { UserId = userId, RoleId = userId }); + + await _context.SaveChangesAsync(); } } diff --git a/API.Tests/AbstractFsTest.cs b/API.Tests/AbstractFsTest.cs new file mode 100644 index 000000000..3341a3a7c --- /dev/null +++ b/API.Tests/AbstractFsTest.cs @@ -0,0 +1,43 @@ + + +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using API.Services.Tasks.Scanner.Parser; + +namespace API.Tests; + +public abstract class AbstractFsTest +{ + + protected static readonly string Root = Parser.NormalizePath(Path.GetPathRoot(Directory.GetCurrentDirectory())); + protected static readonly string ConfigDirectory = Root + "kavita/config/"; + protected static readonly string CacheDirectory = ConfigDirectory + "cache/"; + protected static readonly string CacheLongDirectory = ConfigDirectory + "cache-long/"; + protected static readonly string CoverImageDirectory = ConfigDirectory + "covers/"; + protected static readonly string BackupDirectory = ConfigDirectory + "backups/"; + protected static readonly string LogDirectory = ConfigDirectory + "logs/"; + protected static readonly string BookmarkDirectory = ConfigDirectory + "bookmarks/"; + protected static readonly string SiteThemeDirectory = ConfigDirectory + "themes/"; + protected static readonly string TempDirectory = ConfigDirectory + "temp/"; + protected static readonly string ThemesDirectory = ConfigDirectory + "theme"; + protected static readonly string DataDirectory = Root + "data/"; + + protected static MockFileSystem CreateFileSystem() + { + var fileSystem = new MockFileSystem(); + fileSystem.Directory.SetCurrentDirectory(Root + "kavita/"); + fileSystem.AddDirectory(Root + "kavita/config/"); + fileSystem.AddDirectory(CacheDirectory); + fileSystem.AddDirectory(CacheLongDirectory); + fileSystem.AddDirectory(CoverImageDirectory); + fileSystem.AddDirectory(BackupDirectory); + fileSystem.AddDirectory(BookmarkDirectory); + fileSystem.AddDirectory(SiteThemeDirectory); + fileSystem.AddDirectory(LogDirectory); + fileSystem.AddDirectory(TempDirectory); + fileSystem.AddDirectory(DataDirectory); + fileSystem.AddDirectory(ThemesDirectory); + + return fileSystem; + } +} diff --git a/API.Tests/Comparers/ChapterSortComparerTest.cs b/API.Tests/Comparers/ChapterSortComparerTest.cs index 220be052d..39a68b3b0 100644 --- a/API.Tests/Comparers/ChapterSortComparerTest.cs +++ b/API.Tests/Comparers/ChapterSortComparerTest.cs @@ -4,15 +4,16 @@ using Xunit; namespace API.Tests.Comparers; -public class ChapterSortComparerTest +public class ChapterSortComparerDefaultLastTest { [Theory] - [InlineData(new[] {1, 2, 0}, new[] {1, 2, 0})] + [InlineData(new[] {1, 2, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber}, new[] {1, 2, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber})] [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})] - [InlineData(new[] {1, 0, 0}, new[] {1, 0, 0})] + [InlineData(new[] {1, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber}, new[] {1, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber})] + [InlineData(new[] {API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, 1}, new[] {1, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber})] public void ChapterSortTest(int[] input, int[] expected) { - Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparer()).ToArray()); + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerDefaultLast()).ToArray()); } } diff --git a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs index df3934884..fbae46b59 100644 --- a/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs +++ b/API.Tests/Comparers/ChapterSortComparerZeroFirstTests.cs @@ -4,7 +4,7 @@ using Xunit; namespace API.Tests.Comparers; -public class ChapterSortComparerZeroFirstTests +public class ChapterSortComparerDefaultFirstTests { [Theory] [InlineData(new[] {1, 2, 0}, new[] {0, 1, 2,})] @@ -12,13 +12,13 @@ public class ChapterSortComparerZeroFirstTests [InlineData(new[] {1, 0, 0}, new[] {0, 0, 1})] public void ChapterSortComparerZeroFirstTest(int[] input, int[] expected) { - Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerDefaultFirst()).ToArray()); } [Theory] - [InlineData(new[] {1.0, 0.5, 0.3}, new[] {0.3, 0.5, 1.0})] - public void ChapterSortComparerZeroFirstTest_Doubles(double[] input, double[] expected) + [InlineData(new [] {1.0f, 0.5f, 0.3f}, new [] {0.3f, 0.5f, 1.0f})] + public void ChapterSortComparerZeroFirstTest_Doubles(float[] input, float[] expected) { - Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerZeroFirst()).ToArray()); + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparerDefaultFirst()).ToArray()); } } diff --git a/API.Tests/Comparers/SortComparerZeroLastTests.cs b/API.Tests/Comparers/SortComparerZeroLastTests.cs index 669ca6c37..9a0722984 100644 --- a/API.Tests/Comparers/SortComparerZeroLastTests.cs +++ b/API.Tests/Comparers/SortComparerZeroLastTests.cs @@ -7,11 +7,11 @@ namespace API.Tests.Comparers; public class SortComparerZeroLastTests { [Theory] - [InlineData(new[] {0, 1, 2,}, new[] {1, 2, 0})] + [InlineData(new[] {API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, 1, 2,}, new[] {1, 2, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber})] [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})] - [InlineData(new[] {0, 0, 1}, new[] {1, 0, 0})] + [InlineData(new[] {API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, 1}, new[] {1, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapterNumber})] public void SortComparerZeroLastTest(int[] input, int[] expected) { - Assert.Equal(expected, input.OrderBy(f => f, SortComparerZeroLast.Default).ToArray()); + Assert.Equal(expected, input.OrderBy(f => f, ChapterSortComparerDefaultLast.Default).ToArray()); } } diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs index 4e214e8f1..5568c89d0 100644 --- a/API.Tests/Converters/CronConverterTests.cs +++ b/API.Tests/Converters/CronConverterTests.cs @@ -1,5 +1,4 @@ using API.Helpers.Converters; -using Hangfire; using Xunit; namespace API.Tests.Converters; diff --git a/API.Tests/Extensions/ChapterListExtensionsTests.cs b/API.Tests/Extensions/ChapterListExtensionsTests.cs index 3b59f1b02..d27903ca9 100644 --- a/API.Tests/Extensions/ChapterListExtensionsTests.cs +++ b/API.Tests/Extensions/ChapterListExtensionsTests.cs @@ -30,7 +30,7 @@ public class ChapterListExtensionsTests { var info = new ParserInfo() { - Chapters = "0", + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", @@ -38,12 +38,12 @@ public class ChapterListExtensionsTests IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = "0" + Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume }; var chapterList = new List() { - CreateChapter("darker than black - Some special", "0", CreateFile("/manga/darker than black - special.cbz", MangaFormat.Archive), true) + CreateChapter("darker than black - Some special", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black - special.cbz", MangaFormat.Archive), true) }; var actualChapter = chapterList.GetChapterByRange(info); @@ -57,7 +57,7 @@ public class ChapterListExtensionsTests { var info = new ParserInfo() { - Chapters = "0", + Chapters = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", @@ -65,12 +65,12 @@ public class ChapterListExtensionsTests IsSpecial = true, Series = "darker than black", Title = "darker than black", - Volumes = "0" + Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume }; var chapterList = new List() { - CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true) + CreateChapter("darker than black", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true) }; var actualChapter = chapterList.GetChapterByRange(info); @@ -83,7 +83,7 @@ public class ChapterListExtensionsTests { var info = new ParserInfo() { - Chapters = "0", + Chapters = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/detective comics #001.cbz", @@ -91,13 +91,39 @@ public class ChapterListExtensionsTests IsSpecial = true, Series = "detective comics", Title = "detective comics", - Volumes = "0" + Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume }; var chapterList = new List() { - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) + CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), + CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) + }; + + var actualChapter = chapterList.GetChapterByRange(info); + + Assert.Equal(chapterList[0], actualChapter); + } + + [Fact] + public void GetChapterByRange_On_FilenameChange_ShouldGetChapter() + { + var info = new ParserInfo() + { + Chapters = "1", + Edition = "", + Format = MangaFormat.Archive, + FullFilePath = "/manga/detective comics #001.cbz", + Filename = "detective comics #001.cbz", + IsSpecial = false, + Series = "detective comics", + Title = "detective comics", + Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume + }; + + var chapterList = new List() + { + CreateChapter("1", "1", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), false), }; var actualChapter = chapterList.GetChapterByRange(info); @@ -112,7 +138,7 @@ public class ChapterListExtensionsTests { var chapterList = new List() { - CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), + CreateChapter("darker than black", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), }; @@ -124,7 +150,7 @@ public class ChapterListExtensionsTests { var chapterList = new List() { - CreateChapter("darker than black", "0", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), + CreateChapter("darker than black", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), true), CreateChapter("darker than black", "1", CreateFile("/manga/darker than black.cbz", MangaFormat.Archive), false), }; @@ -151,8 +177,8 @@ public class ChapterListExtensionsTests { var chapterList = new List() { - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) + CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), + CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) }; chapterList[0].ReleaseDate = new DateTime(10, 1, 1); @@ -166,8 +192,8 @@ public class ChapterListExtensionsTests { var chapterList = new List() { - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), - CreateChapter("detective comics", "0", CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) + CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true), + CreateChapter("detective comics", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, CreateFile("/manga/detective comics #001.cbz", MangaFormat.Archive), true) }; chapterList[0].ReleaseDate = new DateTime(2002, 1, 1); diff --git a/API.Tests/Extensions/EncodeFormatExtensionsTests.cs b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs new file mode 100644 index 000000000..a02de84aa --- /dev/null +++ b/API.Tests/Extensions/EncodeFormatExtensionsTests.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Entities.Enums; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class EncodeFormatExtensionsTests +{ + [Fact] + public void GetExtension_ShouldReturnCorrectExtensionForAllValues() + { + // Arrange + var expectedExtensions = new Dictionary + { + { EncodeFormat.PNG, ".png" }, + { EncodeFormat.WEBP, ".webp" }, + { EncodeFormat.AVIF, ".avif" } + }; + + // Act & Assert + foreach (var format in Enum.GetValues(typeof(EncodeFormat)).Cast()) + { + var extension = format.GetExtension(); + Assert.Equal(expectedExtensions[format], extension); + } + } + +} 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/ParserInfoListExtensionsTests.cs b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs index 6ea35e471..227dd2b32 100644 --- a/API.Tests/Extensions/ParserInfoListExtensionsTests.cs +++ b/API.Tests/Extensions/ParserInfoListExtensionsTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using API.Entities.Enums; @@ -6,7 +7,6 @@ using API.Extensions; using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner.Parser; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -18,9 +18,8 @@ public class ParserInfoListExtensions private readonly IDefaultParser _defaultParser; public ParserInfoListExtensions() { - _defaultParser = - new DefaultParser(new DirectoryService(Substitute.For>(), - new MockFileSystem())); + var ds = new DirectoryService(Substitute.For>(), new MockFileSystem()); + _defaultParser = new BasicParser(ds, new ImageParser(ds)); } [Theory] @@ -33,7 +32,7 @@ public class ParserInfoListExtensions [Theory] [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] - [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06-07) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, true)] + [InlineData(new[] {@"Cynthia The Mission - c000-006 (v06-07) [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, false)] [InlineData(new[] {@"Cynthia The Mission v20 c12-20 [Desudesu&Brolen].zip"}, new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip"}, false)] public void HasInfoTest(string[] inputInfos, string[] inputChapters, bool expectedHasInfo) { @@ -41,8 +40,8 @@ public class ParserInfoListExtensions foreach (var filename in inputInfos) { infos.Add(_defaultParser.Parse( - filename, - string.Empty)); + Path.Join("E:/Manga/Cynthia the Mission/", filename), + "E:/Manga/", "E:/Manga/", LibraryType.Manga)); } var files = inputChapters.Select(s => new MangaFileBuilder(s, MangaFormat.Archive, 199).Build()).ToList(); @@ -52,4 +51,26 @@ public class ParserInfoListExtensions Assert.Equal(expectedHasInfo, infos.HasInfo(chapter)); } + + [Fact] + public void HasInfoTest_SuccessWhenSpecial() + { + var infos = new[] + { + _defaultParser.Parse( + "E:/Manga/Cynthia the Mission/Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip", + "E:/Manga/", "E:/Manga/", LibraryType.Manga) + }; + + var files = new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip"} + .Select(s => new MangaFileBuilder(s, MangaFormat.Archive, 199).Build()) + .ToList(); + var chapter = new ChapterBuilder("Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip") + .WithRange("Cynthia The Mission The Special SP01 [Desudesu&Brolen]") + .WithFiles(files) + .WithIsSpecial(true) + .Build(); + + Assert.True(infos.HasInfo(chapter)); + } } diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 230028d44..866e0202c 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -1,11 +1,9 @@ using System.Collections.Generic; using System.Linq; -using API.Data; using API.Data.Misc; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; +using API.Entities.Person; using API.Extensions.QueryExtensions; using API.Helpers.Builders; using Xunit; @@ -45,17 +43,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(), }; @@ -123,29 +121,46 @@ public class QueryableExtensionsTests [Theory] [InlineData(true, 2)] - [InlineData(false, 1)] - public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + [InlineData(false, 2)] + public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedPeopleCount) { - var items = new List() + // Arrange + var items = new List { - new PersonBuilder("Test", PersonRole.Character) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) - .Build(), - new PersonBuilder("Test", PersonRole.Character) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) - .Build(), - new PersonBuilder("Test", PersonRole.Character) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) - .Build(), + CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen), + CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), // 2 series on this person, restrict will still allow access + CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus) }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var ageRestriction = new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns - }); - Assert.Equal(expectedCount, filtered.Count()); + }; + + // Act + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction); + + // Assert + Assert.Equal(expectedPeopleCount, filtered.Count()); + } + + private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings) + { + var person = new PersonBuilder(name).Build(); + + foreach (var ageRating in ageRatings) + { + var seriesMetadata = new SeriesMetadataBuilder().WithAgeRating(ageRating).Build(); + person.SeriesMetadataPeople.Add(new SeriesMetadataPeople + { + SeriesMetadata = seriesMetadata, + Person = person, + Role = PersonRole.Character // Role is now part of the relationship + }); + } + + return person; } [Theory] diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index c14de4439..adaecfba5 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -1,11 +1,9 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Linq; +using System.Linq; using API.Comparators; -using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; +using API.Services.Tasks.Scanner.Parser; using Xunit; namespace API.Tests.Extensions; @@ -17,22 +15,23 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithCoverImage("Special 1") .WithIsSpecial(true) + .WithSortOrder(Parser.SpecialVolumeNumber + 1) .Build()) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithCoverImage("Special 2") .WithIsSpecial(true) + .WithSortOrder(Parser.SpecialVolumeNumber + 2) .Build()) .Build()) .Build(); foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Special 1", series.GetCoverImage()); @@ -43,8 +42,8 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("13") .WithCoverImage("Chapter 13") .Build()) @@ -59,7 +58,7 @@ public class SeriesExtensionsTests .WithVolume(new VolumeBuilder("2") .WithName("Volume 2") - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithCoverImage("Volume 2") .Build()) .Build()) @@ -67,12 +66,83 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Volume 1 Chapter 1", series.GetCoverImage()); } + [Fact] + public void GetCoverImage_LooseChapters_WithSub1_Chapter() + { + var series = new SeriesBuilder("Test 1") + .WithFormat(MangaFormat.Archive) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("-1") + .WithCoverImage("Chapter -1") + .Build()) + .WithChapter(new ChapterBuilder("0.5") + .WithCoverImage("Chapter 0.5") + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithCoverImage("Chapter 2") + .Build()) + .WithChapter(new ChapterBuilder("1") + .WithCoverImage("Chapter 1") + .Build()) + .WithChapter(new ChapterBuilder("3") + .WithCoverImage("Chapter 3") + .Build()) + .WithChapter(new ChapterBuilder("4AU") + .WithCoverImage("Chapter 4AU") + .Build()) + .Build()) + + .Build(); + + + Assert.Equal("Chapter 1", series.GetCoverImage()); + } + + /// + /// Checks the case where there are specials and loose leafs, loose leaf chapters should be preferred + /// + [Fact] + public void GetCoverImage_LooseChapters_WithSub1_Chapter_WithSpecials() + { + var series = new SeriesBuilder("Test 1") + .WithFormat(MangaFormat.Archive) + + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithName(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("I am a Special") + .WithCoverImage("I am a Special") + .Build()) + .WithChapter(new ChapterBuilder("I am a Special 2") + .WithCoverImage("I am a Special 2") + .Build()) + .Build()) + + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("0.5") + .WithCoverImage("Chapter 0.5") + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithCoverImage("Chapter 2") + .Build()) + .WithChapter(new ChapterBuilder("1") + .WithCoverImage("Chapter 1") + .Build()) + .Build()) + + .Build(); + + + Assert.Equal("Chapter 1", series.GetCoverImage()); + } + [Fact] public void GetCoverImage_JustVolumes() { @@ -81,14 +151,14 @@ public class SeriesExtensionsTests .WithVolume(new VolumeBuilder("1") .WithName("Volume 1") - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithCoverImage("Volume 1 Chapter 1") .Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithName("Volume 2") - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithCoverImage("Volume 2") .Build()) .Build()) @@ -109,19 +179,48 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Volume 1 Chapter 1", series.GetCoverImage()); } + [Fact] + public void GetCoverImage_JustVolumes_ButVolume0() + { + var series = new SeriesBuilder("Test 1") + .WithFormat(MangaFormat.Archive) + + .WithVolume(new VolumeBuilder("0") + .WithName("Volume 0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) + .WithCoverImage("Volume 0") + .Build()) + .Build()) + + .WithVolume(new VolumeBuilder("1") + .WithName("Volume 1") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) + .WithCoverImage("Volume 1") + .Build()) + .Build()) + .Build(); + + foreach (var vol in series.Volumes) + { + vol.CoverImage = vol.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default)?.CoverImage; + } + + Assert.Equal("Volume 1", series.GetCoverImage()); + } + [Fact] public void GetCoverImage_JustSpecials_WithDecimal() { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("2.5") .WithIsSpecial(false) .WithCoverImage("Special 1") @@ -135,7 +234,7 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Special 2", series.GetCoverImage()); @@ -146,8 +245,8 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("2.5") .WithIsSpecial(false) .WithCoverImage("Chapter 2.5") @@ -156,16 +255,19 @@ public class SeriesExtensionsTests .WithIsSpecial(false) .WithCoverImage("Chapter 2") .Build()) - .WithChapter(new ChapterBuilder("0") + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(true) .WithCoverImage("Special 1") + .WithSortOrder(Parser.SpecialVolumeNumber + 1) .Build()) - .Build()) + .Build()) .Build(); foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Chapter 2", series.GetCoverImage()); @@ -176,8 +278,8 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("2.5") .WithIsSpecial(false) .WithCoverImage("Chapter 2.5") @@ -186,14 +288,17 @@ public class SeriesExtensionsTests .WithIsSpecial(false) .WithCoverImage("Chapter 2") .Build()) - .WithChapter(new ChapterBuilder("0") + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(true) .WithCoverImage("Special 3") + .WithSortOrder(Parser.SpecialVolumeNumber + 1) .Build()) .Build()) .WithVolume(new VolumeBuilder("1") .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(false) .WithCoverImage("Volume 1") .Build()) @@ -202,7 +307,7 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Volume 1", series.GetCoverImage()); @@ -213,8 +318,8 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("2.5") .WithIsSpecial(false) .WithCoverImage("Chapter 2.5") @@ -223,14 +328,17 @@ public class SeriesExtensionsTests .WithIsSpecial(false) .WithCoverImage("Chapter 2") .Build()) - .WithChapter(new ChapterBuilder("0") + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(true) .WithCoverImage("Special 1") + .WithSortOrder(Parser.SpecialVolumeNumber + 1) .Build()) .Build()) .WithVolume(new VolumeBuilder("1") .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(false) .WithCoverImage("Volume 1") .Build()) @@ -239,7 +347,7 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Volume 1", series.GetCoverImage()); @@ -250,8 +358,8 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Ippo") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1426") .WithIsSpecial(false) .WithCoverImage("Chapter 1426") @@ -260,21 +368,24 @@ public class SeriesExtensionsTests .WithIsSpecial(false) .WithCoverImage("Chapter 1425") .Build()) - .WithChapter(new ChapterBuilder("0") + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(true) - .WithCoverImage("Special 1") + .WithCoverImage("Special 3") + .WithSortOrder(Parser.SpecialVolumeNumber + 1) .Build()) .Build()) .WithVolume(new VolumeBuilder("1") .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(false) .WithCoverImage("Volume 1") .Build()) .Build()) .WithVolume(new VolumeBuilder("137") .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(false) .WithCoverImage("Volume 137") .Build()) @@ -283,7 +394,7 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Volume 1", series.GetCoverImage()); @@ -294,8 +405,8 @@ public class SeriesExtensionsTests { var series = new SeriesBuilder("Test 1") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("0") - .WithName(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("2.5") .WithIsSpecial(false) .WithCoverImage("Chapter 2.5") @@ -307,7 +418,7 @@ public class SeriesExtensionsTests .Build()) .WithVolume(new VolumeBuilder("4") .WithMinNumber(4) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) .WithIsSpecial(false) .WithCoverImage("Volume 4") .Build()) @@ -316,11 +427,77 @@ public class SeriesExtensionsTests foreach (var vol in series.Volumes) { - vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), ChapterSortComparerZeroFirst.Default)?.CoverImage; + vol.CoverImage = vol.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; } Assert.Equal("Chapter 2", series.GetCoverImage()); } + /// + /// Ensure that Series cover is issue 1, when there are less than 1 entities and specials + /// + [Fact] + public void GetCoverImage_LessThanIssue1() + { + var series = new SeriesBuilder("Test 1") + .WithFormat(MangaFormat.Archive) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("0") + .WithIsSpecial(false) + .WithCoverImage("Chapter 0") + .Build()) + .WithChapter(new ChapterBuilder("1") + .WithIsSpecial(false) + .WithCoverImage("Chapter 1") + .Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithMinNumber(4) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) + .WithIsSpecial(false) + .WithCoverImage("Volume 4") + .Build()) + .Build()) + .Build(); + + Assert.Equal("Chapter 1", series.GetCoverImage()); + } + + /// + /// Ensure that Series cover is issue 1, when there are less than 1 entities and specials + /// + [Fact] + public void GetCoverImage_LessThanIssue1_WithNegative() + { + var series = new SeriesBuilder("Test 1") + .WithFormat(MangaFormat.Archive) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithName(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("-1") + .WithIsSpecial(false) + .WithCoverImage("Chapter -1") + .Build()) + .WithChapter(new ChapterBuilder("0") + .WithIsSpecial(false) + .WithCoverImage("Chapter 0") + .Build()) + .WithChapter(new ChapterBuilder("1") + .WithIsSpecial(false) + .WithCoverImage("Chapter 1") + .Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithMinNumber(4) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) + .WithIsSpecial(false) + .WithCoverImage("Volume 4") + .Build()) + .Build()) + .Build(); + + Assert.Equal("Chapter 1", series.GetCoverImage()); + } + } diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 2774ad78e..577e17619 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -1,28 +1,1342 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.DTOs; using API.DTOs.Filtering.v2; +using API.DTOs.Progress; +using API.Entities; +using API.Entities.Enums; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.SignalR; +using Kavita.Common; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Extensions; public class SeriesFilterTests : AbstractDbTest { - - protected override Task ResetDb() + protected override async Task ResetDb() { - return Task.CompletedTask; + _context.Series.RemoveRange(_context.Series); + _context.AppUser.RemoveRange(_context.AppUser); + await _context.SaveChangesAsync(); } + #region HasProgress + + private async Task SetupHasProgress() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("None").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Partial").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Full").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + + // Create read progress on Partial and Full + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + + // Select Partial and set pages read to 5 on first chapter + var partialSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + var partialChapter = partialSeries.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = partialChapter.Id, + LibraryId = 1, + SeriesId = partialSeries.Id, + PageNum = 5, + VolumeId = partialChapter.VolumeId + }, user.Id)); + + // Select Full and set pages read to 10 on first chapter + var fullSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + var fullChapter = fullSeries.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = fullChapter.Id, + LibraryId = 1, + SeriesId = fullSeries.Id, + PageNum = 10, + VolumeId = fullChapter.VolumeId + }, user.Id)); + + return user; + } + + [Fact] + public async Task HasProgress_LessThan50_ShouldReturnSingle() + { + var user = await SetupHasProgress(); + + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 50, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("None", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_LessThanOrEqual50_ShouldReturnTwo() + { + var user = await SetupHasProgress(); + + // Query series with progress <= 50% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 50, user.Id) + .ToListAsync(); + + Assert.Equal(2, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasProgress_GreaterThan50_ShouldReturnFull() + { + var user = await SetupHasProgress(); + + // Query series with progress > 50% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.GreaterThan, 50, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("Full", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_Equal100_ShouldReturnFull() + { + var user = await SetupHasProgress(); + + // Query series with progress == 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.Equal, 100, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("Full", queryResult.First().Name); + } + + [Fact] + public async Task HasProgress_LessThan100_ShouldReturnTwo() + { + var user = await SetupHasProgress(); + + // Query series with progress < 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + .ToListAsync(); + + Assert.Equal(2, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasProgress_LessThanOrEqual100_ShouldReturnAll() + { + var user = await SetupHasProgress(); + + // Query series with progress <= 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThanEqual, 100, user.Id) + .ToListAsync(); + + Assert.Equal(3, queryResult.Count); + Assert.Contains(queryResult, s => s.Name == "None"); + Assert.Contains(queryResult, s => s.Name == "Partial"); + Assert.Contains(queryResult, s => s.Name == "Full"); + } + + [Fact] + public async Task HasProgress_LessThan100_WithProgress99_99_ShouldReturnSeries() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("AlmostFull").WithPages(100) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(100).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); + + // Set progress to 99.99% (99/100 pages read) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + var chapter = series.Volumes.First().Chapters.First(); + + Assert.True(await readerService.SaveReadingProgress(new ProgressDto() + { + ChapterId = chapter.Id, + LibraryId = 1, + SeriesId = series.Id, + PageNum = 99, + VolumeId = chapter.VolumeId + }, user.Id)); + + // Query series with progress < 100% + var queryResult = await _context.Series.HasReadingProgress(true, FilterComparison.LessThan, 100, user.Id) + .ToListAsync(); + + Assert.Single(queryResult); + Assert.Equal("AlmostFull", queryResult.First().Name); + } + #endregion + #region HasLanguage - [Fact] - public async Task HasLanguage_Works() + private async Task SetupHasLanguage() { - var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, new List() { }).ToListAsync(); + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("English").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("en").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("French").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("fr").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Spanish").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithLanguage("es").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasLanguage_Equal_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, ["en"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("en", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_NotEqual_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotEqual, ["en"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.DoesNotContain(foundSeries, s => s.Metadata.Language == "en"); + } + + [Fact] + public async Task HasLanguage_Contains_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Contains, ["en", "fr"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Metadata.Language == "en"); + Assert.Contains(foundSeries, s => s.Metadata.Language == "fr"); + } + + [Fact] + public async Task HasLanguage_NotContains_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.NotContains, ["en", "fr"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("es", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_MustContains_Works() + { + await SetupHasLanguage(); + + // Since "MustContains" matches all the provided languages, no series should match in this case. + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en", "fr"]).ToListAsync(); + Assert.Empty(foundSeries); + + // Single language should work. + foundSeries = await _context.Series.HasLanguage(true, FilterComparison.MustContains, ["en"]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("en", foundSeries[0].Metadata.Language); + } + + [Fact] + public async Task HasLanguage_Matches_Works() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Matches, ["e"]).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains("en", foundSeries.Select(s => s.Metadata.Language)); + Assert.Contains("es", foundSeries.Select(s => s.Metadata.Language)); + } + + [Fact] + public async Task HasLanguage_DisabledCondition_ReturnsAll() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(false, FilterComparison.Equal, ["en"]).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasLanguage_EmptyLanguageList_ReturnsAll() + { + await SetupHasLanguage(); + + var foundSeries = await _context.Series.HasLanguage(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasLanguage_UnsupportedComparison_ThrowsException() + { + await SetupHasLanguage(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasLanguage(true, FilterComparison.GreaterThan, ["en"]).ToListAsync(); + }); } + #endregion + + #region HasAverageRating + + private async Task SetupHasAverageRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("None").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(-1).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Partial").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(50).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Full").WithPages(10) + .WithExternalMetadata(new ExternalSeriesMetadataBuilder().WithAverageExternalRating(100).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasAverageRating_Equal_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, 100).ToListAsync(); + Assert.Single(series); + Assert.Equal("Full", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_GreaterThan_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThan, 50).ToListAsync(); + Assert.Single(series); + Assert.Equal("Full", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_GreaterThanEqual_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.GreaterThanEqual, 50).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.Contains(series, s => s.Name == "Partial"); + Assert.Contains(series, s => s.Name == "Full"); + } + + [Fact] + public async Task HasAverageRating_LessThan_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThan, 50).ToListAsync(); + Assert.Single(series); + Assert.Equal("None", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_LessThanEqual_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.LessThanEqual, 50).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.Contains(series, s => s.Name == "None"); + Assert.Contains(series, s => s.Name == "Partial"); + } + + [Fact] + public async Task HasAverageRating_NotEqual_Works() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.NotEqual, 100).ToListAsync(); + Assert.Equal(2, series.Count); + Assert.DoesNotContain(series, s => s.Name == "Full"); + } + + [Fact] + public async Task HasAverageRating_ConditionFalse_ReturnsAll() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(false, FilterComparison.Equal, 100).ToListAsync(); + Assert.Equal(3, series.Count); + } + + [Fact] + public async Task HasAverageRating_NotSet_IsHandled() + { + await SetupHasAverageRating(); + + var series = await _context.Series.HasAverageRating(true, FilterComparison.Equal, -1).ToListAsync(); + Assert.Single(series); + Assert.Equal("None", series[0].Name); + } + + [Fact] + public async Task HasAverageRating_ThrowsForInvalidComparison() + { + await SetupHasAverageRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAverageRating(true, FilterComparison.Contains, 50).ToListAsync(); + }); + } + + [Fact] + public async Task HasAverageRating_ThrowsForOutOfRangeComparison() + { + await SetupHasAverageRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAverageRating(true, (FilterComparison)999, 50).ToListAsync(); + }); + } + + #endregion + + # region HasPublicationStatus + + private async Task SetupHasPublicationStatus() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Cancelled").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Cancelled).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("OnGoing").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.OnGoing).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Completed").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithPublicationStatus(PublicationStatus.Completed).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasPublicationStatus_Equal_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("Cancelled", foundSeries[0].Name); + } + + [Fact] + public async Task HasPublicationStatus_Contains_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Contains, new List { PublicationStatus.Cancelled, PublicationStatus.Completed }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Cancelled"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_NotContains_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotContains, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "OnGoing"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_NotEqual_Works() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.NotEqual, new List { PublicationStatus.OnGoing }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Cancelled"); + Assert.Contains(foundSeries, s => s.Name == "Completed"); + } + + [Fact] + public async Task HasPublicationStatus_ConditionFalse_ReturnsAll() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(false, FilterComparison.Equal, new List { PublicationStatus.Cancelled }).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasPublicationStatus_EmptyPubStatuses_ReturnsAll() + { + await SetupHasPublicationStatus(); + + var foundSeries = await _context.Series.HasPublicationStatus(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasPublicationStatus_ThrowsForInvalidComparison() + { + await SetupHasPublicationStatus(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasPublicationStatus(true, FilterComparison.BeginsWith, new List { PublicationStatus.Cancelled }).ToListAsync(); + }); + } + + [Fact] + public async Task HasPublicationStatus_ThrowsForOutOfRangeComparison() + { + await SetupHasPublicationStatus(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasPublicationStatus(true, (FilterComparison)999, new List { PublicationStatus.Cancelled }).ToListAsync(); + }); + } + #endregion + + #region HasAgeRating + private async Task SetupHasAgeRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Unknown").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("G").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.G).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Mature").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Mature).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasAgeRating_Equal_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, [AgeRating.G]).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("G", foundSeries[0].Name); + } + + [Fact] + public async Task HasAgeRating_Contains_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Contains, new List { AgeRating.G, AgeRating.Mature }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_NotContains_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotContains, new List { AgeRating.Unknown }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_NotEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.NotEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_GreaterThan_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThan, new List { AgeRating.Unknown }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_GreaterThanEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.GreaterThanEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "G"); + Assert.Contains(foundSeries, s => s.Name == "Mature"); + } + + [Fact] + public async Task HasAgeRating_LessThan_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThan, new List { AgeRating.Mature }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "G"); + } + + [Fact] + public async Task HasAgeRating_LessThanEqual_Works() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.LessThanEqual, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "Unknown"); + Assert.Contains(foundSeries, s => s.Name == "G"); + } + + [Fact] + public async Task HasAgeRating_ConditionFalse_ReturnsAll() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(false, FilterComparison.Equal, new List { AgeRating.G }).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasAgeRating_EmptyRatings_ReturnsAll() + { + await SetupHasAgeRating(); + + var foundSeries = await _context.Series.HasAgeRating(true, FilterComparison.Equal, new List()).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasAgeRating_ThrowsForInvalidComparison() + { + await SetupHasAgeRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAgeRating(true, FilterComparison.BeginsWith, new List { AgeRating.G }).ToListAsync(); + }); + } + + [Fact] + public async Task HasAgeRating_ThrowsForOutOfRangeComparison() + { + await SetupHasAgeRating(); + + await Assert.ThrowsAsync(async () => + { + await _context.Series.HasAgeRating(true, (FilterComparison)999, new List { AgeRating.G }).ToListAsync(); + }); + } + + #endregion + + #region HasReleaseYear + + private async Task SetupHasReleaseYear() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("2000").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2000).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("2020").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2020).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("2025").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2025).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasReleaseYear_Equal_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, 2020).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("2020", foundSeries[0].Name); + } + + [Fact] + public async Task HasReleaseYear_GreaterThan_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.GreaterThan, 2000).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "2020"); + Assert.Contains(foundSeries, s => s.Name == "2025"); + } + + [Fact] + public async Task HasReleaseYear_LessThan_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.LessThan, 2025).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + Assert.Contains(foundSeries, s => s.Name == "2000"); + Assert.Contains(foundSeries, s => s.Name == "2020"); + } + + [Fact] + public async Task HasReleaseYear_IsInLast_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsInLast, 5).ToListAsync(); + Assert.Equal(2, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_IsNotInLast_Works() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsNotInLast, 5).ToListAsync(); + Assert.Single(foundSeries); + Assert.Contains(foundSeries, s => s.Name == "2000"); + } + + [Fact] + public async Task HasReleaseYear_ConditionFalse_ReturnsAll() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(false, FilterComparison.Equal, 2020).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_ReleaseYearNull_ReturnsAll() + { + await SetupHasReleaseYear(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.Equal, null).ToListAsync(); + Assert.Equal(3, foundSeries.Count); + } + + [Fact] + public async Task HasReleaseYear_IsEmpty_Works() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("EmptyYear").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(0).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + var foundSeries = await _context.Series.HasReleaseYear(true, FilterComparison.IsEmpty, 0).ToListAsync(); + Assert.Single(foundSeries); + Assert.Equal("EmptyYear", foundSeries[0].Name); + } + + + #endregion + + #region HasRating + + private async Task SetupHasRating() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("No Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("0 Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("4.5 Rating").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + + var seriesService = new SeriesService(_unitOfWork, Substitute.For(), + Substitute.For(), Substitute.For>(), + Substitute.For(), Substitute.For(), + Substitute.For()); + + // Select 0 Rating + var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); + Assert.NotNull(zeroRating); + + Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + { + SeriesId = zeroRating.Id, + UserRating = 0 + })); + + // Select 4.5 Rating + var partialRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3); + + Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + { + SeriesId = partialRating.Id, + UserRating = 4.5f + })); + + return user; + } + + [Fact] + public async Task HasRating_Equal_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.Equal, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_GreaterThan_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.GreaterThan, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_LessThan_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.LessThan, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("0 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_IsEmpty_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.IsEmpty, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("No Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_GreaterThanEqual_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.GreaterThanEqual, 4.5f, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("4.5 Rating", foundSeries[0].Name); + } + + [Fact] + public async Task HasRating_LessThanEqual_Works() + { + var user = await SetupHasRating(); + + var foundSeries = await _context.Series + .HasRating(true, FilterComparison.LessThanEqual, 0, user.Id) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("0 Rating", foundSeries[0].Name); + } + + #endregion + + #region HasAverageReadTime + + + + #endregion + + #region HasReadLast + + + + #endregion + + #region HasReadingDate + + + + #endregion + + #region HasTags + + + + #endregion + + #region HasPeople + + + + #endregion + + #region HasGenre + + + + #endregion + + #region HasFormat + + + + #endregion + + #region HasCollectionTags + + + + #endregion + + #region HasName + + private async Task SetupHasName() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Don't Toy With Me, Miss Nagatoro").WithLocalizedName("Ijiranaide, Nagatoro-san").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("My Dress-Up Darling").WithLocalizedName("Sono Bisque Doll wa Koi wo Suru").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasName_Equal_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.Equal, "My Dress-Up Darling") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_Equal_LocalizedName_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.Equal, "Ijiranaide, Nagatoro-san") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_BeginsWith_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.BeginsWith, "My Dress") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_BeginsWith_LocalizedName_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.BeginsWith, "Sono Bisque") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("My Dress-Up Darling", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_EndsWith_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.EndsWith, "Nagatoro") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_Matches_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.Matches, "Toy With Me") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + [Fact] + public async Task HasName_NotEqual_Works() + { + await SetupHasName(); + + var foundSeries = await _context.Series + .HasName(true, FilterComparison.NotEqual, "My Dress-Up Darling") + .ToListAsync(); + + Assert.Equal(2, foundSeries.Count); + Assert.Equal("Don't Toy With Me, Miss Nagatoro", foundSeries[0].Name); + } + + + #endregion + + #region HasSummary + + private async Task SetupHasSummary() + { + var library = new LibraryBuilder("Manga") + .WithSeries(new SeriesBuilder("Hippos").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like hippos").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Apples").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like apples").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("Ducks").WithPages(10) + .WithMetadata(new SeriesMetadataBuilder().WithSummary("I like ducks").Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("No Summary").WithPages(10) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").WithPages(10).Build()) + .Build()) + .Build()) + .Build(); + var user = new AppUserBuilder("user", "user@gmail.com") + .WithLibrary(library) + .Build(); + + _context.Users.Add(user); + _context.Library.Add(library); + await _context.SaveChangesAsync(); + + return user; + } + + [Fact] + public async Task HasSummary_Equal_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.Equal, "I like hippos") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Hippos", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_BeginsWith_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.BeginsWith, "I like h") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Hippos", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_EndsWith_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.EndsWith, "apples") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Apples", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_Matches_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.Matches, "like ducks") + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("Ducks", foundSeries[0].Name); + } + + [Fact] + public async Task HasSummary_NotEqual_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.NotEqual, "I like ducks") + .ToListAsync(); + + Assert.Equal(3, foundSeries.Count); + Assert.DoesNotContain(foundSeries, s => s.Name == "Ducks"); + } + + [Fact] + public async Task HasSummary_IsEmpty_Works() + { + await SetupHasSummary(); + + var foundSeries = await _context.Series + .HasSummary(true, FilterComparison.IsEmpty, string.Empty) + .ToListAsync(); + + Assert.Single(foundSeries); + Assert.Equal("No Summary", foundSeries[0].Name); + } + + #endregion + + + #region HasPath + + + + #endregion + + + #region HasFilePath + + + #endregion } diff --git a/API.Tests/Extensions/VersionExtensionTests.cs b/API.Tests/Extensions/VersionExtensionTests.cs new file mode 100644 index 000000000..e19fd7312 --- /dev/null +++ b/API.Tests/Extensions/VersionExtensionTests.cs @@ -0,0 +1,81 @@ +using System; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class VersionHelperTests +{ + [Fact] + public void CompareWithoutRevision_ShouldReturnTrue_WhenMajorMinorBuildMatch() + { + // Arrange + var v1 = new Version(1, 2, 3, 4); + var v2 = new Version(1, 2, 3, 5); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Fact] + public void CompareWithoutRevision_ShouldHandleBuildlessVersions() + { + // Arrange + var v1 = new Version(1, 2); + var v2 = new Version(1, 2); + + // Act + var result = v1.CompareWithoutRevision(v2); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 2, 4)] + [InlineData(1, 2, 3, 1, 2, 0)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenBuildDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 1, 3, 3)] + [InlineData(1, 2, 3, 1, 0, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMinorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } + + [Theory] + [InlineData(1, 2, 3, 2, 2, 3)] + [InlineData(1, 2, 3, 0, 2, 3)] + public void CompareWithoutRevision_ShouldReturnFalse_WhenMajorDiffers( + int major1, int minor1, int build1, + int major2, int minor2, int build2) + { + var v1 = new Version(major1, minor1, build1); + var v2 = new Version(major2, minor2, build2); + + var result = v1.CompareWithoutRevision(v2); + + Assert.False(result); + } +} diff --git a/API.Tests/Extensions/VolumeListExtensionsTests.cs b/API.Tests/Extensions/VolumeListExtensionsTests.cs index e64267896..bbb8f215c 100644 --- a/API.Tests/Extensions/VolumeListExtensionsTests.cs +++ b/API.Tests/Extensions/VolumeListExtensionsTests.cs @@ -3,7 +3,6 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers.Builders; -using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions; @@ -21,12 +20,43 @@ public class VolumeListExtensionsTests .WithChapter(new ChapterBuilder("3").Build()) .WithChapter(new ChapterBuilder("4").Build()) .Build(), - new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithIsSpecial(true).Build()) + .Build(), + + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) .Build(), }; + var v = volumes.GetCoverImage(MangaFormat.Archive); + Assert.Equal(volumes[0].MinNumber, volumes.GetCoverImage(MangaFormat.Archive).MinNumber); + } + + [Fact] + public void GetCoverImage_ChoosesVolume1_WhenHalf() + { + var volumes = new List() + { + new VolumeBuilder("1") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) + .Build(), + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("0.5").Build()) + .Build(), + + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) + .Build(), + }; + + var v = volumes.GetCoverImage(MangaFormat.Archive); Assert.Equal(volumes[0].MinNumber, volumes.GetCoverImage(MangaFormat.Archive).MinNumber); } @@ -39,9 +69,14 @@ public class VolumeListExtensionsTests .WithChapter(new ChapterBuilder("3").Build()) .WithChapter(new ChapterBuilder("4").Build()) .Build(), - new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithIsSpecial(true).Build()) + .Build(), + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) .Build(), }; @@ -57,9 +92,14 @@ public class VolumeListExtensionsTests .WithChapter(new ChapterBuilder("3").Build()) .WithChapter(new ChapterBuilder("4").Build()) .Build(), - new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithIsSpecial(true).Build()) + .Build(), + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) .Build(), }; @@ -75,9 +115,14 @@ public class VolumeListExtensionsTests .WithChapter(new ChapterBuilder("3").Build()) .WithChapter(new ChapterBuilder("4").Build()) .Build(), - new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithIsSpecial(true).Build()) + .Build(), + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) .Build(), }; @@ -95,7 +140,12 @@ public class VolumeListExtensionsTests .Build(), new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) + .Build(), + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) .Build(), }; diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 82f496a7b..3962ba2df 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.IO.Abstractions.TestingHelpers; -using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Helpers.Builders; @@ -11,9 +10,9 @@ using Xunit; namespace API.Tests.Helpers; -public class CacheHelperTests +public class CacheHelperTests: AbstractFsTest { - private const string TestCoverImageDirectory = @"c:\"; + private static readonly string TestCoverImageDirectory = Root; private const string TestCoverImageFile = "thumbnail.jpg"; private readonly string _testCoverPath = Path.Join(TestCoverImageDirectory, TestCoverImageFile); private const string TestCoverArchive = @"file in folder.zip"; @@ -37,24 +36,29 @@ public class CacheHelperTests [Theory] [InlineData("", false)] - [InlineData("C:/", false)] [InlineData(null, false)] public void CoverImageExists_DoesFileExist(string coverImage, bool exists) { Assert.Equal(exists, _cacheHelper.CoverImageExists(coverImage)); } + [Fact] + public void CoverImageExists_DoesFileExistRoot() + { + Assert.False(_cacheHelper.CoverImageExists(Root)); + } + [Fact] public void CoverImageExists_FileExists() { - Assert.True(_cacheHelper.CoverImageExists(TestCoverArchive)); + Assert.True(_cacheHelper.CoverImageExists(Path.Join(TestCoverImageDirectory, TestCoverArchive))); } [Fact] public void ShouldUpdateCoverImage_OnFirstRun() { - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.True(_cacheHelper.ShouldUpdateCoverImage(null, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -65,7 +69,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -76,7 +80,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked_2() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now, @@ -87,7 +91,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -98,7 +102,7 @@ public class CacheHelperTests public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked_Modified() { // Represents first run - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now) .Build(); Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), @@ -122,7 +126,7 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var created = DateTime.Now.Subtract(TimeSpan.FromHours(1)); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) .WithLastModified(DateTime.Now.Subtract(TimeSpan.FromMinutes(1))) .Build(); @@ -133,9 +137,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceCreated() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime =now, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -147,12 +152,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } @@ -160,9 +165,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = now, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -174,12 +180,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.True(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); @@ -188,9 +194,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified_ForceUpdate() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime = now.DateTime, }; var fileSystem = new MockFileSystem(new Dictionary { @@ -202,12 +209,12 @@ public class CacheHelperTests var cacheHelper = new CacheHelper(fileService); var chapter = new ChapterBuilder("1") - .WithLastModified(filesystemFile.LastWriteTime.DateTime) - .WithCreated(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) + .WithCreated(now.DateTime) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, true, file)); } @@ -215,10 +222,11 @@ public class CacheHelperTests [Fact] public void IsFileUnmodifiedSinceCreationOrLastScan_ModifiedSinceLastScan() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now, - CreationTime = DateTimeOffset.Now + LastWriteTime = now.DateTime, + CreationTime = now.DateTime }; var fileSystem = new MockFileSystem(new Dictionary { @@ -234,8 +242,8 @@ public class CacheHelperTests .WithCreated(DateTime.Now.Subtract(TimeSpan.FromMinutes(10))) .Build(); - var file = new MangaFileBuilder(TestCoverArchive, MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); } @@ -243,9 +251,10 @@ public class CacheHelperTests [Fact] public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan_ButLastModifiedSame() { + var now = DateTimeOffset.Now; var filesystemFile = new MockFileData("") { - LastWriteTime = DateTimeOffset.Now + LastWriteTime =now.DateTime }; var fileSystem = new MockFileSystem(new Dictionary { @@ -262,7 +271,7 @@ public class CacheHelperTests .Build(); var file = new MangaFileBuilder(Path.Join(TestCoverImageDirectory, TestCoverArchive), MangaFormat.Archive) - .WithLastModified(filesystemFile.LastWriteTime.DateTime) + .WithLastModified(now.DateTime) .Build(); Assert.False(cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, file)); diff --git a/API.Tests/Helpers/GenreHelperTests.cs b/API.Tests/Helpers/GenreHelperTests.cs deleted file mode 100644 index 830f32ee0..000000000 --- a/API.Tests/Helpers/GenreHelperTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Collections.Generic; -using API.Data; -using API.Entities; -using API.Helpers; -using API.Helpers.Builders; -using Xunit; - -namespace API.Tests.Helpers; - -public class GenreHelperTests -{ - [Fact] - public void UpdateGenre_ShouldAddNewGenre() - { - var allGenres = new List - { - new GenreBuilder("Action").Build(), - new GenreBuilder("action").Build(), - new GenreBuilder("Sci-fi").Build(), - }; - var genreAdded = new List(); - - GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, genre => - { - genreAdded.Add(genre); - }); - - Assert.Equal(2, genreAdded.Count); - Assert.Equal(4, allGenres.Count); - } - - [Fact] - public void UpdateGenre_ShouldNotAddDuplicateGenre() - { - var allGenres = new List - { - new GenreBuilder("Action").Build(), - new GenreBuilder("action").Build(), - new GenreBuilder("Sci-fi").Build(), - - }; - var genreAdded = new List(); - - GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, genre => - { - genreAdded.Add(genre); - }); - - Assert.Equal(3, allGenres.Count); - Assert.Equal(2, genreAdded.Count); - } - - [Fact] - public void AddGenre_ShouldAddOnlyNonExistingGenre() - { - var existingGenres = new List - { - new GenreBuilder("Action").Build(), - new GenreBuilder("action").Build(), - new GenreBuilder("Sci-fi").Build(), - }; - - - GenreHelper.AddGenreIfNotExists(existingGenres, new GenreBuilder("Action").Build()); - Assert.Equal(3, existingGenres.Count); - - GenreHelper.AddGenreIfNotExists(existingGenres, new GenreBuilder("action").Build()); - Assert.Equal(3, existingGenres.Count); - - GenreHelper.AddGenreIfNotExists(existingGenres, new GenreBuilder("Shonen").Build()); - Assert.Equal(4, existingGenres.Count); - } - - [Fact] - public void KeepOnlySamePeopleBetweenLists() - { - var existingGenres = new List - { - new GenreBuilder("Action").Build(), - new GenreBuilder("Sci-fi").Build(), - }; - - var peopleFromChapters = new List - { - new GenreBuilder("Action").Build(), - }; - - var genreRemoved = new List(); - GenreHelper.KeepOnlySameGenreBetweenLists(existingGenres, - peopleFromChapters, genre => - { - genreRemoved.Add(genre); - }); - - Assert.Single(genreRemoved); - } - - [Fact] - public void RemoveEveryoneIfNothingInRemoveAllExcept() - { - var existingGenres = new List - { - new GenreBuilder("Action").Build(), - new GenreBuilder("Sci-fi").Build(), - }; - - var peopleFromChapters = new List(); - - var genreRemoved = new List(); - GenreHelper.KeepOnlySameGenreBetweenLists(existingGenres, - peopleFromChapters, genre => - { - genreRemoved.Add(genre); - }); - - Assert.Equal(2, genreRemoved.Count); - } -} diff --git a/API.Tests/Helpers/OrderableHelperTests.cs b/API.Tests/Helpers/OrderableHelperTests.cs index a6d741be1..15f9e6268 100644 --- a/API.Tests/Helpers/OrderableHelperTests.cs +++ b/API.Tests/Helpers/OrderableHelperTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Entities; using API.Helpers; @@ -49,17 +50,14 @@ public class OrderableHelperTests [Fact] public void ReorderItems_InvalidPosition_NoChange() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "A" }, new AppUserSideNavStream { Id = 2, Order = 1, Name = "A" }, }; - // Act OrderableHelper.ReorderItems(items, 2, 3); // Position 3 is out of range - // Assert Assert.Equal(1, items[0].Id); // Item 1 should remain at position 0 Assert.Equal(2, items[1].Id); // Item 2 should remain at position 1 } @@ -80,7 +78,6 @@ public class OrderableHelperTests [Fact] public void ReorderItems_DoubleMove() { - // Arrange var items = new List { new AppUserSideNavStream { Id = 1, Order = 0, Name = "0" }, @@ -94,7 +91,6 @@ public class OrderableHelperTests // Move 4 -> 1 OrderableHelper.ReorderItems(items, 5, 1); - // Assert Assert.Equal(1, items[0].Id); Assert.Equal(0, items[0].Order); Assert.Equal(5, items[1].Id); @@ -109,4 +105,98 @@ public class OrderableHelperTests Assert.Equal("034125", string.Join("", items.Select(s => s.Name))); } + + private static List CreateTestReadingListItems(int count = 4) + { + var items = new List(); + + for (var i = 0; i < count; i++) + { + items.Add(new ReadingListItem() { Id = i + 1, Order = count, ReadingListId = i + 1}); + } + + return items; + } + + [Fact] + public void ReorderItems_MoveItemToBeginning_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 3, 0); + + Assert.Equal(3, items[0].Id); + Assert.Equal(1, items[1].Id); + Assert.Equal(2, items[2].Id); + Assert.Equal(4, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToEnd_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 1, 3); + + Assert.Equal(2, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(1, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToMiddle_CorrectOrder() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 4, 2); + + Assert.Equal(1, items[0].Id); + Assert.Equal(2, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(3, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_MoveItemToOutOfBoundsPosition_MovesToEnd() + { + var items = CreateTestReadingListItems(); + + OrderableHelper.ReorderItems(items, 2, 10); + + Assert.Equal(1, items[0].Id); + Assert.Equal(3, items[1].Id); + Assert.Equal(4, items[2].Id); + Assert.Equal(2, items[3].Id); + + for (var i = 0; i < items.Count; i++) + { + Assert.Equal(i, items[i].Order); + } + } + + [Fact] + public void ReorderItems_NegativePosition_ThrowsArgumentException() + { + var items = CreateTestReadingListItems(); + + Assert.Throws(() => + OrderableHelper.ReorderItems(items, 2, -1) + ); + } } diff --git a/API.Tests/Helpers/ParserInfoHelperTests.cs b/API.Tests/Helpers/ParserInfoHelperTests.cs index 70ce3aa69..0bb7efb9b 100644 --- a/API.Tests/Helpers/ParserInfoHelperTests.cs +++ b/API.Tests/Helpers/ParserInfoHelperTests.cs @@ -1,8 +1,5 @@ using System.Collections.Generic; -using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services.Tasks.Scanner; diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index ed59a958f..1a38ccdac 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -1,415 +1,133 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using API.Data; -using API.DTOs; -using API.Entities; -using API.Entities.Enums; -using API.Helpers; -using API.Helpers.Builders; -using Xunit; +using System.Linq; +using System.Threading.Tasks; namespace API.Tests.Helpers; -public class PersonHelperTests +public class PersonHelperTests : AbstractDbTest { - #region UpdatePeople - [Fact] - public void UpdatePeople_ShouldAddNewPeople() + protected override async Task ResetDb() { - var allPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleAdded = new List(); - - PersonHelper.UpdatePeople(allPeople, new[] {"Joseph Shmo", "Sally Ann"}, PersonRole.Writer, person => - { - peopleAdded.Add(person); - }); - - Assert.Equal(2, peopleAdded.Count); - Assert.Equal(4, allPeople.Count); + _context.Series.RemoveRange(_context.Series.ToList()); + await _context.SaveChangesAsync(); } - - [Fact] - public void UpdatePeople_ShouldNotAddDuplicatePeople() - { - var allPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Sally Ann", PersonRole.CoverArtist).Build(), - - }; - var peopleAdded = new List(); - - PersonHelper.UpdatePeople(allPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.CoverArtist, person => - { - peopleAdded.Add(person); - }); - - Assert.Equal(3, allPeople.Count); - } - #endregion - - #region UpdatePeopleList - - [Fact] - public void UpdatePeopleList_NullTags_NoChanges() - { - // Arrange - ICollection tags = null; - var series = new SeriesBuilder("Test Series").Build(); - var allTags = new List(); - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(PersonRole.Writer, tags, series, allTags, p => handleAddCalled = true, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.False(onModifiedCalled); - } - - [Fact] - public void UpdatePeopleList_AddNewTag_TagAddedAndOnModifiedCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List - { - new PersonDto { Id = 1, Name = "John Doe", Role = role } - }; - var series = new SeriesBuilder("Test Series").Build(); - var allTags = new List(); - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.True(handleAddCalled); - Assert.True(onModifiedCalled); - Assert.Single(series.Metadata.People); - Assert.Equal("John Doe", series.Metadata.People.First().Name); - } - - [Fact] - public void UpdatePeopleList_RemoveExistingTag_TagRemovedAndOnModifiedCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List(); - var series = new SeriesBuilder("Test Series").Build(); - var person = new PersonBuilder("John Doe", role).Build(); - person.Id = 1; - series.Metadata.People.Add(person); - var allTags = new List - { - person - }; - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.True(onModifiedCalled); - Assert.Empty(series.Metadata.People); - } - - [Fact] - public void UpdatePeopleList_UpdateExistingTag_OnModifiedCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List - { - new PersonDto { Id = 1, Name = "John Doe", Role = role } - }; - var series = new SeriesBuilder("Test Series").Build(); - var person = new PersonBuilder("John Doe", role).Build(); - person.Id = 1; - series.Metadata.People.Add(person); - var allTags = new List - { - person - }; - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.False(onModifiedCalled); - Assert.Single(series.Metadata.People); - Assert.Equal("John Doe", series.Metadata.People.First().Name); - } - - [Fact] - public void UpdatePeopleList_NoChanges_HandleAddAndOnModifiedNotCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List - { - new PersonDto { Id = 1, Name = "John Doe", Role = role } - }; - var series = new SeriesBuilder("Test Series").Build(); - var person = new PersonBuilder("John Doe", role).Build(); - person.Id = 1; - series.Metadata.People.Add(person); - var allTags = new List - { - new PersonBuilder("John Doe", role).Build() - }; - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.False(onModifiedCalled); - Assert.Single(series.Metadata.People); - Assert.Equal("John Doe", series.Metadata.People.First().Name); - } - - - - #endregion - - #region RemovePeople - [Fact] - public void RemovePeople_ShouldRemovePeopleOfSameRole() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleRemoved = new List(); - PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => - { - peopleRemoved.Add(person); - }); - - Assert.NotEqual(existingPeople, peopleRemoved); - Assert.Single(peopleRemoved); - } - - [Fact] - public void RemovePeople_ShouldRemovePeopleFromBothRoles() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleRemoved = new List(); - PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => - { - peopleRemoved.Add(person); - }); - - Assert.NotEqual(existingPeople, peopleRemoved); - Assert.Single(peopleRemoved); - - PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo"}, PersonRole.CoverArtist, person => - { - peopleRemoved.Add(person); - }); - - Assert.Empty(existingPeople); - Assert.Equal(2, peopleRemoved.Count); - } - - [Fact] - public void RemovePeople_ShouldRemovePeopleOfSameRole_WhenNothingPassed() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleRemoved = new List(); - PersonHelper.RemovePeople(existingPeople, new List(), PersonRole.Writer, person => - { - peopleRemoved.Add(person); - }); - - Assert.NotEqual(existingPeople, peopleRemoved); - Assert.Equal(2, peopleRemoved.Count); - } - - - #endregion - - #region KeepOnlySamePeopleBetweenLists - [Fact] - public void KeepOnlySamePeopleBetweenLists() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Sally", PersonRole.Writer).Build(), - }; - - var peopleFromChapters = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - }; - - var peopleRemoved = new List(); - PersonHelper.KeepOnlySamePeopleBetweenLists(existingPeople, - peopleFromChapters, person => - { - peopleRemoved.Add(person); - }); - - Assert.Equal(2, peopleRemoved.Count); - } - #endregion - - #region AddPeople - - [Fact] - public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonDoesNotExist() - { - // Arrange - var metadataPeople = new List(); - var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Single(metadataPeople); - Assert.Contains(person, metadataPeople); - } - - [Fact] - public void AddPersonIfNotExists_ShouldNotAddPerson_WhenPersonAlreadyExists() - { - // Arrange - var metadataPeople = new List - { - new PersonBuilder("John Smith", PersonRole.Character) - .WithId(1) - .Build() - }; - var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Single(metadataPeople); - Assert.NotNull(metadataPeople.SingleOrDefault(p => - p.Name.Equals(person.Name) && p.Role == person.Role && p.NormalizedName == person.NormalizedName)); - Assert.Equal(1, metadataPeople.First().Id); - } - - [Fact] - public void AddPersonIfNotExists_ShouldNotAddPerson_WhenPersonNameIsNullOrEmpty() - { - // Arrange - var metadataPeople = new List(); - var person2 = new PersonBuilder(string.Empty, PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person2); - - // Assert - Assert.Empty(metadataPeople); - } - - [Fact] - public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonNameIsDifferentButRoleIsSame() - { - // Arrange - var metadataPeople = new List - { - new PersonBuilder("John Smith", PersonRole.Character).Build() - }; - var person = new PersonBuilder("John Doe", PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Equal(2, metadataPeople.Count); - Assert.Contains(person, metadataPeople); - } - - [Fact] - public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonNameIsSameButRoleIsDifferent() - { - // Arrange - var metadataPeople = new List - { - new PersonBuilder("John Doe", PersonRole.Writer).Build() - }; - var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Equal(2, metadataPeople.Count); - Assert.Contains(person, metadataPeople); - } - - - - - [Fact] - public void AddPeople_ShouldAddOnlyNonExistingPeople() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Sally", PersonRole.Writer).Build(), - }; - - - PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build()); - Assert.Equal(3, existingPeople.Count); - - PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo", PersonRole.Writer).Build()); - Assert.Equal(3, existingPeople.Count); - - PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo Two", PersonRole.CoverArtist).Build()); - Assert.Equal(4, existingPeople.Count); - } - - #endregion - + // + // // 1. Test adding new people and keeping existing ones + // [Fact] + // public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() + // { + // var existingPerson = new PersonBuilder("Joe Shmo").Build(); + // var chapter = new ChapterBuilder("1").Build(); + // + // // Create an existing person and assign them to the series with a role + // var series = new SeriesBuilder("Test 1") + // .WithFormat(MangaFormat.Archive) + // .WithMetadata(new SeriesMetadataBuilder() + // .WithPerson(existingPerson, PersonRole.Editor) + // .Build()) + // .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) + // .Build(); + // + // _unitOfWork.SeriesRepository.Add(series); + // await _unitOfWork.CommitAsync(); + // + // // Call UpdateChapterPeopleAsync with one existing and one new person + // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork); + // + // // Assert existing person retained and new person added + // var people = await _unitOfWork.PersonRepository.GetAllPeople(); + // Assert.Contains(people, p => p.Name == "Joe Shmo"); + // Assert.Contains(people, p => p.Name == "New Person"); + // + // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + // Assert.Contains("Joe Shmo", chapterPeople); + // Assert.Contains("New Person", chapterPeople); + // } + // + // // 2. Test removing a person no longer in the list + // [Fact] + // public async Task UpdateChapterPeopleAsync_RemovePeople() + // { + // var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); + // var existingPerson2 = new PersonBuilder("Jane Doe").Build(); + // var chapter = new ChapterBuilder("1").Build(); + // + // var series = new SeriesBuilder("Test 1") + // .WithVolume(new VolumeBuilder("1") + // .WithChapter(new ChapterBuilder("1") + // .WithPerson(existingPerson1, PersonRole.Editor) + // .WithPerson(existingPerson2, PersonRole.Editor) + // .Build()) + // .Build()) + // .Build(); + // + // _unitOfWork.SeriesRepository.Add(series); + // await _unitOfWork.CommitAsync(); + // + // // Call UpdateChapterPeopleAsync with only one person + // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); + // + // var people = await _unitOfWork.PersonRepository.GetAllPeople(); + // Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); + // + // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + // Assert.Contains("Joe Shmo", chapterPeople); + // Assert.DoesNotContain("Jane Doe", chapterPeople); + // } + // + // // 3. Test no changes when the list of people is the same + // [Fact] + // public async Task UpdateChapterPeopleAsync_NoChanges() + // { + // var existingPerson = new PersonBuilder("Joe Shmo").Build(); + // var chapter = new ChapterBuilder("1").Build(); + // + // var series = new SeriesBuilder("Test 1") + // .WithVolume(new VolumeBuilder("1") + // .WithChapter(new ChapterBuilder("1") + // .WithPerson(existingPerson, PersonRole.Editor) + // .Build()) + // .Build()) + // .Build(); + // + // _unitOfWork.SeriesRepository.Add(series); + // await _unitOfWork.CommitAsync(); + // + // // Call UpdateChapterPeopleAsync with the same list + // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); + // + // var people = await _unitOfWork.PersonRepository.GetAllPeople(); + // Assert.Contains(people, p => p.Name == "Joe Shmo"); + // + // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + // Assert.Contains("Joe Shmo", chapterPeople); + // Assert.Single(chapter.People); // No duplicate entries + // } + // + // // 4. Test multiple roles for a person + // [Fact] + // public async Task UpdateChapterPeopleAsync_MultipleRoles() + // { + // var person = new PersonBuilder("Joe Shmo").Build(); + // var chapter = new ChapterBuilder("1").Build(); + // + // var series = new SeriesBuilder("Test 1") + // .WithVolume(new VolumeBuilder("1") + // .WithChapter(new ChapterBuilder("1") + // .WithPerson(person, PersonRole.Writer) // Assign person as Writer + // .Build()) + // .Build()) + // .Build(); + // + // _unitOfWork.SeriesRepository.Add(series); + // await _unitOfWork.CommitAsync(); + // + // // Add same person as Editor + // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); + // + // // Ensure that the same person is assigned with two roles + // var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList(); + // Assert.Equal(2, chapterPeople.Count); // One for each role + // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); + // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); + // } } diff --git a/API.Tests/Helpers/RateLimiterTests.cs b/API.Tests/Helpers/RateLimiterTests.cs index c05ce4e6d..e9b0030b9 100644 --- a/API.Tests/Helpers/RateLimiterTests.cs +++ b/API.Tests/Helpers/RateLimiterTests.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using API.Helpers; using Xunit; @@ -33,7 +34,7 @@ public class RateLimiterTests } [Fact] - public void AcquireTokens_Refill() + public async Task AcquireTokens_Refill() { // Arrange var limiter = new RateLimiter(2, TimeSpan.FromSeconds(1)); @@ -43,14 +44,14 @@ public class RateLimiterTests limiter.TryAcquire("test_key"); // Wait for refill - System.Threading.Thread.Sleep(1100); + await Task.Delay(1100); // Assert Assert.True(limiter.TryAcquire("test_key")); } [Fact] - public void AcquireTokens_Refill_WithOff() + public async Task AcquireTokens_Refill_WithOff() { // Arrange var limiter = new RateLimiter(2, TimeSpan.FromSeconds(10), false); @@ -60,7 +61,7 @@ public class RateLimiterTests limiter.TryAcquire("test_key"); // Wait for refill - System.Threading.Thread.Sleep(2100); + await Task.Delay(2100); // Assert Assert.False(limiter.TryAcquire("test_key")); diff --git a/API.Tests/Helpers/ReviewHelperTests.cs b/API.Tests/Helpers/ReviewHelperTests.cs new file mode 100644 index 000000000..b221c3c70 --- /dev/null +++ b/API.Tests/Helpers/ReviewHelperTests.cs @@ -0,0 +1,258 @@ +using API.Helpers; +using System.Collections.Generic; +using System.Linq; +using Xunit; +using API.DTOs.SeriesDetail; + +namespace API.Tests.Helpers; + +public class ReviewHelperTests +{ + #region SelectSpectrumOfReviews Tests + + [Fact] + public void SelectSpectrumOfReviews_WhenLessThan10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(8); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(8, result.Count); + Assert.Equal(reviews, result.OrderByDescending(r => r.Score)); + } + + [Fact] + public void SelectSpectrumOfReviews_WhenMoreThan10Reviews_Returns10Reviews() + { + // Arrange + var reviews = CreateReviewList(20); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Equal(reviews[0], result.First()); + Assert.Equal(reviews[19], result.Last()); + } + + [Fact] + public void SelectSpectrumOfReviews_WithExactly10Reviews_ReturnsAllReviews() + { + // Arrange + var reviews = CreateReviewList(10); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + } + + [Fact] + public void SelectSpectrumOfReviews_WithLargeNumberOfReviews_ReturnsCorrectSpectrum() + { + // Arrange + var reviews = CreateReviewList(100); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(10, result.Count); + Assert.Contains(reviews[0], result); + Assert.Contains(reviews[1], result); + Assert.Contains(reviews[98], result); + Assert.Contains(reviews[99], result); + } + + [Fact] + public void SelectSpectrumOfReviews_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var reviews = new List(); + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void SelectSpectrumOfReviews_ResultsOrderedByScoreDescending() + { + // Arrange + var reviews = new List + { + new UserReviewDto { Tagline = "1", Score = 3 }, + new UserReviewDto { Tagline = "2", Score = 5 }, + new UserReviewDto { Tagline = "3", Score = 1 }, + new UserReviewDto { Tagline = "4", Score = 4 }, + new UserReviewDto { Tagline = "5", Score = 2 } + }; + + // Act + var result = ReviewHelper.SelectSpectrumOfReviews(reviews).ToList(); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(5, result[0].Score); + Assert.Equal(4, result[1].Score); + Assert.Equal(3, result[2].Score); + Assert.Equal(2, result[3].Score); + Assert.Equal(1, result[4].Score); + } + + #endregion + + #region GetCharacters Tests + + [Fact] + public void GetCharacters_WithNullBody_ReturnsNull() + { + // Arrange + string body = null; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetCharacters_WithEmptyBody_ReturnsEmptyString() + { + // Arrange + var body = string.Empty; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithNoTextNodes_ReturnsEmptyString() + { + // Arrange + const string body = "
"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetCharacters_WithLessCharactersThanLimit_ReturnsFullText() + { + // Arrange + var body = "

This is a short review.

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is a short review.…", result); + } + + [Fact] + public void GetCharacters_WithMoreCharactersThanLimit_TruncatesText() + { + // Arrange + var body = "

" + new string('a', 200) + "

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal(new string('a', 175) + "…", result); + Assert.Equal(176, result.Length); // 175 characters + ellipsis + } + + [Fact] + public void GetCharacters_IgnoresScriptTags() + { + // Arrange + const string body = "

Visible text

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("Visible text…", result); + Assert.DoesNotContain("hidden", result); + } + + [Fact] + public void GetCharacters_RemovesMarkdownSymbols() + { + // Arrange + const string body = "

This is **bold** and _italic_ text with [link](url).

"; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.Equal("This is bold and italic text with link.…", result); + } + + [Fact] + public void GetCharacters_HandlesComplexMarkdownAndHtml() + { + // Arrange + const string body = """ + +
+

# Header

+

This is ~~strikethrough~~ and __underlined__ text

+

~~~code block~~~

+

+++highlighted+++

+

img123(image.jpg)

+
+ """; + + // Act + var result = ReviewHelper.GetCharacters(body); + + // Assert + Assert.DoesNotContain("~~", result); + Assert.DoesNotContain("__", result); + Assert.DoesNotContain("~~~", result); + Assert.DoesNotContain("+++", result); + Assert.DoesNotContain("img123(", result); + Assert.Contains("Header", result); + Assert.Contains("strikethrough", result); + Assert.Contains("underlined", result); + Assert.Contains("code block", result); + Assert.Contains("highlighted", result); + } + + #endregion + + #region Helper Methods + + private static List CreateReviewList(int count) + { + var reviews = new List(); + for (var i = 0; i < count; i++) + { + reviews.Add(new UserReviewDto + { + Tagline = $"{i + 1}", + Score = count - i // This makes them ordered by score descending initially + }); + } + return reviews; + } + + #endregion +} + diff --git a/API.Tests/Helpers/ScannerHelper.cs b/API.Tests/Helpers/ScannerHelper.cs new file mode 100644 index 000000000..653efebb1 --- /dev/null +++ b/API.Tests/Helpers/ScannerHelper.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; +using API.Data; +using API.Data.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.Services.Tasks; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner; +using API.SignalR; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit.Abstractions; + +namespace API.Tests.Helpers; +#nullable enable + +public class ScannerHelper +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ITestOutputHelper _testOutputHelper; + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); + private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases"); + private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png"); + private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" }; + + public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper) + { + _unitOfWork = unitOfWork; + _testOutputHelper = testOutputHelper; + } + + public async Task GenerateScannerData(string testcase, Dictionary comicInfos = null) + { + var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos); + + var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); + + var library = new LibraryBuilder(publisher, type) + .WithFolders([new FolderPath() {Path = testDirectoryPath}]) + .Build(); + + var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0]) + .WithLibrary(library) + .Build(); + + _unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists + _unitOfWork.LibraryRepository.Add(library); + await _unitOfWork.CommitAsync(); + + return library; + } + + public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null) + { + fs ??= new FileSystem(); + ds ??= new DirectoryService(Substitute.For>(), fs); + var archiveService = new ArchiveService(Substitute.For>(), ds, + Substitute.For(), Substitute.For()); + var readingItemService = new ReadingItemService(archiveService, Substitute.For(), + Substitute.For(), ds, Substitute.For>()); + + + var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), + Substitute.For(), + ds, Substitute.For(), readingItemService, new FileService(fs), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); + + var scanner = new ScannerService(_unitOfWork, Substitute.For>(), + Substitute.For(), + Substitute.For(), Substitute.For(), ds, + readingItemService, processSeries, Substitute.For()); + return scanner; + } + + private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input) + { + // Split the input string based on " - " + var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length != 2) + { + throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'"); + } + + var publisher = parts[0].Trim(); + var libraryTypeString = parts[1].Trim(); + + // Try to parse the right-hand side as a LibraryType enum + if (!Enum.TryParse(libraryTypeString, out var libraryType)) + { + throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType"); + } + + return (publisher, libraryType); + } + + + + private async Task GenerateTestDirectory(string mapPath, Dictionary comicInfos = null) + { + // Read the map file + var mapContent = await File.ReadAllTextAsync(mapPath); + + // Deserialize the JSON content into a list of strings using System.Text.Json + var filePaths = JsonSerializer.Deserialize>(mapContent); + + // Create a test directory + var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath)); + if (Directory.Exists(testDirectory)) + { + Directory.Delete(testDirectory, true); + } + Directory.CreateDirectory(testDirectory); + + // Generate the files and folders + await Scaffold(testDirectory, filePaths, comicInfos); + + _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); + + return Path.GetFullPath(testDirectory); + } + + + public async Task Scaffold(string testDirectory, List filePaths, Dictionary comicInfos = null) + { + foreach (var relativePath in filePaths) + { + var fullPath = Path.Combine(testDirectory, relativePath); + var fileDir = Path.GetDirectoryName(fullPath); + + // Create the directory if it doesn't exist + if (!Directory.Exists(fileDir)) + { + Directory.CreateDirectory(fileDir); + Console.WriteLine($"Created directory: {fileDir}"); + } + + var ext = Path.GetExtension(fullPath).ToLower(); + if (ComicInfoExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var info)) + { + CreateMinimalCbz(fullPath, info); + } + else + { + // Create an empty file + await File.Create(fullPath).DisposeAsync(); + Console.WriteLine($"Created empty file: {fullPath}"); + } + } + } + + private void CreateMinimalCbz(string filePath, ComicInfo? comicInfo = null) + { + using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create)) + { + // Add the 1x1 image to the archive + archive.CreateEntryFromFile(_imagePath, "1x1.png"); + + if (comicInfo != null) + { + // Serialize ComicInfo object to XML + var comicInfoXml = SerializeComicInfoToXml(comicInfo); + + // Create an entry for ComicInfo.xml in the archive + var entry = archive.CreateEntry("ComicInfo.xml"); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + + // Write the XML to the archive + writer.Write(comicInfoXml); + } + + } + Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata."); + } + + + private static string SerializeComicInfoToXml(ComicInfo comicInfo) + { + var xmlSerializer = new XmlSerializer(typeof(ComicInfo)); + using var stringWriter = new StringWriter(); + using (var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false})) + { + xmlSerializer.Serialize(xmlWriter, comicInfo); + } + + // For the love of god, I spent 2 hours trying to get utf-8 with no BOM + return stringWriter.ToString().Replace("""""", + @""); + } +} diff --git a/API.Tests/Helpers/SeriesHelperTests.cs b/API.Tests/Helpers/SeriesHelperTests.cs index a5b5a063b..22b4a3cd1 100644 --- a/API.Tests/Helpers/SeriesHelperTests.cs +++ b/API.Tests/Helpers/SeriesHelperTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Linq; -using API.Data; using API.Entities; using API.Entities.Enums; using API.Extensions; diff --git a/API.Tests/Helpers/StringHelperTests.cs b/API.Tests/Helpers/StringHelperTests.cs new file mode 100644 index 000000000..8f845c9b0 --- /dev/null +++ b/API.Tests/Helpers/StringHelperTests.cs @@ -0,0 +1,46 @@ +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class StringHelperTests +{ + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!



Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

", + "

A Perfect Marriage Becomes a Perfect Affair!
Every woman wishes for that happily ever after, but when time flies by and you've become a neglected housewife, what's a woman to do?

" + )] + [InlineData( + "

Blog | Twitter | Pixiv | Pawoo

", + "

Blog | Twitter | Pixiv | Pawoo

" + )] + public void TestSquashBreaklines(string input, string expected) + { + Assert.Equal(expected, StringHelper.SquashBreaklines(input)); + } + + [Theory] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!
(Source: Anime News Network)

", + "

A Perfect Marriage Becomes a Perfect Affair!

" + )] + [InlineData( + "

A Perfect Marriage Becomes a Perfect Affair!

(Source: Anime News Network)", + "

A Perfect Marriage Becomes a Perfect Affair!

" + )] + public void TestRemoveSourceInDescription(string input, string expected) + { + Assert.Equal(expected, StringHelper.RemoveSourceInDescription(input)); + } + + + [Theory] + [InlineData( +"""Pawoo

""", +"""Pawoo

""" + )] + public void TestCorrectUrls(string input, string expected) + { + Assert.Equal(expected, StringHelper.CorrectUrls(input)); + } +} diff --git a/API.Tests/Helpers/TagHelperTests.cs b/API.Tests/Helpers/TagHelperTests.cs deleted file mode 100644 index 430a85d69..000000000 --- a/API.Tests/Helpers/TagHelperTests.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System.Collections.Generic; -using API.Data; -using API.Entities; -using API.Helpers; -using API.Helpers.Builders; -using Xunit; - -namespace API.Tests.Helpers; - -public class TagHelperTests -{ - [Fact] - public void UpdateTag_ShouldAddNewTag() - { - var allTags = new List - { - new TagBuilder("Action").Build(), - new TagBuilder("action").Build(), - new TagBuilder("Sci-fi").Build(), - }; - var tagAdded = new List(); - - TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, (tag, added) => - { - if (added) - { - tagAdded.Add(tag); - } - - }); - - Assert.Single(tagAdded); - Assert.Equal(4, allTags.Count); - } - - [Fact] - public void UpdateTag_ShouldNotAddDuplicateTag() - { - var allTags = new List - { - new TagBuilder("Action").Build(), - new TagBuilder("action").Build(), - new TagBuilder("Sci-fi").Build(), - - }; - var tagAdded = new List(); - - TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, (tag, added) => - { - if (added) - { - tagAdded.Add(tag); - } - TagHelper.AddTagIfNotExists(allTags, tag); - }); - - Assert.Equal(3, allTags.Count); - Assert.Empty(tagAdded); - } - - [Fact] - public void AddTag_ShouldAddOnlyNonExistingTag() - { - var existingTags = new List - { - new TagBuilder("Action").Build(), - new TagBuilder("action").Build(), - new TagBuilder("Sci-fi").Build(), - }; - - - TagHelper.AddTagIfNotExists(existingTags, new TagBuilder("Action").Build()); - Assert.Equal(3, existingTags.Count); - - TagHelper.AddTagIfNotExists(existingTags, new TagBuilder("action").Build()); - Assert.Equal(3, existingTags.Count); - - TagHelper.AddTagIfNotExists(existingTags, new TagBuilder("Shonen").Build()); - Assert.Equal(4, existingTags.Count); - } - - [Fact] - public void KeepOnlySamePeopleBetweenLists() - { - var existingTags = new List - { - new TagBuilder("Action").Build(), - new TagBuilder("Sci-fi").Build(), - }; - - var peopleFromChapters = new List - { - new TagBuilder("Action").Build(), - }; - - var tagRemoved = new List(); - TagHelper.KeepOnlySameTagBetweenLists(existingTags, - peopleFromChapters, tag => - { - tagRemoved.Add(tag); - }); - - Assert.Single(tagRemoved); - } - - [Fact] - public void RemoveEveryoneIfNothingInRemoveAllExcept() - { - var existingTags = new List - { - new TagBuilder("Action").Build(), - new TagBuilder("Sci-fi").Build(), - }; - - var peopleFromChapters = new List(); - - var tagRemoved = new List(); - TagHelper.KeepOnlySameTagBetweenLists(existingTags, - peopleFromChapters, tag => - { - tagRemoved.Add(tag); - }); - - Assert.Equal(2, tagRemoved.Count); - } -} diff --git a/API.Tests/Parser/BookParserTests.cs b/API.Tests/Parser/BookParserTests.cs deleted file mode 100644 index 52fd02ae8..000000000 --- a/API.Tests/Parser/BookParserTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Xunit; - -namespace API.Tests.Parser; - -public class BookParserTests -{ - [Theory] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")] - [InlineData("BBC Focus 00 The Science of Happiness 2nd Edition (2018)", "BBC Focus 00 The Science of Happiness 2nd Edition")] - [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "Faust")] - public void ParseSeriesTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); - } - - [Theory] - [InlineData("Harrison, Kim - Dates from Hell - Hollows Vol 2.5.epub", "2.5")] - [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "1")] - public void ParseVolumeTest(string filename, string expected) - { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); - } - - // [Theory] - // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA", "@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA")] - // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url('fonts/font.css')", "@font-face{font-family:'syyskuu_repaleinen';src:url('TEST/fonts/font.css')")] - // public void ReplaceFontSrcUrl(string input, string expected) - // { - // var apiBase = "TEST/"; - // var actual = API.Parser.Parser.FontSrcUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); - // Assert.Equal(expected, actual); - // } - // - // [Theory] - // [InlineData("@import url('font.css');", "@import url('TEST/font.css');")] - // public void ReplaceImportSrcUrl(string input, string expected) - // { - // var apiBase = "TEST/"; - // var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); - // Assert.Equal(expected, actual); - // } - -} diff --git a/API.Tests/Parsers/BasicParserTests.cs b/API.Tests/Parsers/BasicParserTests.cs new file mode 100644 index 000000000..32673e0e6 --- /dev/null +++ b/API.Tests/Parsers/BasicParserTests.cs @@ -0,0 +1,249 @@ +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Parsers; + +public class BasicParserTests : AbstractFsTest +{ + private readonly BasicParser _parser; + private readonly ILogger _dsLogger = Substitute.For>(); + private readonly string _rootDirectory; + + public BasicParserTests() + { + var fileSystem = CreateFileSystem(); + _rootDirectory = Path.Join(DataDirectory, "Books/"); + fileSystem.AddDirectory(_rootDirectory); + fileSystem.AddFile($"{_rootDirectory}Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); + + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData("")); + fileSystem.AddFile($"{_rootDirectory}Accel World/Accel World - Chapter 3.cbz", new MockFileData("")); + fileSystem.AddFile("$\"{RootDirectory}Accel World/Accel World Gaiden SP01.cbz", new MockFileData("")); + + + fileSystem.AddFile($"{_rootDirectory}Accel World/cover.png", new MockFileData("")); + + fileSystem.AddFile($"{_rootDirectory}Batman/Batman #1.cbz", new MockFileData("")); + + var ds = new DirectoryService(_dsLogger, fileSystem); + _parser = new BasicParser(ds, new ImageParser(ds)); + } + + #region Parse_Manga + + /// + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored + /// + [Fact] + public void Parse_MangaLibrary_JustCover_ShouldReturnNull() + { + var actual = _parser.Parse($"{_rootDirectory}Accel World/cover.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); + Assert.Null(actual); + } + + /// + /// Tests that when there is a loose-leaf cover in the manga library, that it is ignored + /// + [Fact] + public void Parse_MangaLibrary_OtherImage_ShouldReturnNull() + { + var actual = _parser.Parse($"{_rootDirectory}Accel World/page 01.png", $"{_rootDirectory}Accel World/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + } + + /// + /// Tests that when there is a volume and chapter in filename, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_VolumeAndChapterInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", $"{_rootDirectory}Mujaki no Rakuen/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Mujaki no Rakuen", actual.Series); + Assert.Equal("12", actual.Volumes); + Assert.Equal("76", actual.Chapters); + Assert.False(actual.IsSpecial); + } + + /// + /// Tests that when there is a volume in filename, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_JustVolumeInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz", + $"{_rootDirectory}Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series); + Assert.Equal("1", actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.False(actual.IsSpecial); + } + + /// + /// Tests that when there is a chapter only in filename, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_JustChapterInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Beelzebub/Beelzebub_01_[Noodles].zip", + $"{_rootDirectory}Beelzebub/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Beelzebub", actual.Series); + Assert.Equal(Parser.LooseLeafVolume, actual.Volumes); + Assert.Equal("1", actual.Chapters); + Assert.False(actual.IsSpecial); + } + + /// + /// Tests that when there is a SP Marker in filename, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialMarkerInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Summer Time Rendering", actual.Series); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + + /// + /// Tests that when the filename parses as a special, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Summer Time Rendering/Volume SP01.cbr", + $"{_rootDirectory}Summer Time Rendering/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Summer Time Rendering", actual.Series); + Assert.Equal("Volume", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when the filename parses as a special, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename2() + { + var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip", + "M:/Kimi wa Midara na Boku no Joou/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series); + Assert.Equal("[Renzokusei] Special 1", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when the filename parses as a special, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename_StrangeNaming() + { + var actual = _parser.Parse($"{_rootDirectory}My Dress-Up Darling/SP01 1. Special Name.cbz", + _rootDirectory, + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("My Dress-Up Darling", actual.Series); + Assert.Equal("1. Special Name", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when there is an edition in filename, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_EditionInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", + $"{_rootDirectory}Air Gear/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Air Gear", actual.Series); + Assert.Equal("1", actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.False(actual.IsSpecial); + Assert.Equal("Omnibus", actual.Edition); + } + + #endregion + + #region Parse_Books + /// + /// Tests that when there is a volume in filename, it appropriately parses + /// + [Fact] + public void Parse_MangaBooks_JustVolumeInFilename() + { + var actual = _parser.Parse($"{_rootDirectory}Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", + $"{_rootDirectory}Epubs/", + _rootDirectory, LibraryType.Manga); + Assert.NotNull(actual); + + Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series); + Assert.Equal("2.5", actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + } + + #endregion + + #region IsApplicable + /// + /// Tests that this Parser can only be used on images and Image library type + /// + [Fact] + public void IsApplicable_Fails_WhenNonMatchingLibraryType() + { + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Image)); + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.ComicVine)); + } + + /// + /// Tests that this Parser can only be used on images and Image library type + /// + [Fact] + public void IsApplicable_Success_WhenMatchingLibraryType() + { + Assert.True(_parser.IsApplicable("something.png", LibraryType.Manga)); + Assert.True(_parser.IsApplicable("something.png", LibraryType.Comic)); + Assert.True(_parser.IsApplicable("something.pdf", LibraryType.Book)); + Assert.True(_parser.IsApplicable("something.epub", LibraryType.LightNovel)); + } + + + #endregion +} diff --git a/API.Tests/Parsers/BookParserTests.cs b/API.Tests/Parsers/BookParserTests.cs new file mode 100644 index 000000000..90147ac6b --- /dev/null +++ b/API.Tests/Parsers/BookParserTests.cs @@ -0,0 +1,73 @@ +using System.IO.Abstractions.TestingHelpers; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Parsers; + +public class BookParserTests +{ + private readonly BookParser _parser; + private readonly ILogger _dsLogger = Substitute.For>(); + private const string RootDirectory = "C:/Books/"; + + public BookParserTests() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Books/"); + fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData("")); + fileSystem.AddFile("C:/Books/Adam Freeman - Pro ASP.NET Core 6.epub", new MockFileData("")); + fileSystem.AddFile("C:/Books/My Fav Book SP01.epub", new MockFileData("")); + var ds = new DirectoryService(_dsLogger, fileSystem); + _parser = new BookParser(ds, Substitute.For(), new BasicParser(ds, new ImageParser(ds))); + } + + #region Parse + + // TODO: I'm not sure how to actually test this as it relies on an epub parser to actually do anything + + /// + /// Tests that if there is a Series Folder then Chapter folder, the code appropriately identifies the Series name and Chapter + /// + // [Fact] + // public void Parse_SeriesWithDirectoryName() + // { + // var actual = _parser.Parse("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", "C:/Books/Birds of Prey/", + // RootDirectory, LibraryType.Book, new ComicInfo() + // { + // Series = "Harry Potter", + // Volume = "1" + // }); + // + // Assert.NotNull(actual); + // Assert.Equal("Harry Potter", actual.Series); + // Assert.Equal("1", actual.Volumes); + // } + + #endregion + + #region IsApplicable + /// + /// Tests that this Parser can only be used on images and Image library type + /// + [Fact] + public void IsApplicable_Fails_WhenNonMatchingLibraryType() + { + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Manga)); + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Book)); + + } + + /// + /// Tests that this Parser can only be used on images and Image library type + /// + [Fact] + public void IsApplicable_Success_WhenMatchingLibraryType() + { + Assert.True(_parser.IsApplicable("something.epub", LibraryType.Image)); + } + #endregion +} diff --git a/API.Tests/Parsers/ComicVineParserTests.cs b/API.Tests/Parsers/ComicVineParserTests.cs new file mode 100644 index 000000000..f01e98afd --- /dev/null +++ b/API.Tests/Parsers/ComicVineParserTests.cs @@ -0,0 +1,115 @@ +using System.IO.Abstractions.TestingHelpers; +using API.Data.Metadata; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Parsers; + +public class ComicVineParserTests +{ + private readonly ComicVineParser _parser; + private readonly ILogger _dsLogger = Substitute.For>(); + private const string RootDirectory = "C:/Comics/"; + + public ComicVineParserTests() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Comics/"); + fileSystem.AddDirectory("C:/Comics/Birds of Prey (2002)"); + fileSystem.AddFile("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", new MockFileData("")); + fileSystem.AddFile("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", new MockFileData("")); + fileSystem.AddFile("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", new MockFileData("")); + var ds = new DirectoryService(_dsLogger, fileSystem); + _parser = new ComicVineParser(ds); + } + + #region Parse + + /// + /// Tests that when Series and Volume are filled out, Kavita uses that for the Series Name + /// + [Fact] + public void Parse_SeriesWithComicInfo() + { + var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", + RootDirectory, LibraryType.ComicVine, new ComicInfo() + { + Series = "Birds of Prey", + Volume = "2002" + }); + + Assert.NotNull(actual); + Assert.Equal("Birds of Prey (2002)", actual.Series); + Assert.Equal("2002", actual.Volumes); + } + + /// + /// Tests that no ComicInfo, take the Directory Name if it matches "Series (2002)" or "Series (2)" + /// + [Fact] + public void Parse_SeriesWithDirectoryNameAsSeriesYear() + { + var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", + RootDirectory, LibraryType.ComicVine, null); + + Assert.NotNull(actual); + Assert.Equal("Birds of Prey (2002)", actual.Series); + Assert.Equal("2002", actual.Volumes); + Assert.Equal("1", actual.Chapters); + } + + /// + /// Tests that no ComicInfo, take a directory name up to root if it matches "Series (2002)" or "Series (2)" + /// + [Fact] + public void Parse_SeriesWithADirectoryNameAsSeriesYear() + { + var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/", + RootDirectory, LibraryType.ComicVine, null); + + Assert.NotNull(actual); + Assert.Equal("Birds of Prey (1999)", actual.Series); + Assert.Equal("1999", actual.Volumes); + Assert.Equal("1", actual.Chapters); + } + + /// + /// Tests that no ComicInfo and nothing matches Series (Volume), then just take the directory name as the Series + /// + [Fact] + public void Parse_FallbackToDirectoryNameOnly() + { + var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/", + RootDirectory, LibraryType.ComicVine, null); + + Assert.NotNull(actual); + Assert.Equal("Blood Syndicate", actual.Series); + Assert.Equal(Parser.LooseLeafVolume, actual.Volumes); + Assert.Equal("1", actual.Chapters); + } + #endregion + + #region IsApplicable + /// + /// Tests that this Parser can only be used on ComicVine type + /// + [Fact] + public void IsApplicable_Fails_WhenNonMatchingLibraryType() + { + Assert.False(_parser.IsApplicable("", LibraryType.Comic)); + } + + /// + /// Tests that this Parser can only be used on ComicVine type + /// + [Fact] + public void IsApplicable_Success_WhenMatchingLibraryType() + { + Assert.True(_parser.IsApplicable("", LibraryType.ComicVine)); + } + #endregion +} diff --git a/API.Tests/Parser/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs similarity index 73% rename from API.Tests/Parser/DefaultParserTests.cs rename to API.Tests/Parsers/DefaultParserTests.cs index 14e75f353..733b55d62 100644 --- a/API.Tests/Parser/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO.Abstractions.TestingHelpers; -using System.Linq; using API.Entities.Enums; using API.Services; using API.Services.Tasks.Scanner.Parser; @@ -10,7 +8,7 @@ using NSubstitute; using Xunit; using Xunit.Abstractions; -namespace API.Tests.Parser; +namespace API.Tests.Parsers; public class DefaultParserTests { @@ -21,10 +19,12 @@ public class DefaultParserTests { _testOutputHelper = testOutputHelper; var directoryService = new DirectoryService(Substitute.For>(), new MockFileSystem()); - _defaultParser = new DefaultParser(directoryService); + _defaultParser = new BasicParser(directoryService, new ImageParser(directoryService)); } + + #region ParseFromFallbackFolders [Theory] [InlineData("C:/", "C:/Love Hina/Love Hina - Special.cbz", "Love Hina")] @@ -33,7 +33,7 @@ public class DefaultParserTests [InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")] public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries) { - var actual = _defaultParser.Parse(inputPath, rootDir); + var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null); if (actual == null) { Assert.NotNull(actual); @@ -44,19 +44,18 @@ public class DefaultParserTests } [Theory] - [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")] - [InlineData("/manga/Btooom!/Vol.1 Chapter 2/1.cbz", "Btooom!~1~2")] - [InlineData("/manga/Monster/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", "Monster~0~1")] - [InlineData("/manga/Hajime no Ippo/Artbook/Hajime no Ippo - Artbook.cbz", "Hajime no Ippo~0~0")] - public void ParseFromFallbackFolders_ShouldParseSeriesVolumeAndChapter(string inputFile, string expectedParseInfo) + [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", new [] {"Btooom!", "1", "1"})] + [InlineData("/manga/Btooom!/Vol.1 Chapter 2/1.cbz", new [] {"Btooom!", "1", "2"})] + [InlineData("/manga/Monster/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", new [] {"Monster", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, "1"})] + [InlineData("/manga/Hajime no Ippo/Artbook/Hajime no Ippo - Artbook.cbz", new [] {"Hajime no Ippo", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter})] + public void ParseFromFallbackFolders_ShouldParseSeriesVolumeAndChapter(string inputFile, string[] expectedParseInfo) { const string rootDirectory = "/manga/"; - var tokens = expectedParseInfo.Split("~"); - var actual = new ParserInfo {Series = "", Chapters = "0", Volumes = "0"}; + var actual = new ParserInfo {Series = "", Chapters = Parser.DefaultChapter, Volumes = Parser.LooseLeafVolume}; _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); - Assert.Equal(tokens[0], actual.Series); - Assert.Equal(tokens[1], actual.Volumes); - Assert.Equal(tokens[2], actual.Chapters); + Assert.Equal(expectedParseInfo[0], actual.Series); + Assert.Equal(expectedParseInfo[1], actual.Volumes); + Assert.Equal(expectedParseInfo[2], actual.Chapters); } [Theory] @@ -74,8 +73,8 @@ public class DefaultParserTests fs.AddDirectory(rootDirectory); fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); - var parser = new DefaultParser(ds); - var actual = parser.Parse(inputFile, rootDirectory); + var parser = new BasicParser(ds, new ImageParser(ds)); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -90,8 +89,8 @@ public class DefaultParserTests fs.AddDirectory(rootDirectory); fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); - var parser = new DefaultParser(ds); - var actual = parser.Parse(inputFile, rootDirectory); + var parser = new BasicParser(ds, new ImageParser(ds)); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -101,13 +100,6 @@ public class DefaultParserTests #region Parse - [Fact] - public void Parse_MangaLibrary_JustCover_ShouldReturnNull() - { - const string rootPath = @"E:/Manga/"; - var actual = _defaultParser.Parse(@"E:/Manga/Accel World/cover.png", rootPath); - Assert.Null(actual); - } [Fact] public void Parse_ParseInfo_Manga() @@ -127,19 +119,20 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", Volumes = "1", - Chapters = "0", Filename = "Vol 1.cbz", Format = MangaFormat.Archive, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Vol 1.cbz", Format = MangaFormat.Archive, 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 = "0", + Series = "Beelzebub", Volumes = Parser.LooseLeafVolume, Chapters = "1", Filename = "Beelzebub_01_[Noodles].zip", Format = MangaFormat.Archive, FullFilePath = filepath }); - filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip"; + // 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"; expected.Add(filepath, new ParserInfo { Series = "Ichinensei ni Nacchattara", Volumes = "1", @@ -147,71 +140,71 @@ 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 = "", - Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); - filepath = @"E:\Manga\Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)\Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz"; + filepath = @"E:/Manga/Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)/Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz"; expected.Add(filepath, new ParserInfo { Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "", - Chapters = "0", Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); - filepath = @"E:\Manga\Dorohedoro\Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; + filepath = @"E:/Manga/Dorohedoro/Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; expected.Add(filepath, new ParserInfo { Series = "Dorohedoro", Volumes = "1", Edition = "", - Chapters = "0", Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, 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 = "0", Edition = "", + Series = "APOSIMZ", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "40", Filename = "APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); - filepath = @"E:\Manga\Corpse Party Musume\Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; + filepath = @"E:/Manga/Corpse Party Musume/Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; expected.Add(filepath, new ParserInfo { - Series = "Kedouin Makoto - Corpse Party Musume", Volumes = "0", Edition = "", + Series = "Kedouin Makoto - Corpse Party Musume", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "9", Filename = "Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); - filepath = @"E:\Manga\Goblin Slayer\Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; + filepath = @"E:/Manga/Goblin Slayer/Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; expected.Add(filepath, new ParserInfo { - Series = "Goblin Slayer - Brand New Day", Volumes = "0", Edition = "", + Series = "Goblin Slayer - Brand New Day", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "6.5", Filename = "Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); - filepath = @"E:\Manga\Summer Time Rendering\Specials\Record 014 (between chapter 083 and ch084) SP11.cbr"; + filepath = @"E:/Manga/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr"; expected.Add(filepath, new ParserInfo { - Series = "Summer Time Rendering", Volumes = "0", Edition = "", - Chapters = "0", Filename = "Record 014 (between chapter 083 and ch084) SP11.cbr", Format = MangaFormat.Archive, + Series = "Summer Time Rendering", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, Edition = "", + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Record 014 (between chapter 083 and ch084) SP11.cbr", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = true }); - filepath = @"E:\Manga\Seraph of the End\Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz"; + filepath = @"E:/Manga/Seraph of the End/Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz"; expected.Add(filepath, new ParserInfo { - Series = "Seraph of the End - Vampire Reign", Volumes = "0", Edition = "", + Series = "Seraph of the End - Vampire Reign", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "93", Filename = "Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Manga\Kono Subarashii Sekai ni Bakuen wo!\Vol. 00 Ch. 000.cbz"; + 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 = "", @@ -219,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 = "", @@ -228,37 +221,37 @@ 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 = "0", Edition = "", + Series = "The Beginning After the End", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "1", Filename = "Chapter 001.cbz", Format = MangaFormat.Archive, 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", - Chapters = "0", Filename = "Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", Format = MangaFormat.Archive, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Manga\Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub"; + filepath = @"E:/Manga/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub"; expected.Add(filepath, new ParserInfo { Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "", - Chapters = "0", Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, FullFilePath = filepath, IsSpecial = false }); foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null); if (expectedInfo == null) { Assert.Null(actual); @@ -283,20 +276,20 @@ public class DefaultParserTests } } - [Fact] + //[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 = "0", Edition = "", + 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"); + 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); @@ -314,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 = "", @@ -322,7 +315,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\"); + 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); @@ -340,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 = "", @@ -348,7 +341,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\"); + 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); @@ -379,7 +372,7 @@ public class DefaultParserTests filesystem.AddFile(@"E:/Manga/Foo 50/Specials/Foo 50 SP01.cbz", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var parser = new DefaultParser(ds); + var parser = new BasicParser(ds, new ImageParser(ds)); var filepath = @"E:/Manga/Foo 50/Foo 50 v1.cbz"; // There is a bad parse for series like "Foo 50", so we have parsed chapter as 50 @@ -390,7 +383,7 @@ public class DefaultParserTests FullFilePath = filepath }; - var actual = parser.Parse(filepath, rootPath); + var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); @@ -414,12 +407,12 @@ public class DefaultParserTests filepath = @"E:/Manga/Foo 50/Specials/Foo 50 SP01.cbz"; expected = new ParserInfo { - Series = "Foo 50", Volumes = "0", IsSpecial = true, - Chapters = "50", Filename = "Foo 50 SP01.cbz", Format = MangaFormat.Archive, + Series = "Foo 50", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, IsSpecial = true, + Chapters = Parser.DefaultChapter, Filename = "Foo 50 SP01.cbz", Format = MangaFormat.Archive, FullFilePath = filepath }; - actual = parser.Parse(filepath, rootPath); + actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expected.Format, actual.Format); @@ -444,26 +437,26 @@ public class DefaultParserTests [Fact] public void Parse_ParseInfo_Comic() { - const string rootPath = @"E:/Comics/"; + const string rootPath = "E:/Comics/"; var expected = new Dictionary(); var filepath = @"E:/Comics/Teen Titans/Teen Titans v1 Annual 01 (1967) SP01.cbr"; expected.Add(filepath, new ParserInfo { - Series = "Teen Titans", Volumes = "0", - Chapters = "0", Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, + Series = "Teen Titans", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, + Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, FullFilePath = filepath }); // Fallback test with bad naming - filepath = @"E:\Comics\Comics\Babe\Babe Vol.1 #1-4\Babe 01.cbr"; + filepath = @"E:/Comics/Comics/Babe/Babe Vol.1 #1-4/Babe 01.cbr"; expected.Add(filepath, new ParserInfo { - Series = "Babe", Volumes = "0", Edition = "", + Series = "Babe", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "1", Filename = "Babe 01.cbr", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); - filepath = @"E:\Comics\Comics\Publisher\Batman the Detective (2021)\Batman the Detective - v6 - 11 - (2021).cbr"; + 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 = "", @@ -471,10 +464,10 @@ 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 = "0", Edition = "", + Series = "Batman - The Man Who Laughs", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", Chapters = "1", Filename = "Batman - The Man Who Laughs #1 (2005).cbr", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); @@ -482,7 +475,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, LibraryType.Comic); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null); if (expectedInfo == null) { Assert.Null(actual); diff --git a/API.Tests/Parsers/ImageParserTests.cs b/API.Tests/Parsers/ImageParserTests.cs new file mode 100644 index 000000000..f95c98ddf --- /dev/null +++ b/API.Tests/Parsers/ImageParserTests.cs @@ -0,0 +1,97 @@ +using System.IO.Abstractions.TestingHelpers; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Parsers; + +public class ImageParserTests +{ + private readonly ImageParser _parser; + private readonly ILogger _dsLogger = Substitute.For>(); + private const string RootDirectory = "C:/Comics/"; + + public ImageParserTests() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Comics/"); + fileSystem.AddDirectory("C:/Comics/Birds of Prey (2002)"); + fileSystem.AddFile("C:/Comics/Birds of Prey/Chapter 01/01.jpg", new MockFileData("")); + fileSystem.AddFile("C:/Comics/DC Comics/Birds of Prey/Chapter 01/01.jpg", new MockFileData("")); + var ds = new DirectoryService(_dsLogger, fileSystem); + _parser = new ImageParser(ds); + } + + #region Parse + + /// + /// Tests that if there is a Series Folder then Chapter folder, the code appropriately identifies the Series name and Chapter + /// + [Fact] + public void Parse_SeriesWithDirectoryName() + { + var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/", + RootDirectory, LibraryType.Image, null); + + Assert.NotNull(actual); + Assert.Equal("Birds of Prey", actual.Series); + Assert.Equal("1", actual.Chapters); + } + + /// + /// Tests that if there is a Series Folder only, the code appropriately identifies the Series name from folder + /// + [Fact] + public void Parse_SeriesWithNoNestedChapter() + { + var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/", + RootDirectory, LibraryType.Image, null); + + Assert.NotNull(actual); + Assert.Equal("Birds of Prey", actual.Series); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + } + + /// + /// Tests that if there is a Series Folder only, the code appropriately identifies the Series name from folder and everything else as a + /// + [Fact] + public void Parse_SeriesWithLooseImages() + { + var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/", + RootDirectory, LibraryType.Image, null); + + Assert.NotNull(actual); + Assert.Equal("Birds of Prey", actual.Series); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + + #endregion + + #region IsApplicable + /// + /// Tests that this Parser can only be used on images and Image library type + /// + [Fact] + public void IsApplicable_Fails_WhenNonMatchingLibraryType() + { + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Manga)); + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Image)); + Assert.False(_parser.IsApplicable("something.epub", LibraryType.Image)); + } + + /// + /// Tests that this Parser can only be used on images and Image library type + /// + [Fact] + public void IsApplicable_Success_WhenMatchingLibraryType() + { + Assert.True(_parser.IsApplicable("something.png", LibraryType.Image)); + } + #endregion +} diff --git a/API.Tests/Parsers/PdfParserTests.cs b/API.Tests/Parsers/PdfParserTests.cs new file mode 100644 index 000000000..72088526d --- /dev/null +++ b/API.Tests/Parsers/PdfParserTests.cs @@ -0,0 +1,71 @@ +using System.IO.Abstractions.TestingHelpers; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Parsers; + +public class PdfParserTests +{ + private readonly PdfParser _parser; + private readonly ILogger _dsLogger = Substitute.For>(); + private const string RootDirectory = "C:/Books/"; + + public PdfParserTests() + { + var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory("C:/Books/"); + fileSystem.AddDirectory("C:/Books/Birds of Prey (2002)"); + fileSystem.AddFile("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", new MockFileData("")); + fileSystem.AddFile("C:/Comics/DC Comics/Birds of Prey/Chapter 01/01.jpg", new MockFileData("")); + var ds = new DirectoryService(_dsLogger, fileSystem); + _parser = new PdfParser(ds); + } + + #region Parse + + /// + /// Tests that if there is a Series Folder then Chapter folder, the code appropriately identifies the Series name and Chapter + /// + [Fact] + public void Parse_Book_SeriesWithDirectoryName() + { + var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", + "C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/", + RootDirectory, LibraryType.Book, null); + + Assert.NotNull(actual); + Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + #endregion + + #region IsApplicable + /// + /// Tests that this Parser can only be used on pdfs + /// + [Fact] + public void IsApplicable_Fails_WhenNonMatchingLibraryType() + { + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Manga)); + Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Image)); + Assert.False(_parser.IsApplicable("something.epub", LibraryType.Image)); + Assert.False(_parser.IsApplicable("something.png", LibraryType.Book)); + } + + /// + /// Tests that this Parser can only be used on pdfs + /// + [Fact] + public void IsApplicable_Success_WhenMatchingLibraryType() + { + Assert.True(_parser.IsApplicable("something.pdf", LibraryType.Book)); + Assert.True(_parser.IsApplicable("something.pdf", LibraryType.Manga)); + } + #endregion +} diff --git a/API.Tests/Parsing/BookParsingTests.cs b/API.Tests/Parsing/BookParsingTests.cs new file mode 100644 index 000000000..9b02eff63 --- /dev/null +++ b/API.Tests/Parsing/BookParsingTests.cs @@ -0,0 +1,24 @@ +using API.Entities.Enums; +using Xunit; + +namespace API.Tests.Parsing; + +public class BookParsingTests +{ + [Theory] + [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")] + [InlineData("BBC Focus 00 The Science of Happiness 2nd Edition (2018)", "BBC Focus 00 The Science of Happiness 2nd Edition")] + [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "Faust")] + public void ParseSeriesTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Book)); + } + + [Theory] + [InlineData("Harrison, Kim - Dates from Hell - Hollows Vol 2.5.epub", "2.5")] + [InlineData("Faust - Volume 01 [Del Rey][Scans_Compressed]", "1")] + public void ParseVolumeTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Book)); + } +} diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parsing/ComicParsingTests.cs similarity index 67% rename from API.Tests/Parser/ComicParserTests.cs rename to API.Tests/Parsing/ComicParsingTests.cs index 4740c4f54..a0375a566 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parsing/ComicParsingTests.cs @@ -1,26 +1,11 @@ -using System.IO.Abstractions.TestingHelpers; -using API.Services; +using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; -using Xunit.Abstractions; -namespace API.Tests.Parser; +namespace API.Tests.Parsing; -public class ComicParserTests +public class ComicParsingTests { - private readonly ITestOutputHelper _testOutputHelper; - private readonly DefaultParser _defaultParser; - - public ComicParserTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - _defaultParser = - new DefaultParser(new DirectoryService(Substitute.For>(), - new MockFileSystem())); - } - [Theory] [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "Asterix the Gladiator")] [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "The First Asterix Frieze")] @@ -66,56 +51,58 @@ public class ComicParserTests [InlineData("Demon 012 (Sep 1973) c2c", "Demon")] [InlineData("Dragon Age - Until We Sleep 01 (of 03)", "Dragon Age - Until We Sleep")] [InlineData("Green Lantern v2 017 - The Spy-Eye that doomed Green Lantern v2", "Green Lantern")] - [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire - Adam Strange")] - [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis - Rags Morales Sketches")] + [InlineData("Green Lantern - Circle of Fire Special - Adam Strange (2000)", "Green Lantern - Circle of Fire Special - Adam Strange")] + [InlineData("Identity Crisis Extra - Rags Morales Sketches (2005)", "Identity Crisis Extra - Rags Morales Sketches")] [InlineData("Daredevil - t6 - 10 - (2019)", "Daredevil")] [InlineData("Batgirl T2000 #57", "Batgirl")] [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "Teen Titans")] [InlineData("Conquistador_-Tome_2", "Conquistador")] [InlineData("Max_l_explorateur-_Tome_0", "Max l explorateur")] [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")] - [InlineData("Bd Fr-Aldebaran-Antares-t6", "Aldebaran-Antares")] + [InlineData("Bd Fr-Aldebaran-Antares-t6", "Bd Fr-Aldebaran-Antares")] [InlineData("Tintin - T22 Vol 714 pour Sydney", "Tintin")] [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)); + Assert.Equal(expected, Parser.ParseComicSeries(filename)); } [Theory] - [InlineData("01 Spider-Man & Wolverine 01.cbr", "0")] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] - [InlineData("Batman & Catwoman - Trail of the Gun 01", "0")] - [InlineData("Batman & Daredevil - King of New York", "0")] - [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "0")] - [InlineData("Batman & Robin the Teen Wonder #0", "0")] - [InlineData("Batman & Wildcat (1 of 3)", "0")] - [InlineData("Batman And Superman World's Finest #01", "0")] - [InlineData("Babe 01", "0")] - [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "0")] + [InlineData("01 Spider-Man & Wolverine 01.cbr", Parser.LooseLeafVolume)] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", Parser.LooseLeafVolume)] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", Parser.LooseLeafVolume)] + [InlineData("Batman & Catwoman - Trail of the Gun 01", Parser.LooseLeafVolume)] + [InlineData("Batman & Daredevil - King of New York", Parser.LooseLeafVolume)] + [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", Parser.LooseLeafVolume)] + [InlineData("Batman & Robin the Teen Wonder #0", Parser.LooseLeafVolume)] + [InlineData("Batman & Wildcat (1 of 3)", Parser.LooseLeafVolume)] + [InlineData("Batman And Superman World's Finest #01", Parser.LooseLeafVolume)] + [InlineData("Babe 01", Parser.LooseLeafVolume)] + [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", Parser.LooseLeafVolume)] [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] - [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "0")] + [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", Parser.LooseLeafVolume)] [InlineData("Superman v1 024 (09-10 1943)", "1")] [InlineData("Superman v1.5 024 (09-10 1943)", "1.5")] - [InlineData("Amazing Man Comics chapter 25", "0")] - [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")] - [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] - [InlineData("spawn-123", "0")] - [InlineData("spawn-chapter-123", "0")] - [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "0")] - [InlineData("Batman Beyond 04 (of 6) (1999)", "0")] - [InlineData("Batman Beyond 001 (2012)", "0")] - [InlineData("Batman Beyond 2.0 001 (2013)", "0")] - [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "0")] + [InlineData("Amazing Man Comics chapter 25", Parser.LooseLeafVolume)] + [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", Parser.LooseLeafVolume)] + [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", Parser.LooseLeafVolume)] + [InlineData("spawn-123", Parser.LooseLeafVolume)] + [InlineData("spawn-chapter-123", Parser.LooseLeafVolume)] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", Parser.LooseLeafVolume)] + [InlineData("Batman Beyond 04 (of 6) (1999)", Parser.LooseLeafVolume)] + [InlineData("Batman Beyond 001 (2012)", Parser.LooseLeafVolume)] + [InlineData("Batman Beyond 2.0 001 (2013)", Parser.LooseLeafVolume)] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", Parser.LooseLeafVolume)] [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "1")] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", Parser.LooseLeafVolume)] [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "2000")] [InlineData("Batgirl V2000 #57", "2000")] - [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "0")] - [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "0")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", Parser.LooseLeafVolume)] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", Parser.LooseLeafVolume)] [InlineData("Daredevil - v6 - 10 - (2019)", "6")] [InlineData("Daredevil - v6.5", "6.5")] // Tome Tests @@ -125,22 +112,25 @@ public class ComicParserTests [InlineData("Conquistador_Tome_2", "2")] [InlineData("Max_l_explorateur-_Tome_0", "0")] [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "3")] - [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "0")] + [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", Parser.LooseLeafVolume)] [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "1")] // Russian Tests [InlineData("Kebab Том 1 Глава 3", "1")] - [InlineData("Манга Глава 2", "0")] + [InlineData("Манга Глава 2", Parser.LooseLeafVolume)] + [InlineData("ย้อนเวลากลับมาร้าย เล่ม 1", "1")] + [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "1")] + [InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", Parser.LooseLeafVolume)] public void ParseComicVolumeTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename)); + Assert.Equal(expected, Parser.ParseComicVolume(filename)); } [Theory] [InlineData("01 Spider-Man & Wolverine 01.cbr", "1")] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", Parser.DefaultChapter)] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", Parser.DefaultChapter)] [InlineData("Batman & Catwoman - Trail of the Gun 01", "1")] - [InlineData("Batman & Daredevil - King of New York", "0")] + [InlineData("Batman & Daredevil - King of New York", Parser.DefaultChapter)] [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")] [InlineData("Batman & Robin the Teen Wonder #0", "0")] [InlineData("Batman & Wildcat (1 of 3)", "1")] @@ -164,8 +154,8 @@ public class ComicParserTests [InlineData("Batman Beyond 001 (2012)", "1")] [InlineData("Batman Beyond 2.0 001 (2013)", "1")] [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "1")] - [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "0")] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", Parser.DefaultChapter)] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", Parser.DefaultChapter)] [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "57")] [InlineData("Batgirl V2000 #57", "57")] [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "21")] @@ -174,43 +164,47 @@ public class ComicParserTests [InlineData("Daredevil - v6 - 10 - (2019)", "10")] [InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")] [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "1")] - [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "0")] + [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", Parser.DefaultChapter)] [InlineData("Kebab Том 1 Глава 3", "3")] [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)); + Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Comic)); } [Theory] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", true)] - [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", true)] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", false)] + [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", false)] [InlineData("Baldwin the Brave & Other Tales Special SP1.cbr", true)] - [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", true)] - [InlineData("Boule et Bill - THS -Bill à disparu", true)] - [InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)] - [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] + [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", false)] + [InlineData("Boule et Bill - THS -Bill à disparu", false)] + [InlineData("Asterix - HS - Les 12 travaux d'Astérix", false)] + [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", false)] [InlineData("laughs", false)] - [InlineData("Annual Days of Summer", true)] - [InlineData("Adventure Time 2013 Annual #001 (2013)", true)] - [InlineData("Adventure Time 2013_Annual_#001 (2013)", true)] - [InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)] + [InlineData("Annual Days of Summer", false)] + [InlineData("Adventure Time 2013 Annual #001 (2013)", false)] + [InlineData("Adventure Time 2013_Annual_#001 (2013)", false)] + [InlineData("Adventure Time 2013_-_Annual #001 (2013)", false)] [InlineData("G.I. Joe - A Real American Hero Yearbook 004 Reprint (2021)", false)] [InlineData("Mazebook 001", false)] - [InlineData("X-23 One Shot (2010)", true)] - [InlineData("Casus Belli v1 Hors-Série 21 - Mousquetaires et Sorcellerie", true)] - [InlineData("Batman Beyond Annual", true)] - [InlineData("Batman Beyond Bonus", true)] - [InlineData("Batman Beyond OneShot", true)] - [InlineData("Batman Beyond Specials", true)] - [InlineData("Batman Beyond Omnibus (1999)", true)] - [InlineData("Batman Beyond Omnibus", true)] - [InlineData("01 Annual Batman Beyond", true)] + [InlineData("X-23 One Shot (2010)", false)] + [InlineData("Casus Belli v1 Hors-Série 21 - Mousquetaires et Sorcellerie", false)] + [InlineData("Batman Beyond Annual", false)] + [InlineData("Batman Beyond Bonus", false)] + [InlineData("Batman Beyond OneShot", false)] + [InlineData("Batman Beyond Specials", false)] + [InlineData("Batman Beyond Omnibus (1999)", false)] + [InlineData("Batman Beyond Omnibus", false)] + [InlineData("01 Annual Batman Beyond", false)] + [InlineData("Blood Syndicate Annual #001", false)] public void IsComicSpecialTest(string input, bool expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsComicSpecial(input)); + Assert.Equal(expected, Parser.IsSpecial(input, LibraryType.Comic)); } } diff --git a/API.Tests/Parsing/ImageParsingTests.cs b/API.Tests/Parsing/ImageParsingTests.cs new file mode 100644 index 000000000..3d78d9372 --- /dev/null +++ b/API.Tests/Parsing/ImageParsingTests.cs @@ -0,0 +1,107 @@ +using System.IO.Abstractions.TestingHelpers; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Parsing; + +public class ImageParsingTests +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly ImageParser _parser; + + public ImageParsingTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + var directoryService = new DirectoryService(Substitute.For>(), new MockFileSystem()); + _parser = new ImageParser(directoryService); + } + + //[Fact] + public void Parse_ParseInfo_Manga_ImageOnly() + { + // 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 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 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null); + Assert.NotNull(actual2); + _testOutputHelper.WriteLine($"Validating {filepath}"); + Assert.Equal(expectedInfo2.Format, actual2.Format); + _testOutputHelper.WriteLine("Format ✓"); + Assert.Equal(expectedInfo2.Series, actual2.Series); + _testOutputHelper.WriteLine("Series ✓"); + Assert.Equal(expectedInfo2.Chapters, actual2.Chapters); + _testOutputHelper.WriteLine("Chapters ✓"); + Assert.Equal(expectedInfo2.Volumes, actual2.Volumes); + _testOutputHelper.WriteLine("Volumes ✓"); + Assert.Equal(expectedInfo2.Edition, actual2.Edition); + _testOutputHelper.WriteLine("Edition ✓"); + Assert.Equal(expectedInfo2.Filename, actual2.Filename); + _testOutputHelper.WriteLine("Filename ✓"); + 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"; + expectedInfo2 = new ParserInfo + { + Series = "Just Images the second", Volumes = "19", Edition = "", + Chapters = "186", Filename = "Vol. 19 p106.gif", Format = MangaFormat.Image, + FullFilePath = filepath, IsSpecial = false + }; + + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + Assert.NotNull(actual2); + _testOutputHelper.WriteLine($"Validating {filepath}"); + Assert.Equal(expectedInfo2.Format, actual2.Format); + _testOutputHelper.WriteLine("Format ✓"); + Assert.Equal(expectedInfo2.Series, actual2.Series); + _testOutputHelper.WriteLine("Series ✓"); + Assert.Equal(expectedInfo2.Chapters, actual2.Chapters); + _testOutputHelper.WriteLine("Chapters ✓"); + Assert.Equal(expectedInfo2.Volumes, actual2.Volumes); + _testOutputHelper.WriteLine("Volumes ✓"); + Assert.Equal(expectedInfo2.Edition, actual2.Edition); + _testOutputHelper.WriteLine("Edition ✓"); + Assert.Equal(expectedInfo2.Filename, actual2.Filename); + _testOutputHelper.WriteLine("Filename ✓"); + 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"; + expectedInfo2 = new ParserInfo + { + Series = "Just Images the second", Volumes = "19", Edition = "", + Chapters = "186", Filename = "Vol. 19 p106.gif", Format = MangaFormat.Image, + FullFilePath = filepath, IsSpecial = false + }; + + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + Assert.NotNull(actual2); + _testOutputHelper.WriteLine($"Validating {filepath}"); + Assert.Equal(expectedInfo2.Format, actual2.Format); + _testOutputHelper.WriteLine("Format ✓"); + Assert.Equal(expectedInfo2.Series, actual2.Series); + _testOutputHelper.WriteLine("Series ✓"); + Assert.Equal(expectedInfo2.Chapters, actual2.Chapters); + _testOutputHelper.WriteLine("Chapters ✓"); + Assert.Equal(expectedInfo2.Volumes, actual2.Volumes); + _testOutputHelper.WriteLine("Volumes ✓"); + Assert.Equal(expectedInfo2.Edition, actual2.Edition); + _testOutputHelper.WriteLine("Edition ✓"); + Assert.Equal(expectedInfo2.Filename, actual2.Filename); + _testOutputHelper.WriteLine("Filename ✓"); + Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath); + _testOutputHelper.WriteLine("FullFilePath ✓"); + } +} diff --git a/API.Tests/Parser/MagazineParserTests.cs b/API.Tests/Parsing/MagazineParserTests.cs similarity index 100% rename from API.Tests/Parser/MagazineParserTests.cs rename to API.Tests/Parsing/MagazineParserTests.cs diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parsing/MangaParsingTests.cs similarity index 85% rename from API.Tests/Parser/MangaParserTests.cs rename to API.Tests/Parsing/MangaParsingTests.cs index 126e781d6..8b93c5f90 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -1,18 +1,10 @@ using API.Entities.Enums; using Xunit; -using Xunit.Abstractions; -namespace API.Tests.Parser; +namespace API.Tests.Parsing; -public class MangaParserTests +public class MangaParsingTests { - private readonly ITestOutputHelper _testOutputHelper; - - public MangaParserTests(ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - } - [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "1")] @@ -25,7 +17,7 @@ public class MangaParserTests [InlineData("v001", "1")] [InlineData("Vol 1", "1")] [InlineData("vol_356-1", "356")] // Mangapy syntax - [InlineData("No Volume", "0")] + [InlineData("No Volume", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1.1")] [InlineData("Tonikaku Cawaii [Volume 11].cbz", "11")] @@ -40,18 +32,18 @@ public class MangaParserTests [InlineData("Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", "1")] [InlineData("Dorohedoro v11 (2013) (Digital) (LostNerevarine-Empire).cbz", "11")] [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] - [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "0")] + [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "0")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("VanDread-v01-c001[MD].zip", "1")] [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch27_[VISCANS].zip", "4")] [InlineData("Mob Psycho 100 v02 (2019) (Digital) (Shizu).cbz", "2")] [InlineData("Kodomo no Jikan vol. 1.cbz", "1")] [InlineData("Kodomo no Jikan vol. 10.cbz", "10")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", "0")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("Vagabond_v03", "3")] [InlineData("Mujaki No Rakune Volume 10.cbz", "10")] - [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "0")] + [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("Volume 12 - Janken Boy is Coming!.cbz", "12")] [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "20")] [InlineData("Gantz.V26.cbz", "26")] @@ -60,7 +52,7 @@ public class MangaParserTests [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "4")] [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "1")] [InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")] - [InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("X-Men v1 #201 (September 2007).cbz", "1")] [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")] [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "3")] @@ -72,21 +64,23 @@ public class MangaParserTests [InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")] [InlineData("Dance in the Vampire Bund {Special Edition} v03.5 (2019) (Digital) (KG Manga)", "3.5")] [InlineData("Kebab Том 1 Глава 3", "1")] - [InlineData("Манга Глава 2", "0")] + [InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] [InlineData("Манга Тома 1-4", "1-4")] [InlineData("Манга Том 1-4", "1-4")] [InlineData("조선왕조실톡 106화", "106")] [InlineData("죽음 13회", "13")] [InlineData("동의보감 13장", "13")] [InlineData("몰?루 아카이브 7.5권", "7.5")] + [InlineData("주술회전 1.5권", "1.5")] [InlineData("63권#200", "63")] [InlineData("시즌34삽화2", "34")] [InlineData("Accel World Chapter 001 Volume 002", "2")] [InlineData("Accel World Volume 2", "2")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake", "30")] + [InlineData("Zom 100 - Bucket List of the Dead v01", "1")] public void ParseVolumeTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename)); + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Manga)); } [Theory] @@ -139,7 +133,6 @@ public class MangaParserTests [InlineData("Vagabond_v03", "Vagabond")] [InlineData("[AN] Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1", "Mahoutsukai to Deshi no Futekisetsu na Kankei")] [InlineData("Beelzebub_Side_Story_02_RHS.zip", "Beelzebub Side Story")] - [InlineData("[BAA]_Darker_than_Black_Omake-1.zip", "Darker than Black")] [InlineData("Baketeriya ch01-05.zip", "Baketeriya")] [InlineData("[PROzess]Kimi_ha_midara_na_Boku_no_Joou_-_Ch01", "Kimi ha midara na Boku no Joou")] [InlineData("[SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar", "NEEDLESS")] @@ -206,21 +199,30 @@ public class MangaParserTests [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", "หนึ่งความคิด นิจนิรันดร์")] + [InlineData("不安の種\uff0b - 01", "不安の種\uff0b")] + [InlineData("Giant Ojou-sama - Ch. 33.5 - Volume 04 Bonus Chapter", "Giant Ojou-sama")] + [InlineData("[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", "")] + [InlineData("Monster #8 Ch. 001", "Monster #8")] + [InlineData("Zom 100 - Bucket List of the Dead v01", "Zom 100 - Bucket List of the Dead")] public void ParseSeriesTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename)); + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga)); } [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "90-98")] - [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "0")] - [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "0")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1-8")] - [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "0")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("c001", "1")] - [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", "0")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("Adding volume 1 with File: Ana Satsujin Vol. 1 Ch. 5 - Manga Box (gb).cbz", "5")] [InlineData("Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz", "18")] [InlineData("Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip", "0-6")] @@ -243,7 +245,7 @@ public class MangaParserTests [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1-6")] [InlineData("APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", "40")] [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "12")] - [InlineData("Vol 1", "0")] + [InlineData("Vol 1", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("VanDread-v01-c001[MD].zip", "1")] [InlineData("Goblin Slayer Side Story - Year One 025.5", "25.5")] [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "1")] @@ -255,10 +257,10 @@ public class MangaParserTests [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "101-108")] [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "2")] [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "71-79")] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter.rar", "0")] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter.rar", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("Beelzebub_153b_RHS.zip", "153.5")] [InlineData("Beelzebub_150-153b_RHS.zip", "150-153.5")] - [InlineData("Transferred to another world magical swordsman v1.1", "0")] + [InlineData("Transferred to another world magical swordsman v1.1", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "15")] [InlineData("Kiss x Sis - Ch.12 - 1 , 2 , 3P!.cbz", "12")] [InlineData("Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1", "1")] @@ -277,26 +279,31 @@ public class MangaParserTests [InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "1-10")] [InlineData("Deku_&_Bakugo_-_Rising_v1_c1.1.cbz", "1.1")] [InlineData("Chapter 63 - The Promise Made for 520 Cenz.cbr", "63")] - [InlineData("Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", "0")] + [InlineData("Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")] - [InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")] + [InlineData("Samurai Jack Vol. 01 - The threads of Time", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")] [InlineData("자유록 13회#2", "13")] [InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")] [InlineData("[ハレム]ナナとカオル ~高校生のSMごっこ~ 第10話", "10")] - [InlineData("Dance in the Vampire Bund {Special Edition} v03.5 (2019) (Digital) (KG Manga)", "0")] + [InlineData("Dance in the Vampire Bund {Special Edition} v03.5 (2019) (Digital) (KG Manga)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [InlineData("Kebab Том 1 Глава 3", "3")] [InlineData("Манга Глава 2", "2")] [InlineData("Манга 2 Глава", "2")] [InlineData("Манга Том 1 2 Глава", "2")] [InlineData("Accel World Chapter 001 Volume 002", "1")] [InlineData("Bleach 001-003", "1-3")] - [InlineData("Accel World Volume 2", "0")] + [InlineData("Accel World Volume 2", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] [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")] + [InlineData("Monster #8 Ch. 001", "1")] public void ParseChaptersTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename)); + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename, LibraryType.Manga)); } @@ -316,25 +323,25 @@ public class MangaParserTests Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseEdition(input)); } [Theory] - [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", true)] - [InlineData("Beelzebub_Omake_June_2012_RHS", true)] + [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", false)] + [InlineData("Beelzebub_Omake_June_2012_RHS", false)] [InlineData("Beelzebub_Side_Story_02_RHS.zip", false)] - [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", true)] - [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", true)] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] - [InlineData("Ani-Hina Art Collection.cbz", true)] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", true)] - [InlineData("A Town Where You Live - Bonus Chapter.zip", true)] + [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", false)] + [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", false)] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", false)] + [InlineData("Ani-Hina Art Collection.cbz", false)] + [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", false)] + [InlineData("A Town Where You Live - Bonus Chapter.zip", false)] [InlineData("Yuki Merry - 4-Komga Anthology", false)] - [InlineData("Beastars - SP01", false)] - [InlineData("Beastars SP01", false)] + [InlineData("Beastars - SP01", true)] + [InlineData("Beastars SP01", true)] [InlineData("The League of Extraordinary Gentlemen", false)] [InlineData("The League of Extra-ordinary Gentlemen", false)] [InlineData("Dr. Ramune - Mysterious Disease Specialist v01 (2020) (Digital) (danke-Empire)", false)] [InlineData("Hajime no Ippo - Artbook", false)] public void IsMangaSpecialTest(string input, bool expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsMangaSpecial(input)); + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsSpecial(input, LibraryType.Manga)); } [Theory] diff --git a/API.Tests/Parser/ParserInfoTests.cs b/API.Tests/Parsing/ParserInfoTests.cs similarity index 90% rename from API.Tests/Parser/ParserInfoTests.cs rename to API.Tests/Parsing/ParserInfoTests.cs index e7c48317b..cbb8ae99a 100644 --- a/API.Tests/Parser/ParserInfoTests.cs +++ b/API.Tests/Parsing/ParserInfoTests.cs @@ -2,7 +2,7 @@ using API.Services.Tasks.Scanner.Parser; using Xunit; -namespace API.Tests.Parser; +namespace API.Tests.Parsing; public class ParserInfoTests { @@ -11,14 +11,14 @@ public class ParserInfoTests { var p1 = new ParserInfo() { - Chapters = "0", + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = "0" + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() @@ -30,7 +30,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "Darker Than Black", - Volumes = "0" + Volumes = Parser.LooseLeafVolume }; var expected = new ParserInfo() @@ -42,7 +42,7 @@ public class ParserInfoTests IsSpecial = false, Series = "darker than black", Title = "darker than black", - Volumes = "0" + Volumes = Parser.LooseLeafVolume }; p1.Merge(p2); @@ -62,12 +62,12 @@ public class ParserInfoTests IsSpecial = true, Series = "darker than black", Title = "darker than black", - Volumes = "0" + Volumes = Parser.LooseLeafVolume }; var p2 = new ParserInfo() { - Chapters = "0", + Chapters = Parser.DefaultChapter, Edition = "", Format = MangaFormat.Archive, FullFilePath = "/manga/darker than black.cbz", diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parsing/ParsingTests.cs similarity index 89% rename from API.Tests/Parser/ParserTest.cs rename to API.Tests/Parsing/ParsingTests.cs index 5bdd3eb6e..7d5da4f9c 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parsing/ParsingTests.cs @@ -3,18 +3,32 @@ using System.Linq; using Xunit; using static API.Services.Tasks.Scanner.Parser.Parser; -namespace API.Tests.Parser; +namespace API.Tests.Parsing; -public class ParserTests +public class ParsingTests { [Fact] public void ShouldWork() { - var s = 6.5f + ""; + var s = 6.5f.ToString(CultureInfo.InvariantCulture); var a = float.Parse(s, CultureInfo.InvariantCulture); Assert.Equal(6.5f, a); + + s = 6.5f + ""; + a = float.Parse(s, CultureInfo.CurrentCulture); + Assert.Equal(6.5f, a); } + // [Theory] + // [InlineData("de-DE")] + // [InlineData("en-US")] + // public void ShouldParse(string culture) + // { + // var s = 6.5f + ""; + // var a = float.Parse(s, CultureInfo.CreateSpecificCulture(culture)); + // Assert.Equal(6.5f, a); + // } + [Theory] [InlineData("Joe Shmo, Green Blue", "Joe Shmo, Green Blue")] [InlineData("Shmo, Joe", "Shmo, Joe")] @@ -29,6 +43,7 @@ public class ParserTests [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")] [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")] [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")] + [InlineData("SP01 1. DEAD Tube Prologue", "1. DEAD Tube Prologue")] public void CleanSpecialTitleTest(string input, string expected) { Assert.Equal(expected, CleanSpecialTitle(input)); @@ -45,6 +60,18 @@ public class ParserTests Assert.Equal(expected, HasSpecialMarker(input)); } + [Theory] + [InlineData("Beastars - SP01", 1)] + [InlineData("Beastars SP01", 1)] + [InlineData("Beastars Special 01", 0)] + [InlineData("Beastars Extra 01", 0)] + [InlineData("Batman Beyond - Return of the Joker (2001) SP01", 1)] + [InlineData("Batman Beyond - Return of the Joker (2001)", 0)] + public void ParseSpecialIndexTest(string input, int expected) + { + Assert.Equal(expected, ParseSpecialIndex(input)); + } + [Theory] [InlineData("0001", "1")] [InlineData("1", "1")] @@ -71,7 +98,8 @@ public class ParserTests [InlineData("-The Title", false, "The Title")] [InlineData("- The Title", false, "The Title")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", + true, "Batman - Detective Comics - Rebirth Deluxe Edition Book 04")] [InlineData("Something - Full Color Edition", false, "Something - Full Color Edition")] [InlineData("Witchblade 089 (2005) (Bittertek-DCP) (Top Cow (Image Comics))", true, "Witchblade 089")] [InlineData("(C99) Kami-sama Hiroimashita. (SSSS.GRIDMAN)", false, "Kami-sama Hiroimashita.")] @@ -155,6 +183,7 @@ public class ParserTests [InlineData("3.5", 3.5)] [InlineData("3.5-4.0", 3.5)] [InlineData("asdfasdf", 0.0)] + [InlineData("-10", -10.0)] public void MinimumNumberFromRangeTest(string input, float expected) { Assert.Equal(expected, MinNumberFromRange(input)); @@ -171,6 +200,7 @@ public class ParserTests [InlineData("3.5", 3.5)] [InlineData("3.5-4.0", 4.0)] [InlineData("asdfasdf", 0.0)] + [InlineData("-10", -10.0)] public void MaximumNumberFromRangeTest(string input, float expected) { Assert.Equal(expected, MaxNumberFromRange(input)); @@ -186,6 +216,7 @@ public class ParserTests [InlineData("카비타", "카비타")] [InlineData("06", "06")] [InlineData("", "")] + [InlineData("不安の種+", "不安の種+")] public void NormalizeTest(string input, string expected) { Assert.Equal(expected, Normalize(input)); @@ -220,6 +251,7 @@ public class ParserTests [InlineData("ch1/backcover.png", false)] [InlineData("backcover.png", false)] [InlineData("back_cover.png", false)] + [InlineData("LD Blacklands #1 35 (back cover).png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); @@ -235,6 +267,7 @@ public class ParserTests [InlineData("@recycle/Love Hina/", true)] [InlineData("E:/Test/__MACOSX/Love Hina/", true)] [InlineData("E:/Test/.caltrash/Love Hina/", true)] + [InlineData("E:/Test/.yacreaderlibrary/Love Hina/", true)] public void HasBlacklistedFolderInPathTest(string inputPath, bool expected) { Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath)); diff --git a/API.Tests/Repository/CollectionTagRepositoryTests.cs b/API.Tests/Repository/CollectionTagRepositoryTests.cs index 1859ab1fc..5318260be 100644 --- a/API.Tests/Repository/CollectionTagRepositoryTests.cs +++ b/API.Tests/Repository/CollectionTagRepositoryTests.cs @@ -15,7 +15,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging; using NSubstitute; -using Xunit; namespace API.Tests.Repository; @@ -114,65 +113,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/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index ec4b2a9f5..5705e1bc0 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using API.Data; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; @@ -159,4 +158,6 @@ public class SeriesRepositoryTests } } + // TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck) + } diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 086d99863..8cf93df37 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -7,7 +7,6 @@ using System.Linq; using API.Archive; using API.Entities.Enums; using API.Services; -using EasyCaching.Core; using Microsoft.Extensions.Logging; using NetVips; using NSubstitute; @@ -29,7 +28,7 @@ public class ArchiveServiceTests { _testOutputHelper = testOutputHelper; _archiveService = new ArchiveService(_logger, _directoryService, - new ImageService(Substitute.For>(), _directoryService, Substitute.For()), + new ImageService(Substitute.For>(), _directoryService), Substitute.For()); } @@ -167,7 +166,7 @@ public class ArchiveServiceTests public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) { var ds = Substitute.For(_directoryServiceLogger, new FileSystem()); - var imageService = new ImageService(Substitute.For>(), ds, Substitute.For()); + var imageService = new ImageService(Substitute.For>(), ds); var archiveService = Substitute.For(_logger, ds, imageService, Substitute.For()); var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); @@ -198,7 +197,7 @@ public class ArchiveServiceTests [InlineData("sorting.zip", "sorting.expected.png")] public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) { - var imageService = new ImageService(Substitute.For>(), _directoryService, Substitute.For()); + var imageService = new ImageService(Substitute.For>(), _directoryService); var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService, Substitute.For()); diff --git a/API.Tests/Services/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs index c4ca95a11..aac5724f7 100644 --- a/API.Tests/Services/BackupServiceTests.cs +++ b/API.Tests/Services/BackupServiceTests.cs @@ -1,10 +1,8 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Data.Common; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; -using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; @@ -21,7 +19,7 @@ using Xunit; namespace API.Tests.Services; -public class BackupServiceTests +public class BackupServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; @@ -31,13 +29,6 @@ public class BackupServiceTests private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string LogDirectory = "C:/kavita/config/logs/"; - private const string ConfigDirectory = "C:/kavita/config/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks"; - private const string ThemesDirectory = "C:/kavita/config/theme"; public BackupServiceTests() { @@ -82,7 +73,7 @@ public class BackupServiceTests _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -94,22 +85,6 @@ public class BackupServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(LogDirectory); - fileSystem.AddDirectory(ThemesDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index e4647524e..a80c1ca01 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -1,7 +1,8 @@ using System.IO; using System.IO.Abstractions; +using API.Entities.Enums; using API.Services; -using EasyCaching.Core; +using API.Services.Tasks.Scanner.Parser; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -17,7 +18,7 @@ public class BookServiceTests { var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); _bookService = new BookService(_logger, directoryService, - new ImageService(Substitute.For>(), directoryService, Substitute.For()) + new ImageService(Substitute.For>(), directoryService) , Substitute.For()); } @@ -81,4 +82,64 @@ public class BookServiceTests Assert.Equal("Accel World", comicInfo.Series); } + [Fact] + public void ShouldHaveComicInfoForPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "test.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Variations Chromatiques de concert", comicInfo.Title); + Assert.Equal("Georges Bizet \\(1838-1875\\)", comicInfo.Writer); + } + + //[Fact] + public void ShouldUsePdfInfoDict() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs"); + var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Rollo at Work", comicInfo.Title); + Assert.Equal("Jacob Abbott", comicInfo.Writer); + Assert.Equal(2008, comicInfo.Year); + } + + [Fact] + public void ShouldHandleIndirectPdfObjects() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "indirect.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal(2018, comicInfo.Year); + Assert.Equal(8, comicInfo.Month); + } + + [Fact] + public void FailGracefullyWithEncryptedPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "encrypted.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.Null(comicInfo); + } + + [Fact] + public void SeriesFallBackToMetadataTitle() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + var pdfParser = new PdfParser(ds); + + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var filePath = Path.Join(testDirectory, "Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf"); + + var comicInfo = _bookService.GetComicInfo(filePath); + Assert.NotNull(comicInfo); + + var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, comicInfo); + Assert.NotNull(parserInfo); + Assert.Equal(parserInfo.Title, comicInfo.Title); + Assert.Equal(parserInfo.Series, comicInfo.Title); + } } diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs index 6a82f457d..596fbbc4d 100644 --- a/API.Tests/Services/BookmarkServiceTests.cs +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -9,12 +9,9 @@ using API.Data.Repositories; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; -using API.SignalR; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -25,17 +22,12 @@ using Xunit; namespace API.Tests.Services; -public class BookmarkServiceTests +public class BookmarkServiceTests: AbstractFsTest { private readonly IUnitOfWork _unitOfWork; private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string BookmarkDirectory = "C:/kavita/config/bookmarks/"; - public BookmarkServiceTests() { @@ -88,7 +80,7 @@ Substitute.For()); _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -102,20 +94,6 @@ Substitute.For()); await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(BookmarkDirectory); - fileSystem.AddDirectory("C:/data/"); - - return fileSystem; - } - #endregion #region BookmarkPage @@ -132,7 +110,7 @@ Substitute.For()); var series = new SeriesBuilder("Test") .WithFormat(MangaFormat.Epub) - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .Build()) .Build()) @@ -181,7 +159,7 @@ Substitute.For()); .WithFormat(MangaFormat.Epub) .WithVolume(new VolumeBuilder("1") .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .Build()) .Build()) .Build(); diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index e1419e052..5c1752cd8 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -1,12 +1,10 @@ -using System.Collections.Generic; -using System.Data.Common; +using System.Data.Common; using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Metadata; -using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; @@ -52,17 +50,17 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService throw new System.NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) { throw new System.NotImplementedException(); } - public ParserInfo ParseFile(string path, string rootPath, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { throw new System.NotImplementedException(); } } -public class CacheServiceTests +public class CacheServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; @@ -71,11 +69,6 @@ public class CacheServiceTests private readonly DbConnection _connection; private readonly DataContext _context; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - public CacheServiceTests() { var contextOptions = new DbContextOptionsBuilder() @@ -118,7 +111,7 @@ public class CacheServiceTests _context.ServerSetting.Update(setting); _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); return await _context.SaveChangesAsync() > 0; } @@ -130,19 +123,6 @@ public class CacheServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - #endregion #region Ensure @@ -156,7 +136,9 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), + Substitute.For(), ds, Substitute.For>()), + Substitute.For()); await ResetDB(); var s = new SeriesBuilder("Test").Build(); @@ -231,7 +213,8 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), Substitute.For(), ds, Substitute.For>()), + Substitute.For()); cleanupService.CleanupChapters(new []{1, 3}); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories)); @@ -252,14 +235,15 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), Substitute.For(), ds, Substitute.For>()), + Substitute.For()); var c = new ChapterBuilder("1") .WithFile(new MangaFileBuilder($"{DataDirectory}1.epub", MangaFormat.Epub).Build()) .WithFile(new MangaFileBuilder($"{DataDirectory}2.epub", MangaFormat.Epub).Build()) .Build(); cs.GetCachedFile(c); - Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c)); + Assert.Equal($"{DataDirectory}1.epub", cs.GetCachedFile(c)); } #endregion @@ -292,7 +276,8 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), Substitute.For(), ds, Substitute.For>()), + Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); @@ -335,7 +320,8 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), Substitute.For(), ds, Substitute.For>()), + Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); @@ -375,7 +361,8 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), Substitute.For(), ds, Substitute.For>()), + Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); @@ -419,7 +406,8 @@ public class CacheServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cs = new CacheService(_logger, _unitOfWork, ds, new ReadingItemService(Substitute.For(), - Substitute.For(), Substitute.For(), ds), Substitute.For()); + Substitute.For(), Substitute.For(), ds, Substitute.For>()), + Substitute.For()); // Flatten to prepare for how GetFullPath expects ds.Flatten($"{CacheDirectory}1/"); diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 8c29c5c18..0f1e9e9da 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; -using API.Data; using API.Data.Repositories; using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -30,11 +27,10 @@ public class CleanupServiceTests : AbstractDbTest private readonly IEventHub _messageHub = Substitute.For(); private readonly IReaderService _readerService; - public CleanupServiceTests() : base() { _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder("C:/data/").Build()) + .WithFolderPath(new FolderPathBuilder(Root + "data/").Build()) .Build()); _readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For(), @@ -139,7 +135,7 @@ public class CleanupServiceTests : AbstractDbTest // Add 2 series with cover images _context.Series.Add(new SeriesBuilder("Test 1") .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithCoverImage("v01_c01.jpg").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c01.jpg").Build()) .WithCoverImage("v01_c01.jpg") .Build()) .WithCoverImage("series_01.jpg") @@ -148,7 +144,7 @@ public class CleanupServiceTests : AbstractDbTest _context.Series.Add(new SeriesBuilder("Test 2") .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithCoverImage("v01_c03.jpg").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithCoverImage("v01_c03.jpg").Build()) .WithCoverImage("v01_c03.jpg") .Build()) .WithCoverImage("series_03.jpg") @@ -167,53 +163,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] @@ -389,13 +385,12 @@ public class CleanupServiceTests : AbstractDbTest [Fact] public async Task CleanupDbEntries_CleanupAbandonedChapters() { - var c = new ChapterBuilder("0") + var c = new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(1) .Build(); var series = new SeriesBuilder("Test") .WithFormat(MangaFormat.Epub) - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(1) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(c) .Build()) .Build(); @@ -436,24 +431,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, @@ -466,7 +463,7 @@ public class CleanupServiceTests : AbstractDbTest await cleanupService.CleanupDbEntries(); - Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()); + Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync()); } #endregion @@ -520,6 +517,71 @@ public class CleanupServiceTests : AbstractDbTest } #endregion + #region ConsolidateProgress + + [Fact] + public async Task ConsolidateProgress_ShouldRemoveDuplicates() + { + await ResetDb(); + + var s = new SeriesBuilder("Test ConsolidateProgress_ShouldRemoveDuplicates") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPages(3) + .Build()) + .Build()) + .Build(); + + s.Library = new LibraryBuilder("Test Lib").Build(); + _context.Series.Add(s); + + var user = new AppUser() + { + UserName = "ConsolidateProgress_ShouldRemoveDuplicates", + }; + _context.AppUser.Add(user); + + await _unitOfWork.CommitAsync(); + + // Add 2 progress events + user.Progresses ??= []; + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 1, + }); + await _unitOfWork.CommitAsync(); + + // Add a duplicate with higher page number + user.Progresses.Add(new AppUserProgress() + { + ChapterId = 1, + VolumeId = 1, + SeriesId = 1, + LibraryId = s.LibraryId, + PagesRead = 3, + }); + await _unitOfWork.CommitAsync(); + + Assert.Equal(2, (await _unitOfWork.AppUserProgressRepository.GetAllProgress()).Count()); + + var cleanupService = new CleanupService(Substitute.For>(), _unitOfWork, + Substitute.For(), + new DirectoryService(Substitute.For>(), new MockFileSystem())); + + + await cleanupService.ConsolidateProgress(); + + var progress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + Assert.Single(progress); + Assert.True(progress.First().PagesRead == 3); + } + #endregion + #region EnsureChapterProgressIsCapped @@ -537,7 +599,7 @@ public class CleanupServiceTests : AbstractDbTest c.UserProgress = new List(); s.Volumes = new List() { - new VolumeBuilder("0").WithChapter(c).Build() + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume).WithChapter(c).Build() }; _context.Series.Add(s); @@ -586,7 +648,7 @@ public class CleanupServiceTests : AbstractDbTest } #endregion - // #region CleanupBookmarks + #region CleanupBookmarks // // [Fact] // public async Task CleanupBookmarks_LeaveAllFiles() @@ -723,5 +785,5 @@ public class CleanupServiceTests : AbstractDbTest // Assert.Equal(1, ds.FileSystem.Directory.GetDirectories($"{BookmarkDirectory}1/1/").Length); // } // - // #endregion + #endregion } diff --git a/API.Tests/Services/CollectionTagServiceTests.cs b/API.Tests/Services/CollectionTagServiceTests.cs index c06767ed1..14ce131d8 100644 --- a/API.Tests/Services/CollectionTagServiceTests.cs +++ b/API.Tests/Services/CollectionTagServiceTests.cs @@ -1,15 +1,18 @@ -using System.Collections.Generic; +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.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 Kavita.Common; using NSubstitute; using Xunit; @@ -25,7 +28,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 +36,494 @@ 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(); } + #region DeleteTag [Fact] - public async Task TagExistsByName_ShouldFindTag() + public async Task DeleteTag_ShouldDeleteTag_WhenTagExists() { + // Arrange await SeedSeries(); - Assert.True(await _service.TagExistsByName("Tag 1")); - Assert.True(await _service.TagExistsByName("tag 1")); - Assert.False(await _service.TagExistsByName("tag5")); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var deletedTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(deletedTag); + Assert.Single(user.Collections); // Only one collection should remain } + [Fact] + public async Task DeleteTag_ShouldReturnTrue_WhenTagDoesNotExist() + { + // Arrange + await SeedSeries(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to delete a non-existent tag + var result = await _service.DeleteTag(999, user); + + // Assert + Assert.True(result); // Should return true because the tag is already "deleted" + Assert.Equal(2, user.Collections.Count); // Both collections should remain + } + + [Fact] + public async Task DeleteTag_ShouldNotAffectOtherTags() + { + // Arrange + await SeedSeries(); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act + var result = await _service.DeleteTag(1, user); + + // Assert + Assert.True(result); + var remainingTag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(remainingTag); + Assert.Equal("Tag 2", remainingTag.Title); + Assert.True(remainingTag.Promoted); + } + + #endregion + + #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 UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource() + { + await SeedSeries(); + + 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)); } [Fact] - public async Task AddTagToSeries_ShouldAddTagToAllSeries() + public async Task UpdateTag_ShouldThrowException_WhenTagDoesNotExist() { + // Arrange 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")); + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Non-existent Tag", + Id = 999, // Non-existent ID + Promoted = false + }, 1)); + + Assert.Equal("collection-doesnt-exist", exception.Message); } [Fact] - public async Task RemoveTagFromSeries_ShouldRemoveMultiple() + public async Task UpdateTag_ShouldThrowException_WhenUserDoesNotOwnTag() + { + // Arrange + await SeedSeries(); + + // Create a second user + var user2 = new AppUserBuilder("user2", "user2", Seed.DefaultThemes.First()).Build(); + _unitOfWork.UserRepository.Add(user2); + await _unitOfWork.CommitAsync(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, // This belongs to user1 + Promoted = false + }, 2)); // User with ID 2 + + Assert.Equal("access-denied", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleIsEmpty() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = " ", // Empty after trimming + Id = 1, + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-title-required", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldThrowException_WhenTitleAlreadyExists() + { + // Arrange + await SeedSeries(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 2", // Already exists + Id = 1, // Trying to rename Tag 1 to Tag 2 + Promoted = false + }, 1)); + + Assert.Equal("collection-tag-duplicate", exception.Message); + } + + [Fact] + public async Task UpdateTag_ShouldUpdateCoverImageSettings() + { + // Arrange + await SeedSeries(); + + // Act + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.CoverImageLocked); + + // Now test unlocking the cover image + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + CoverImageLocked = false + }, 1); + + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.CoverImageLocked); + Assert.Equal(string.Empty, tag.CoverImage); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForAdminRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with admin role + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + await AddUserWithRole(user.Id, PolicyConstants.AdminRole); + + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldAllowPromoteForPromoteRole() + { + // Arrange + await SeedSeries(); + + // Setup a user with promote role + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Mock to return promote role for the user + await AddUserWithRole(user.Id, PolicyConstants.PromoteRole); + + // Act - Try to promote a tag that wasn't previously promoted + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.True(tag.Promoted); + } + + [Fact] + public async Task UpdateTag_ShouldNotChangePromotion_WhenUserHasNoPermission() + { + // Arrange + await SeedSeries(); + + // Setup a user with no special roles + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.NotNull(user); + + // Act - Try to promote a tag without proper role + await _service.UpdateTag(new AppUserCollectionDto() + { + Title = "Tag 1", + Id = 1, + Promoted = true + }, 1); + + // Assert + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.False(tag.Promoted); // Should remain unpromoted + } + #endregion + + + #region RemoveTagFromSeries + + [Fact] + public async Task RemoveTagFromSeries_RemoveSeriesFromTag() { 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 userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections); + Assert.Equal(2, userCollections!.Collections.Count); + Assert.Single(tag.Items); + Assert.Equal(2, tag.Items.First().Id); + } + + /// + /// Ensure the rating of the tag updates after a series change + /// + [Fact] + public async Task RemoveTagFromSeries_RemoveSeriesFromTag_UpdatesRating() + { + 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 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); } + + [Fact] + public async Task RemoveTagFromSeries_ShouldReturnFalse_WhenTagIsNull() + { + // Act + var result = await _service.RemoveTagFromSeries(null, [1]); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleEmptySeriesIdsList() + { + // Arrange + await SeedSeries(); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act + var result = await _service.RemoveTagFromSeries(tag, Array.Empty()); + + // Assert + Assert.True(result); + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNonExistentSeriesIds() + { + // Arrange + await SeedSeries(); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + var initialItemCount = tag.Items.Count; + + // Act - Try to remove a series that doesn't exist in the tag + var result = await _service.RemoveTagFromSeries(tag, [999]); + + // Assert + Assert.True(result); + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + Assert.Equal(initialItemCount, tag.Items.Count); // No items should be removed + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldHandleNullItemsList() + { + // Arrange + await SeedSeries(); + + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.NotNull(tag); + + // Force null items list + tag.Items = null; + _unitOfWork.CollectionTagRepository.Update(tag); + await _unitOfWork.CommitAsync(); + + // Act + var result = await _service.RemoveTagFromSeries(tag, [1]); + + // Assert + Assert.True(result); + // The tag should not be removed since the items list was null, not empty + var tagAfter = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1); + Assert.Null(tagAfter); + } + + [Fact] + public async Task RemoveTagFromSeries_ShouldUpdateAgeRating_WhenMultipleSeriesRemain() + { + // Arrange + await SeedSeries(); + + // Add a third series with a different age rating + var s3 = new SeriesBuilder("Series 3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.PG).Build()).Build(); + _context.Library.First().Series.Add(s3); + await _unitOfWork.CommitAsync(); + + // Add series 3 to tag 2 + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + tag.Items.Add(s3); + _unitOfWork.CollectionTagRepository.Update(tag); + await _unitOfWork.CommitAsync(); + + // Act - Remove the series with Mature rating + await _service.RemoveTagFromSeries(tag, new[] {1}); + + // Assert + tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2); + Assert.NotNull(tag); + Assert.Equal(2, tag.Items.Count); + + // The age rating should be updated to the highest remaining rating (PG) + Assert.Equal(AgeRating.PG, tag.AgeRating); + } + + + #endregion + } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 0de244cac..c5216bebf 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,20 +1,30 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; using API.Services; +using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; -public class DirectoryServiceTests +public class DirectoryServiceTests: AbstractFsTest { private readonly ILogger _logger = Substitute.For>(); + private readonly ITestOutputHelper _testOutputHelper; + + public DirectoryServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } #region TraverseTreeParallelForEach @@ -372,9 +382,16 @@ public class DirectoryServiceTests #endregion #region IsDriveMounted + // The root directory (/) is always mounted on non windows [Fact] public void IsDriveMounted_DriveIsNotMounted() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _testOutputHelper.WriteLine("Skipping test on non Windows platform"); + return; + } + const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); @@ -386,6 +403,12 @@ public class DirectoryServiceTests [Fact] public void IsDriveMounted_DriveIsMounted() { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + _testOutputHelper.WriteLine("Skipping test on non Windows platform"); + return; + } + const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); fileSystem.AddFile($"{testDirectory}data-0.txt", new MockFileData("abc")); @@ -721,6 +744,54 @@ public class DirectoryServiceTests #endregion + #region FindLowestDirectoriesFromFiles + + [Theory] + [InlineData(new [] {"C:/Manga/"}, + new [] {"C:/Manga/Love Hina/Vol. 01.cbz"}, + "C:/Manga/Love Hina")] + [InlineData(new [] {"C:/Manga/"}, + new [] {"C:/Manga/Romance/Love Hina/Vol. 01.cbz"}, + "C:/Manga/Romance/Love Hina")] + [InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/Dir 2/"}, + new [] {"C:/Manga/Dir 1/Love Hina/Vol. 01.cbz"}, + "C:/Manga/Dir 1/Love Hina")] + [InlineData(new [] {"C:/Manga/Dir 1/", "c://Manga/"}, + new [] {"D:/Manga/Love Hina/Vol. 01.cbz", "D:/Manga/Vol. 01.cbz"}, + null)] + [InlineData(new [] {@"C:\mount\drive\Library\Test Library\Comics\"}, + new [] {@"C:\mount\drive\Library\Test Library\Comics\Bruce Lee (1994)\Bruce Lee #001 (1994).cbz"}, + @"C:/mount/drive/Library/Test Library/Comics/Bruce Lee (1994)")] + [InlineData(new [] {"C:/Manga/"}, + new [] {"C:/Manga/Love Hina/Vol. 01.cbz", "C:/Manga/Love Hina/Specials/Sp01.cbz"}, + "C:/Manga/Love Hina")] + [InlineData(new [] {"/manga"}, + new [] {"/manga/Love Hina/Vol. 01.cbz", "/manga/Love Hina/Specials/Sp01.cbz"}, + "/manga/Love Hina")] + [InlineData(new [] {"/manga"}, + new [] {"/manga/Love Hina/Hina/Vol. 01.cbz", "/manga/Love Hina/Specials/Sp01.cbz"}, + "/manga/Love Hina")] + [InlineData(new [] {"/manga"}, + new [] {"/manga/Dress Up Darling/Dress Up Darling Ch 01.cbz", "/manga/Dress Up Darling/Dress Up Darling/Dress Up Darling Vol 01.cbz"}, + "/manga/Dress Up Darling")] + public void FindLowestDirectoriesFromFilesTest(string[] rootDirectories, string[] files, string expectedDirectory) + { + var fileSystem = new MockFileSystem(); + foreach (var directory in rootDirectories) + { + fileSystem.AddDirectory(directory); + } + foreach (var f in files) + { + fileSystem.AddFile(f, new MockFileData("")); + } + var ds = new DirectoryService(Substitute.For>(), fileSystem); + + var actual = ds.FindLowestDirectoriesFromFiles(rootDirectories, files); + Assert.Equal(expectedDirectory, actual); + } + + #endregion #region GetFoldersTillRoot [Theory] @@ -851,12 +922,14 @@ public class DirectoryServiceTests #region GetHumanReadableBytes [Theory] - [InlineData(1200, "1.17 KB")] - [InlineData(1, "1 B")] - [InlineData(10000000, "9.54 MB")] - [InlineData(10000000000, "9.31 GB")] - public void GetHumanReadableBytesTest(long bytes, string expected) + [InlineData(1200, 1.17, " KB")] + [InlineData(1, 1, " B")] + [InlineData(10000000, 9.54, " MB")] + [InlineData(10000000000, 9.31, " GB")] + public void GetHumanReadableBytesTest(long bytes, float number, string suffix) { + // GetHumanReadableBytes is user facing, should be in CultureInfo.CurrentCulture + var expected = number.ToString(CultureInfo.CurrentCulture) + suffix; Assert.Equal(expected, DirectoryService.GetHumanReadableBytes(bytes)); } #endregion @@ -878,8 +951,9 @@ public class DirectoryServiceTests var ds = new DirectoryService(Substitute.For>(), fileSystem); - - var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions); + var globMatcher = new GlobMatcher(); + globMatcher.AddExclude("*.*"); + var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions, globMatcher); Assert.Empty(allFiles); @@ -903,7 +977,9 @@ public class DirectoryServiceTests var ds = new DirectoryService(Substitute.For>(), fileSystem); - var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions); + var globMatcher = new GlobMatcher(); + globMatcher.AddExclude("**/Accel World/*"); + var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions, globMatcher); Assert.Single(allFiles); // Ignore files are not counted in files, only valid extensions @@ -932,7 +1008,10 @@ public class DirectoryServiceTests var ds = new DirectoryService(Substitute.For>(), fileSystem); - var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions); + var globMatcher = new GlobMatcher(); + globMatcher.AddExclude("**/Accel World/*"); + globMatcher.AddExclude("**/ArtBooks/*"); + var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions, globMatcher); Assert.Equal(2, allFiles.Count); // Ignore files are not counted in files, only valid extensions @@ -986,11 +1065,14 @@ public class DirectoryServiceTests #region GetParentDirectory [Theory] - [InlineData(@"C:/file.txt", "C:/")] - [InlineData(@"C:/folder/file.txt", "C:/folder")] - [InlineData(@"C:/folder/subfolder/file.txt", "C:/folder/subfolder")] + [InlineData(@"file.txt", "")] + [InlineData(@"folder/file.txt", "folder")] + [InlineData(@"folder/subfolder/file.txt", "folder/subfolder")] public void GetParentDirectoryName_ShouldFindParentOfFiles(string path, string expected) { + path = Root + path; + expected = Root + expected; + var fileSystem = new MockFileSystem(new Dictionary { { path, new MockFileData(string.Empty)} @@ -1000,11 +1082,14 @@ public class DirectoryServiceTests Assert.Equal(expected, ds.GetParentDirectoryName(path)); } [Theory] - [InlineData(@"C:/folder", "C:/")] - [InlineData(@"C:/folder/subfolder", "C:/folder")] - [InlineData(@"C:/folder/subfolder/another", "C:/folder/subfolder")] + [InlineData(@"folder", "")] + [InlineData(@"folder/subfolder", "folder")] + [InlineData(@"folder/subfolder/another", "folder/subfolder")] public void GetParentDirectoryName_ShouldFindParentOfDirectories(string path, string expected) { + path = Root + path; + expected = Root + expected; + var fileSystem = new MockFileSystem(); fileSystem.AddDirectory(path); diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs new file mode 100644 index 000000000..127bceb7a --- /dev/null +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -0,0 +1,2860 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; +using API.Helpers.Builders; +using API.Services.Plus; +using API.Services.Tasks.Metadata; +using API.SignalR; +using Hangfire; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +/// +/// Given these rely on Kavita+, this will not have any [Fact]/[Theory] on them and must be manually checked +/// +public class ExternalMetadataServiceTests : AbstractDbTest +{ + private readonly ExternalMetadataService _externalMetadataService; + private readonly Dictionary _genreLookup = new Dictionary(); + private readonly Dictionary _tagLookup = new Dictionary(); + private readonly Dictionary _personLookup = new Dictionary(); + + + public ExternalMetadataServiceTests() + { + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + + _externalMetadataService = new ExternalMetadataService(_unitOfWork, Substitute.For>(), + _mapper, Substitute.For(), Substitute.For(), Substitute.For(), + Substitute.For()); + } + + #region Gloabl + + [Fact] + public async Task Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + #endregion + + #region Summary + + [Fact] + public async Task Summary_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "Test" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal(series.Metadata.Summary, postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked") + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should not write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This summary is not locked", postSeries.Metadata.Summary); + } + + [Fact] + public async Task Summary_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Summary"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithSummary("This summary is not locked", true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableSummary = true; + metadataSettings.Overrides = [MetadataSettingField.Summary]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Summary = "This should write" + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.False(string.IsNullOrEmpty(postSeries.Metadata.Summary)); + Assert.Equal("This should write", postSeries.Metadata.Summary); + } + + + #endregion + + #region Release Year + + [Fact] + public async Task ReleaseYear_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(0, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(1990, postSeries.Metadata.ReleaseYear); + } + + [Fact] + public async Task ReleaseYear_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Release Year"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithReleaseYear(1990, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableStartDate = true; + metadataSettings.Overrides = [MetadataSettingField.StartDate]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + StartDate = DateTime.UtcNow + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(DateTime.UtcNow.Year, postSeries.Metadata.ReleaseYear); + } + + #endregion + + #region LocalizedName + + [Fact] + public async Task LocalizedName_NoExisting_Off_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(string.Empty, postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here") + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Localized Name here", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedName("Localized Name here", true) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + metadataSettings.Overrides = [MetadataSettingField.LocalizedName]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください", "Kimchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal("Kimchi", postSeries.LocalizedName); + } + + [Fact] + public async Task LocalizedName_OnlyNonEnglishSynonyms_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Localized Name"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithLocalizedNameAllowEmpty(string.Empty) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableLocalizedName = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Synonyms = [seriesName, "設定しないでください"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.True(string.IsNullOrEmpty(postSeries.LocalizedName)); + } + + #endregion + + #region Publication Status + + [Fact] + public async Task PublicationStatus_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.OnGoing, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Hiatus, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus, true) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + metadataSettings.Overrides = [MetadataSettingField.PublicationStatus]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Completed, postSeries.Metadata.PublicationStatus); + } + + [Fact] + public async Task PublicationStatus_Existing_CorrectState_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Publication Status"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.Hiatus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePublicationStatus = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Volumes = 2 + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(PublicationStatus.Ended, postSeries.Metadata.PublicationStatus); + } + + + + #endregion + + #region Age Rating + + [Fact] + public async Task AgeRating_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingHigher_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Mature) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Mature, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_ExistingLower_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Everyone, postSeries.Metadata.AgeRating); + } + + [Fact] + public async Task AgeRating_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Age Rating"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Everyone, true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.Overrides = [MetadataSettingField.AgeRating]; + metadataSettings.AgeRatingMappings = new Dictionary() + { + {"Ecchi", AgeRating.Teen}, // Genre + {"H", AgeRating.R18Plus}, // Tag + }; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating); + } + + #endregion + + #region Genres + + [Fact] + public async Task Genres_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Genres); + } + + [Fact] + public async Task Genres_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Action"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task Genres_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Ecchi"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + #endregion + + #region Tags + + [Fact] + public async Task Tags_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.Tags); + } + + [Fact] + public async Task Tags_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["H"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task Tags_Existing_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithTag(_tagLookup["H"], true) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Tags]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title)); + } + + #endregion + + #region People - Writers/Artists + + [Fact] + public async Task People_Writer_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer)); + } + + [Fact] + public async Task People_Writer_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Writer_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Writer_Locked_Override_ReverseNamingMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Twowheeler", "Johnny", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Writer_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Writer) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Writer_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Writer/Artists"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe", "Story")] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe 2", "Story")] + }, 1); + + postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region People - Characters + + [Fact] + public async Task People_Character_NoExisting_Off_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = false; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal([], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character)); + } + + [Fact] + public async Task People_Character_NoExisting_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["John Doe"], postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Person.Name)); + } + + [Fact] + public async Task People_Character_Locked_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.CharacterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe", "Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + Assert.True( postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .FirstOrDefault(p => p.Person.Name == "John Doe")!.KavitaPlusConnection); + } + + [Fact] + public async Task People_Character_Locked_Override_ReverseNamingNoMatch_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = false; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("Twowheeler", "Johnny", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler", "Twowheeler Johnny"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + [Fact] + public async Task People_Character_Locked_Override_PersonRoleNotSet_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(_personLookup["Johnny Twowheeler"], PersonRole.Character) + .Build()) + .Build(); + series.Metadata.WriterLocked = true; + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = []; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"Johnny Twowheeler"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + + [Fact] + public async Task People_Character_OverrideReMatchDeletesOld_Modification() + { + await ResetDb(); + + const string seriesName = "Test - People - Character"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Character]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe", CharacterRole.Main)] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Characters = [CreateCharacter("John", "Doe 2", CharacterRole.Main)] + }, 1); + + postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[]{"John Doe 2"}.OrderBy(s => s), + postSeries.Metadata.People.Where(p => p.Role == PersonRole.Character) + .Select(p => p.Person.Name) + .OrderBy(s => s)); + } + + #endregion + + #region Series Cover + // Not sure how to test this + #endregion + + #region Relationships + + // Not enabled + + // Non-Sequel + + [Fact] + public async Task Relationships_NonSequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_NonSequel_LocalizedName() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + } + + // Non-Sequel with no match due to Format difference + [Fact] + public async Task Relationships_NonSequel_FormatDifference() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithLocalizedName("School bus") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = "School bus", + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + AniListId = 10, + PlusMediaFormat = PlusMediaFormat.Book + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Empty(sourceSeries.Relations); + } + + // Non-Sequel existing relationship with new link, both exist + [Fact] + public async Task Relationships_NonSequel_ExistingLink_DifferentType_BothExist() + { + await ResetDb(); + + var existingRelationshipSeries = new SeriesBuilder("Existing") + .WithLibraryId(1) + .Build(); + _context.Series.Attach(existingRelationshipSeries); + await _context.SaveChangesAsync(); + + const string seriesName = "Test - Relationships Side Story"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithRelationship(existingRelationshipSeries.Id, RelationKind.Annual) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Side Story - Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .WithExternalMetadata(new ExternalSeriesMetadata() + { + AniListId = 10 + }) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.SideStory, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 2); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Equal(seriesName, sourceSeries.Name); + + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.Annual && r.TargetSeriesId == existingRelationshipSeries.Id); + Assert.Contains(sourceSeries.Relations, r => r.RelationKind == RelationKind.SideStory && r.TargetSeriesId == series2.Id); + } + + + + // Sequel/Prequel + [Fact] + public async Task Relationships_Sequel_CreatesPrequel() + { + await ResetDb(); + + const string seriesName = "Test - Relationships Source"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + var series2 = new SeriesBuilder("Test - Relationships Target") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Sequel, + SeriesName = new ALMediaTitle() + { + PreferredTitle = series2.Name, + EnglishTitle = null, + NativeTitle = series2.Name, + RomajiTitle = series2.Name, + }, + PlusMediaFormat = PlusMediaFormat.Manga + }] + }, 1); + + // Repull Series and validate what is overwritten + var sourceSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sourceSeries); + Assert.Single(sourceSeries.Relations); + Assert.Equal(series2.Name, sourceSeries.Relations.First().TargetSeries.Name); + + var sequel = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(sequel); + Assert.Equal(seriesName, sequel.Relations.First().TargetSeries.Name); + } + + [Fact] + public async Task Relationships_Prequel_CreatesSequel() + { + await ResetDb(); + + // ID 1: Blue Lock - Episode Nagi + var series = new SeriesBuilder("Blue Lock - Episode Nagi") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + + // ID 2: Blue Lock + var series2 = new SeriesBuilder("Blue Lock") + .WithLibraryId(1) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series2); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableRelationships = true; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + // Apply to Blue Lock - Episode Nagi (ID 1), setting Blue Lock (ID 2) as its prequel + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = "Blue Lock - Episode Nagi", // The series we're updating metadata for + Relations = [new SeriesRelationship() + { + Relation = RelationKind.Prequel, // Blue Lock is the prequel to Nagi + SeriesName = new ALMediaTitle() + { + PreferredTitle = "Blue Lock", + EnglishTitle = "Blue Lock", + NativeTitle = "ブルーロック", + RomajiTitle = "Blue Lock", + }, + PlusMediaFormat = PlusMediaFormat.Manga, + AniListId = 106130, + MalId = 114745, + Provider = ScrobbleProvider.AniList + }] + }, 1); // Apply to series ID 1 (Nagi) + + // Verify Blue Lock - Episode Nagi has Blue Lock as prequel + var nagiSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(nagiSeries); + Assert.Single(nagiSeries.Relations); + Assert.Equal("Blue Lock", nagiSeries.Relations.First().TargetSeries.Name); + Assert.Equal(RelationKind.Prequel, nagiSeries.Relations.First().RelationKind); + + // Verify Blue Lock has Blue Lock - Episode Nagi as sequel + var blueLockSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Metadata | SeriesIncludes.Related); + Assert.NotNull(blueLockSeries); + Assert.Single(blueLockSeries.Relations); + Assert.Equal("Blue Lock - Episode Nagi", blueLockSeries.Relations.First().TargetSeries.Name); + Assert.Equal(RelationKind.Sequel, blueLockSeries.Relations.First().RelationKind); + } + + + #endregion + + #region Blacklist + + [Fact] + public async Task Blacklist_Genres() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Genres"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Boxing", "Sports", "Action"], + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Genres.Select(t => t.Title).OrderBy(s => s)); + } + + + [Fact] + public async Task Blacklist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Blacklist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.EnableGenres = true; + metadataSettings.Blacklist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Boxing"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + // Blacklist Tag + + // Field Map then Blacklist Genre + + // Field Map then Blacklist Tag + + #endregion + + #region Whitelist + + [Fact] + public async Task Whitelist_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Whitelist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Sports"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + [Fact] + public async Task Whitelist_WithFieldMap_Tags() + { + await ResetDb(); + + const string seriesName = "Test - Whitelist Tags"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Boxing", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Sports", + ExcludeFromSource = false + + }]; + metadataSettings.Whitelist = ["Sports", "Action"]; + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Boxing"}, new MetadataTagDto() {Name = "Action"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(new[] {"Sports", "Action"}.OrderBy(s => s), postSeries.Metadata.Tags.Select(t => t.Title).OrderBy(s => s)); + } + + #endregion + + #region Field Mapping + + [Fact] + public async Task FieldMap_GenreToGenre_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_TagToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] { "Ecchi", "Fanservice" }.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + [Fact] + public async Task Tags_Existing_FieldMap_RemoveSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Tag Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Tag, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = true + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Tags = [new MetadataTagDto() {Name = "Ecchi"}] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal(["Fanservice"], postSeries.Metadata.Tags.Select(g => g.Title)); + } + + [Fact] + public async Task FieldMap_GenreToTag_KeepSource_Modification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Ecchi", + DestinationType = MetadataFieldType.Tag, + DestinationValue = "Fanservice", + ExcludeFromSource = false + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Genres = ["Ecchi"] + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Ecchi"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + Assert.Equal( + new[] {"Fanservice"}.OrderBy(s => s), + postSeries.Metadata.Tags.Select(g => g.Title).OrderBy(s => s) + ); + } + + + + [Fact] + public async Task FieldMap_GenreToGenre_RemoveSource_NoExternalGenre_NoModification() + { + await ResetDb(); + + const string seriesName = "Test - Genres Field Mapping"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithGenre(_genreLookup["Action"]) + .Build()) + .Build(); + _context.Series.Attach(series); + await _context.SaveChangesAsync(); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnableGenres = true; + metadataSettings.EnableTags = true; + metadataSettings.Overrides = [MetadataSettingField.Genres, MetadataSettingField.Tags]; + metadataSettings.FieldMappings = [new MetadataFieldMapping() + { + SourceType = MetadataFieldType.Genre, + SourceValue = "Action", + DestinationType = MetadataFieldType.Genre, + DestinationValue = "Adventure", + ExcludeFromSource = true + + }]; + + _context.MetadataSettings.Update(metadataSettings); + await _context.SaveChangesAsync(); + + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + }, 1); + + // Repull Series and validate what is overwritten + var postSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + Assert.Equal( + new[] {"Action"}.OrderBy(s => s), + postSeries.Metadata.Genres.Select(g => g.Title).OrderBy(s => s) + ); + } + + #endregion + + + + protected override async Task ResetDb() + { + _context.Series.RemoveRange(_context.Series); + _context.AppUser.RemoveRange(_context.AppUser); + _context.Genre.RemoveRange(_context.Genre); + _context.Tag.RemoveRange(_context.Tag); + _context.Person.RemoveRange(_context.Person); + + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = false; + metadataSettings.EnableSummary = false; + metadataSettings.EnableCoverImage = false; + metadataSettings.EnableLocalizedName = false; + metadataSettings.EnableGenres = false; + metadataSettings.EnablePeople = false; + metadataSettings.EnableRelationships = false; + metadataSettings.EnableTags = false; + metadataSettings.EnablePublicationStatus = false; + metadataSettings.EnableStartDate = false; + _context.MetadataSettings.Update(metadataSettings); + + await _context.SaveChangesAsync(); + + _context.AppUser.Add(new AppUserBuilder("Joe", "Joe") + .WithRole(PolicyConstants.AdminRole) + .WithLibrary(await _context.Library.FirstAsync(l => l.Id == 1)) + .Build()); + + // Create a bunch of Genres for this test and store their string in _genreLookup + _genreLookup.Clear(); + var g1 = new GenreBuilder("Action").Build(); + var g2 = new GenreBuilder("Ecchi").Build(); + _context.Genre.Add(g1); + _context.Genre.Add(g2); + _genreLookup.Add("Action", g1); + _genreLookup.Add("Ecchi", g2); + + _tagLookup.Clear(); + var t1 = new TagBuilder("H").Build(); + var t2 = new TagBuilder("Boxing").Build(); + _context.Tag.Add(t1); + _context.Tag.Add(t2); + _tagLookup.Add("H", t1); + _tagLookup.Add("Boxing", t2); + + _personLookup.Clear(); + var p1 = new PersonBuilder("Johnny Twowheeler").Build(); + var p2 = new PersonBuilder("Boxing").Build(); + _context.Person.Add(p1); + _context.Person.Add(p2); + _personLookup.Add("Johnny Twowheeler", p1); + _personLookup.Add("Batman Robin", p2); + + await _context.SaveChangesAsync(); + } + + private static SeriesStaffDto CreateStaff(string first, string last, string role) + { + return new SeriesStaffDto() {Name = $"{first} {last}", Role = role, Url = "", FirstName = first, LastName = last}; + } + + private static SeriesCharacter CreateCharacter(string first, string last, CharacterRole role) + { + return new SeriesCharacter() {Name = $"{first} {last}", Description = "", Url = "", ImageUrl = "", Role = role}; + } +} diff --git a/API.Tests/Services/ImageServiceTests.cs b/API.Tests/Services/ImageServiceTests.cs new file mode 100644 index 000000000..a1073a55b --- /dev/null +++ b/API.Tests/Services/ImageServiceTests.cs @@ -0,0 +1,221 @@ +using System.IO; +using System.Linq; +using System.Text; +using API.Entities.Enums; +using API.Services; +using NetVips; +using Xunit; +using Image = NetVips.Image; + +namespace API.Tests.Services; + +public class ImageServiceTests +{ + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/Covers"); + private readonly string _testDirectoryColorScapes = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageService/ColorScapes"); + private const string OutputPattern = "_output"; + private const string BaselinePattern = "_baseline"; + + /// + /// Run this once to get the baseline generation + /// + [Fact] + public void GenerateBaseline() + { + GenerateFiles(BaselinePattern); + Assert.True(true); + } + + /// + /// Change the Scaling/Crop code then run this continuously + /// + [Fact] + public void TestScaling() + { + GenerateFiles(OutputPattern); + GenerateHtmlFile(); + Assert.True(true); + } + + private void GenerateFiles(string outputExtension) + { + // Step 1: Delete any images that have _output in the name + var outputFiles = Directory.GetFiles(_testDirectory, "*_output.*"); + foreach (var file in outputFiles) + { + File.Delete(file); + } + + // Step 2: Scan the _testDirectory for images + var imageFiles = Directory.GetFiles(_testDirectory, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + // Step 3: Process each image + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var dims = CoverImageSize.Default.GetDimensions(); + using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered); + + var size = ImageService.GetSizeForDimensions(sourceImage, dims.Width, dims.Height); + var crop = ImageService.GetCropForDimensions(sourceImage, dims.Width, dims.Height); + + using var thumbnail = Image.Thumbnail(imagePath, dims.Width, dims.Height, + size: size, + crop: crop); + + var outputFileName = fileName + outputExtension + ".png"; + thumbnail.WriteToFile(Path.Join(_testDirectory, outputFileName)); + } + } + + private void GenerateHtmlFile() + { + var imageFiles = Directory.GetFiles(_testDirectory, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + var htmlBuilder = new StringBuilder(); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("Image Comparison"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("
"); + + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var baselinePath = Path.Combine(_testDirectory, fileName + "_baseline.png"); + var outputPath = Path.Combine(_testDirectory, fileName + "_output.png"); + var dims = CoverImageSize.Default.GetDimensions(); + + using var sourceImage = Image.NewFromFile(imagePath, false, Enums.Access.SequentialUnbuffered); + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine($"

{fileName} ({((double) sourceImage.Width / sourceImage.Height).ToString("F2")}) - {ImageService.WillScaleWell(sourceImage, dims.Width, dims.Height)}

"); + htmlBuilder.AppendLine($"\"{fileName}\""); + if (File.Exists(baselinePath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + if (File.Exists(outputPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + htmlBuilder.AppendLine("
"); + } + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + + File.WriteAllText(Path.Combine(_testDirectory, "index.html"), htmlBuilder.ToString()); + } + + + [Fact] + public void TestColorScapes() + { + // Step 1: Delete any images that have _output in the name + var outputFiles = Directory.GetFiles(_testDirectoryColorScapes, "*_output.*"); + foreach (var file in outputFiles) + { + File.Delete(file); + } + + // Step 2: Scan the _testDirectory for images + var imageFiles = Directory.GetFiles(_testDirectoryColorScapes, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + // Step 3: Process each image + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var colors = ImageService.CalculateColorScape(imagePath); + + // Generate primary color image + GenerateColorImage(colors.Primary, Path.Combine(_testDirectoryColorScapes, $"{fileName}_primary_output.png")); + + // Generate secondary color image + GenerateColorImage(colors.Secondary, Path.Combine(_testDirectoryColorScapes, $"{fileName}_secondary_output.png")); + } + + // Step 4: Generate HTML file + GenerateHtmlFileForColorScape(); + Assert.True(true); + } + + private static void GenerateColorImage(string hexColor, string outputPath) + { + var color = ImageService.HexToRgb(hexColor); + using var colorImage = Image.Black(200, 100); + using var output = colorImage + new[] { color.R / 255.0, color.G / 255.0, color.B / 255.0 }; + output.WriteToFile(outputPath); + } + + private void GenerateHtmlFileForColorScape() + { + var imageFiles = Directory.GetFiles(_testDirectoryColorScapes, "*.*") + .Where(file => !file.EndsWith("html")) + .Where(file => !file.Contains(OutputPattern) && !file.Contains(BaselinePattern)) + .ToList(); + + var htmlBuilder = new StringBuilder(); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("Color Scape Comparison"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine("
"); + + foreach (var imagePath in imageFiles) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + var primaryPath = Path.Combine(_testDirectoryColorScapes, $"{fileName}_primary_output.png"); + var secondaryPath = Path.Combine(_testDirectoryColorScapes, $"{fileName}_secondary_output.png"); + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine($"

{fileName}

"); + htmlBuilder.AppendLine($"\"{fileName}\""); + if (File.Exists(primaryPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + if (File.Exists(secondaryPath)) + { + htmlBuilder.AppendLine($"\"{fileName}"); + } + htmlBuilder.AppendLine("
"); + } + + htmlBuilder.AppendLine("
"); + htmlBuilder.AppendLine(""); + htmlBuilder.AppendLine(""); + + File.WriteAllText(Path.Combine(_testDirectoryColorScapes, "colorscape_index.html"), htmlBuilder.ToString()); + } +} diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index a0f5aa90b..f81ebd3c4 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -1,37 +1,41 @@ using System; using System.Collections.Generic; -using System.Data.Common; +using System.IO; +using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; 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; -using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using AutoMapper; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; +using API.Tests.Helpers; +using Hangfire; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; -internal class MockReadingItemService : IReadingItemService +public class MockReadingItemService : IReadingItemService { - private readonly IDefaultParser _defaultParser; + private readonly BasicParser _basicParser; + private readonly ComicVineParser _comicVineParser; + private readonly ImageParser _imageParser; + private readonly BookParser _bookParser; + private readonly PdfParser _pdfParser; - public MockReadingItemService(IDefaultParser defaultParser) + public MockReadingItemService(IDirectoryService directoryService, IBookService bookService) { - _defaultParser = defaultParser; + _imageParser = new ImageParser(directoryService); + _basicParser = new BasicParser(directoryService, _imageParser); + _bookParser = new BookParser(directoryService, bookService, _basicParser); + _comicVineParser = new ComicVineParser(directoryService); + _pdfParser = new PdfParser(directoryService); } public ComicInfo GetComicInfo(string filePath) @@ -54,99 +58,57 @@ internal class MockReadingItemService : IReadingItemService throw new NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) { - return _defaultParser.Parse(path, rootPath, type); + if (_comicVineParser.IsApplicable(path, type)) + { + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_imageParser.IsApplicable(path, type)) + { + return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_bookParser.IsApplicable(path, type)) + { + return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_pdfParser.IsApplicable(path, type)) + { + return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_basicParser.IsApplicable(path, type)) + { + return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + + return null; } - public ParserInfo ParseFile(string path, string rootPath, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { - return _defaultParser.Parse(path, rootPath, type); + return Parse(path, rootPath, libraryRoot, type); } } -public class ParseScannedFilesTests +public class ParseScannedFilesTests : AbstractDbTest { private readonly ILogger _logger = Substitute.For>(); - private readonly IUnitOfWork _unitOfWork; + private readonly ScannerHelper _scannerHelper; - private readonly DbConnection _connection; - private readonly DataContext _context; - - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - - public ParseScannedFilesTests() + public ParseScannedFilesTests(ITestOutputHelper testOutputHelper) { - var contextOptions = new DbContextOptionsBuilder() - .UseSqlite(CreateInMemoryDatabase()) - .Options; - _connection = RelationalOptionsExtension.Extract(contextOptions).Connection; - - _context = new DataContext(contextOptions); - Task.Run(SeedDb).GetAwaiter().GetResult(); - - _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); - // Since ProcessFile relies on _readingItemService, we can implement our own versions of _readingItemService so we have control over how the calls work + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper); } - #region Setup - - private static DbConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - - connection.Open(); - - return connection; - } - - private async Task SeedDb() - { - await _context.Database.MigrateAsync(); - var filesystem = CreateFileSystem(); - - await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem)); - - var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); - setting.Value = CacheDirectory; - - setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync(); - setting.Value = BackupDirectory; - - _context.ServerSetting.Update(setting); - - _context.Library.Add(new LibraryBuilder("Manga") - .WithFolderPath(new FolderPathBuilder(DataDirectory).Build()) - .Build()); - return await _context.SaveChangesAsync() > 0; - } - - private async Task ResetDB() + protected override async Task ResetDb() { _context.Series.RemoveRange(_context.Series.ToList()); await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - - #endregion - #region MergeName // NOTE: I don't think I can test MergeName as it relies on Tracking Files, which is more complicated than I need @@ -219,48 +181,45 @@ public class ParseScannedFilesTests #region ScanLibrariesForSeries + /// + /// Test that when a folder has 2 series with a localizedSeries, they combine into one final series + /// + // [Fact] + // public async Task ScanLibrariesForSeries_ShouldCombineSeries() + // { + // // TODO: Implement these unit tests + // } + [Fact] public async Task ScanLibrariesForSeries_ShouldFindFiles() { var fileSystem = new MockFileSystem(); - fileSystem.AddDirectory("C:/Data/"); - fileSystem.AddFile("C:/Data/Accel World v1.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World v2.cbz", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Accel World v2.pdf", new MockFileData(string.Empty)); - fileSystem.AddFile("C:/Data/Nothing.pdf", new MockFileData(string.Empty)); + fileSystem.AddDirectory(Root + "Data/"); + fileSystem.AddFile(Root + "Data/Accel World v1.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Accel World v2.cbz", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Accel World v2.pdf", new MockFileData(string.Empty)); + fileSystem.AddFile(Root + "Data/Nothing.pdf", new MockFileData(string.Empty)); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var parsedSeries = new Dictionary>(); - - Task TrackFiles(Tuple> parsedInfo) - { - var skippedScan = parsedInfo.Item1; - var parsedFiles = parsedInfo.Item2; - if (parsedFiles.Count == 0) return Task.CompletedTask; - - var foundParsedSeries = new ParsedSeries() - { - Name = parsedFiles.First().Series, - NormalizedName = parsedFiles.First().Series.ToNormalized(), - Format = parsedFiles.First().Format - }; - - parsedSeries.Add(foundParsedSeries, parsedFiles); - return Task.CompletedTask; - } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); + Assert.NotNull(library); + library.Type = LibraryType.Manga; - await psf.ScanLibrariesForSeries(library, new List() {"C:/Data/"}, false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), TrackFiles); + var parsedSeries = await psf.ScanLibrariesForSeries(library, new List() {Root + "Data/"}, false, + await _unitOfWork.SeriesRepository.GetFolderPathMap(1)); - Assert.Equal(3, parsedSeries.Values.Count); - Assert.NotEmpty(parsedSeries.Keys.Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World"))); + // Assert.Equal(3, parsedSeries.Values.Count); + // Assert.NotEmpty(parsedSeries.Keys.Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World"))); + + Assert.Equal(3, parsedSeries.Count); + Assert.NotEmpty(parsedSeries.Select(p => p.ParsedSeries).Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World"))); } #endregion @@ -289,18 +248,16 @@ public class ParseScannedFilesTests var fileSystem = CreateTestFilesystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); var directoriesSeen = new HashSet(); - var library = - await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); - await psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), - (files, directoryPath) => + var scanResults = await psf.ScanFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + foreach (var scanResult in scanResults) { - directoriesSeen.Add(directoryPath); - return Task.CompletedTask; - }, library); + directoriesSeen.Add(scanResult.Folder); + } Assert.Equal(2, directoriesSeen.Count); } @@ -311,16 +268,20 @@ public class ParseScannedFilesTests var fileSystem = CreateTestFilesystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + LibraryIncludes.Folders | LibraryIncludes.FileTypes); + Assert.NotNull(library); var directoriesSeen = new HashSet(); - await psf.ProcessFiles("C:/Data/", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), - (files, directoryPath) => + var scanResults = await psf.ScanFiles("C:/Data/", false, + await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + + foreach (var scanResult in scanResults) { - directoriesSeen.Add(directoryPath); - return Task.CompletedTask; - }, await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, - LibraryIncludes.Folders | LibraryIncludes.FileTypes)); + directoriesSeen.Add(scanResult.Folder); + } Assert.Single(directoriesSeen); directoriesSeen.TryGetValue("C:/Data/", out var actual); @@ -342,18 +303,14 @@ public class ParseScannedFilesTests var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var callCount = 0; - await psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),(files, folderPath) => - { - callCount++; + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + LibraryIncludes.Folders | LibraryIncludes.FileTypes); + Assert.NotNull(library); + var scanResults = await psf.ScanFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); - return Task.CompletedTask; - }, await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, - LibraryIncludes.Folders | LibraryIncludes.FileTypes)); - - Assert.Equal(2, callCount); + Assert.Equal(2, scanResults.Count); } @@ -375,18 +332,235 @@ public class ParseScannedFilesTests var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - var callCount = 0; - await psf.ProcessFiles("C:/Data", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),(files, folderPath) => - { - callCount++; - return Task.CompletedTask; - }, await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, - LibraryIncludes.Folders | LibraryIncludes.FileTypes)); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, + LibraryIncludes.Folders | LibraryIncludes.FileTypes); + Assert.NotNull(library); + var scanResults = await psf.ScanFiles("C:/Data", false, + await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); - Assert.Equal(1, callCount); + Assert.Single(scanResults); } + + + #endregion + + // TODO: Add back in (removed for Hotfix v0.8.5.x) + //[Fact] + public async Task HasSeriesFolderNotChangedSinceLastScan_AllSeriesFoldersHaveChanges() + { + const string testcase = "Subfolders always scanning all series changes - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + + var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + // Add a new chapter to a volume of the series, and scan. Validate that only, and all directories of this + // series are marked as HasChanged + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"), + "The Executioner and Her Way of Life Vol. 1"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz")); + + // 4 series, of which 2 have volumes as directories + var folderMap = await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id); + Assert.Equal(6, folderMap.Count); + + var res = await psf.ScanFiles(testDirectoryPath, true, folderMap, postLib); + var changes = res.Where(sc => sc.HasChanged).ToList(); + Assert.Equal(2, changes.Count); + // Only volumes of The Executioner and Her Way of Life should be marked as HasChanged (Spice and Wolf also has 2 volumes dirs) + Assert.Equal(2, changes.Count(sc => sc.Folder.Contains("The Executioner and Her Way of Life"))); + } + + [Fact] + public async Task HasSeriesFolderNotChangedSinceLastScan_PublisherLayout() + { + const string testcase = "Subfolder always scanning fix publisher layout - Comic.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Equal(2, frieren.Volumes.Count); + + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + // Add a volume to a series, and scan. Ensure only this series is marked as HasChanged + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "YenPress"), "The Executioner and Her Way of Life"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 2.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(1, changes); + } + + // TODO: Add back in (removed for Hotfix v0.8.5.x) + //[Fact] + public async Task SubFoldersNoSubFolders_SkipAll() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + // Needs to be actual time as the write time is now, so if we set LastFolderChecked in the past + // it'll always a scan as it was changed since the last scan. + await Task.Delay(1100); // Ensure at least one second has passed since library scan + + var res = await psf.ScanFiles(testDirectoryPath, true, + await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + Assert.DoesNotContain(res, sc => sc.HasChanged); + } + + [Fact] + public async Task SubFoldersNoSubFolders_ScanAllAfterAddInRoot() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2)); + _context.Series.Update(spiceAndWolf); + await _context.SaveChangesAsync(); + + // Add file at series root + var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(2, changes); + } + + [Fact] + public async Task SubFoldersNoSubFolders_ScanAllAfterAddInSubFolder() + { + const string testcase = "Subfolders and files at root - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var fs = new FileSystem(); + var ds = new DirectoryService(Substitute.For>(), fs); + var psf = new ParseScannedFiles(Substitute.For>(), ds, + new MockReadingItemService(ds, Substitute.For()), Substitute.For()); + + var scanner = _scannerHelper.CreateServices(ds, fs); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + spiceAndWolf.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(2)); + _context.Series.Update(spiceAndWolf); + await _context.SaveChangesAsync(); + + // Add file in subfolder + var spiceAndWolfDir = Path.Join(Path.Join(testDirectoryPath, "Spice and Wolf"), "Spice and Wolf Vol. 3"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0011.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz")); + + var res = await psf.ScanFiles(testDirectoryPath, true, + await _unitOfWork.SeriesRepository.GetFolderPathMap(postLib.Id), postLib); + var changes = res.Count(sc => sc.HasChanged); + Assert.Equal(2, changes); + } } diff --git a/API.Tests/Services/ProcessSeriesTests.cs b/API.Tests/Services/ProcessSeriesTests.cs index ef5c45007..119e1bc10 100644 --- a/API.Tests/Services/ProcessSeriesTests.cs +++ b/API.Tests/Services/ProcessSeriesTests.cs @@ -1,23 +1,8 @@ -using System.IO; -using API.Data; -using API.Data.Metadata; -using API.Entities; -using API.Entities.Enums; -using API.Helpers; -using API.Helpers.Builders; -using API.Services; -using API.Services.Tasks.Metadata; -using API.Services.Tasks.Scanner; -using API.SignalR; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Xunit; - -namespace API.Tests.Services; +namespace API.Tests.Services; public class ProcessSeriesTests { - + // TODO: Implement #region UpdateSeriesMetadata diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 3134997ff..102ea3b81 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1,24 +1,20 @@ using System.Collections.Generic; using System.Data.Common; -using System.Globalization; using System.IO.Abstractions.TestingHelpers; using System.Linq; 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; -using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; using AutoMapper; using Hangfire; using Hangfire.InMemory; @@ -31,18 +27,13 @@ using Xunit.Abstractions; namespace API.Tests.Services; -public class ReaderServiceTests +public class ReaderServiceTests: AbstractFsTest { private readonly ITestOutputHelper _testOutputHelper; private readonly IUnitOfWork _unitOfWork; private readonly DataContext _context; private readonly ReaderService _readerService; - private const string CacheDirectory = "C:/kavita/config/cache/"; - private const string CoverImageDirectory = "C:/kavita/config/covers/"; - private const string BackupDirectory = "C:/kavita/config/backups/"; - private const string DataDirectory = "C:/data/"; - public ReaderServiceTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; @@ -100,19 +91,6 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); } - private static MockFileSystem CreateFileSystem() - { - var fileSystem = new MockFileSystem(); - fileSystem.Directory.SetCurrentDirectory("C:/kavita/"); - fileSystem.AddDirectory("C:/kavita/config/"); - fileSystem.AddDirectory(CacheDirectory); - fileSystem.AddDirectory(CoverImageDirectory); - fileSystem.AddDirectory(BackupDirectory); - fileSystem.AddDirectory(DataDirectory); - - return fileSystem; - } - #endregion #region FormatBookmarkFolderPath @@ -135,9 +113,8 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(1) .Build()) .Build()) @@ -165,9 +142,8 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(1) .Build()) .Build()) @@ -204,9 +180,8 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(1) .Build()) .Build()) @@ -259,12 +234,11 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(1) .Build()) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(2) .Build()) .Build()) @@ -298,12 +272,11 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(1) .Build()) - .WithChapter(new ChapterBuilder("0") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) .WithPages(2) .Build()) .Build()) @@ -347,19 +320,16 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("21").Build()) .WithChapter(new ChapterBuilder("22").Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithMinNumber(3) .WithChapter(new ChapterBuilder("31").Build()) .WithChapter(new ChapterBuilder("32").Build()) .Build()) @@ -379,6 +349,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -390,12 +361,10 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1-2") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) .Build()) .WithVolume(new VolumeBuilder("3-4") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("1").Build()) .Build()) .Build(); @@ -412,6 +381,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 1, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("3-4", actualChapter.Volume.Name); Assert.Equal("1", actualChapter.Range); } @@ -456,6 +426,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("31", actualChapter.Range); } @@ -466,19 +437,16 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("21").Build()) .WithChapter(new ChapterBuilder("22").Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithMinNumber(3) .WithChapter(new ChapterBuilder("31").Build()) .WithChapter(new ChapterBuilder("32").Build()) .Build()) @@ -497,6 +465,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -507,19 +476,16 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("1.5") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("21").Build()) .WithChapter(new ChapterBuilder("22").Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithMinNumber(3) .WithChapter(new ChapterBuilder("31").Build()) .WithChapter(new ChapterBuilder("32").Build()) .Build()) @@ -539,6 +505,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("21", actualChapter.Range); } @@ -548,16 +515,14 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("21").Build()) + .WithChapter(new ChapterBuilder("22").Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("21").Build()) - .WithChapter(new ChapterBuilder("22").Build()) + .WithChapter(new ChapterBuilder("1").Build()) + .WithChapter(new ChapterBuilder("2").Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -574,7 +539,8 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); - Assert.Equal("1", actualChapter.Range); + Assert.NotNull(actualChapter); + Assert.Equal("21", actualChapter.Range); } [Fact] @@ -583,20 +549,17 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("66").Build()) .WithChapter(new ChapterBuilder("67").Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) - .WithChapter(new ChapterBuilder("0").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -616,7 +579,8 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); - Assert.Equal("0", actualChapter.Range); + Assert.NotNull(actualChapter); + Assert.Equal(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, actualChapter.Range); } [Fact] @@ -626,15 +590,13 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber).Build()) + .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -658,7 +620,6 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) @@ -683,8 +644,7 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) @@ -704,68 +664,69 @@ public class ReaderServiceTests } // This is commented out because, while valid, I can't solve how to make this pass (https://github.com/Kareadita/Kavita/issues/2099) - // [Fact] - // public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials_FirstIsVolume() - // { - // await ResetDb(); - // - // var series = new SeriesBuilder("Test") - // .WithVolume(new VolumeBuilder("0") - // .WithMinNumber(0) - // .WithChapter(new ChapterBuilder("1").Build()) - // .WithChapter(new ChapterBuilder("2").Build()) - // .Build()) - // .WithVolume(new VolumeBuilder("1") - // .WithMinNumber(1) - // .WithChapter(new ChapterBuilder("0").Build()) - // .Build()) - // .Build(); - // series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - // - // _context.Series.Add(series); - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007" - // }); - // - // await _context.SaveChangesAsync(); - // - // var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); - // Assert.Equal(-1, nextChapter); - // } + [Fact] + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials_FirstIsVolume() + { + await ResetDb(); - // This is commented out because, while valid, I can't solve how to make this pass - // [Fact] - // public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_WithSpecials() - // { - // await ResetDb(); - // - // var series = new SeriesBuilder("Test") - // .WithVolume(new VolumeBuilder("0") - // .WithMinNumber(0) - // .WithChapter(new ChapterBuilder("1").Build()) - // .WithChapter(new ChapterBuilder("2").Build()) - // .WithChapter(new ChapterBuilder("0").WithIsSpecial(true).Build()) - // .Build()) - // - // .WithVolume(new VolumeBuilder("1") - // .WithMinNumber(1) - // .WithChapter(new ChapterBuilder("2").Build()) - // .Build()) - // .Build(); - // series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); - // - // _context.Series.Add(series); - // _context.AppUser.Add(new AppUser() - // { - // UserName = "majora2007" - // }); - // - // await _context.SaveChangesAsync(); - // - // var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 4, 1); - // Assert.Equal(-1, nextChapter); - // } + var series = new SeriesBuilder("Test") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").Build()) + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) + .Build()) + .Build(); + series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); + + _context.Series.Add(series); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); + Assert.Equal(-1, nextChapter); + } + + [Fact] + public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_WithSpecials() + { + await ResetDb(); + + var series = new SeriesBuilder("Test") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").Build()) + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) + .Build()) + + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("2").Build()) + .Build()) + .Build(); + series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); + + _context.Series.Add(series); + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007" + }); + + await _context.SaveChangesAsync(); + + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); + Assert.Equal(-1, nextChapter); + } @@ -776,15 +737,19 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) + .WithChapter(new ChapterBuilder("B.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 2) + .Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -802,6 +767,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -811,11 +777,17 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) + .Build()) + + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .WithPages(1) + .Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -833,6 +805,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 2, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -842,15 +815,21 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) .Build()) + .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) + .Build()) + + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .WithPages(1) + .Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -864,7 +843,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - var nextChapter = await _readerService.GetNextChapterIdAsync(1, 1, 3, 1); + var nextChapter = await _readerService.GetNextChapterIdAsync(1, 3, 4, 1); Assert.Equal(-1, nextChapter); } @@ -876,14 +855,18 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) + .WithChapter(new ChapterBuilder("B.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 2) + .Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -901,6 +884,7 @@ public class ReaderServiceTests var nextChapter = await _readerService.GetNextChapterIdAsync(1, 2, 3, 1); Assert.NotEqual(-1, nextChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); + Assert.NotNull(actualChapter); Assert.Equal("B.cbz", actualChapter.Range); } @@ -911,12 +895,10 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("12").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("12").Build()) .Build()) .Build(); @@ -952,19 +934,16 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("21").Build()) .WithChapter(new ChapterBuilder("22").Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithMinNumber(3) .WithChapter(new ChapterBuilder("31").Build()) .WithChapter(new ChapterBuilder("32").Build()) .Build()) @@ -984,6 +963,7 @@ public class ReaderServiceTests var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.NotNull(actualChapter); Assert.Equal("1", actualChapter.Range); } @@ -995,19 +975,16 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("1.5") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("21").Build()) .WithChapter(new ChapterBuilder("22").Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithMinNumber(3) .WithChapter(new ChapterBuilder("31").Build()) .WithChapter(new ChapterBuilder("32").Build()) .Build()) @@ -1025,6 +1002,7 @@ public class ReaderServiceTests var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 3, 5, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.NotNull(actualChapter); Assert.Equal("22", actualChapter.Range); } @@ -1034,11 +1012,18 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("40").WithPages(1).Build()) .WithChapter(new ChapterBuilder("50").WithPages(1).Build()) .WithChapter(new ChapterBuilder("60").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithPages(1).WithIsSpecial(true).Build()) + .Build()) + + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .WithPages(1) + .Build()) .Build()) .WithVolume(new VolumeBuilder("1997") @@ -1065,7 +1050,7 @@ public class ReaderServiceTests // prevChapter should be id from ch.21 from volume 2001 - var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 4, 7, 1); + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 5, 7, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); Assert.NotNull(actualChapter); @@ -1109,6 +1094,7 @@ public class ReaderServiceTests var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -1119,15 +1105,13 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).Build()) + .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 2).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1147,6 +1131,7 @@ public class ReaderServiceTests var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(2, prevChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.NotNull(actualChapter); Assert.Equal("2", actualChapter.Range); } @@ -1157,7 +1142,6 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) @@ -1187,8 +1171,7 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1215,15 +1198,13 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("0").Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1237,10 +1218,7 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - - - - var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); + var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 3, 1); Assert.Equal(-1, prevChapter); } @@ -1250,23 +1228,20 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("5").Build()) .WithChapter(new ChapterBuilder("6").Build()) .WithChapter(new ChapterBuilder("7").Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("2").WithIsSpecial(true).Build()) + .WithChapter(new ChapterBuilder("1").Build()) + .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) - .WithChapter(new ChapterBuilder("3").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("4").WithIsSpecial(true).Build()) + .WithChapter(new ChapterBuilder("3").Build()) + .WithChapter(new ChapterBuilder("4").Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1298,8 +1273,7 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) @@ -1329,14 +1303,18 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) - .WithChapter(new ChapterBuilder("A.cbz").WithIsSpecial(true).Build()) - .WithChapter(new ChapterBuilder("B.cbz").WithIsSpecial(true).Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .Build()) + .WithChapter(new ChapterBuilder("B.cbz") + .WithIsSpecial(true) + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 2) + .Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1357,6 +1335,7 @@ public class ReaderServiceTests var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 2, 4, 1); Assert.NotEqual(-1, prevChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.NotNull(actualChapter); Assert.Equal("A.cbz", actualChapter.Range); } @@ -1366,13 +1345,11 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithMinNumber(0) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("21").Build()) .WithChapter(new ChapterBuilder("22").Build()) .Build()) @@ -1389,12 +1366,10 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); - - - var prevChapter = await _readerService.GetPrevChapterIdAsync(1, 1, 1, 1); Assert.NotEqual(-1, prevChapter); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); + Assert.NotNull(actualChapter); Assert.Equal("22", actualChapter.Range); } @@ -1405,12 +1380,10 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) .WithChapter(new ChapterBuilder("12").Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithMinNumber(2) .WithChapter(new ChapterBuilder("12").Build()) .Build()) .Build(); @@ -1438,7 +1411,7 @@ public class ReaderServiceTests { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").Build()) .WithChapter(new ChapterBuilder("96").Build()) .Build()) @@ -1485,7 +1458,7 @@ public class ReaderServiceTests .WithChapter(new ChapterBuilder("1").WithPages(3).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithPages(4) .Build(); @@ -1524,7 +1497,7 @@ public class ReaderServiceTests .WithChapter(new ChapterBuilder("1", "1-11").WithPages(3).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithPages(4) .Build(); @@ -1625,16 +1598,21 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") // Loose chapters - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("45").WithPages(1).Build()) .WithChapter(new ChapterBuilder("46").WithPages(1).Build()) .WithChapter(new ChapterBuilder("47").WithPages(1).Build()) .WithChapter(new ChapterBuilder("48").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title") + .WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1) + .WithIsSpecial(true).WithPages(1) + .Build()) .Build()) // One file volume .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) // Read + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) // Read .Build()) // Chapter-based volume .WithVolume(new VolumeBuilder("2") @@ -1694,10 +1672,12 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") // Loose chapters - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Prologue").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Prologue").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1728,7 +1708,7 @@ public class ReaderServiceTests .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) .Build()) - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("31").WithPages(1).Build()) .WithChapter(new ChapterBuilder("32").WithPages(1).Build()) .Build()) @@ -1782,7 +1762,7 @@ public class ReaderServiceTests { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("230").WithPages(1).Build()) .WithChapter(new ChapterBuilder("231").WithPages(1).Build()) .Build()) @@ -1818,17 +1798,19 @@ public class ReaderServiceTests { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("100").WithPages(1).Build()) .WithChapter(new ChapterBuilder("101").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Christmas Eve").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Christmas Eve").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -1871,7 +1853,7 @@ public class ReaderServiceTests { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("100").WithPages(1).Build()) .WithChapter(new ChapterBuilder("101").WithPages(1).Build()) .WithChapter(new ChapterBuilder("102").WithPages(1).Build()) @@ -1987,7 +1969,7 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) @@ -2027,11 +2009,13 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -2083,7 +2067,7 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("230").WithPages(1).Build()) //.WithChapter(new ChapterBuilder("231").WithPages(1).Build()) (Added later) .Build()) @@ -2093,7 +2077,7 @@ public class ReaderServiceTests .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) //.WithChapter(new ChapterBuilder("14.9").WithPages(1).Build()) (added later) .Build()) .Build(); @@ -2133,13 +2117,13 @@ public class ReaderServiceTests public async Task GetContinuePoint_ShouldReturnUnreadSingleVolume_WhenThereAreSomeSingleVolumesBeforeLooseLeafChapters() { await ResetDb(); - var readChapter1 = new ChapterBuilder("0").WithPages(1).Build(); - var readChapter2 = new ChapterBuilder("0").WithPages(1).Build(); - var volume = new VolumeBuilder("3").WithChapter(new ChapterBuilder("0").WithPages(1).Build()).Build(); + var readChapter1 = new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build(); + var readChapter2 = new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build(); + var volume = new VolumeBuilder("3").WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()).Build(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("51").WithPages(1).Build()) .WithChapter(new ChapterBuilder("52").WithPages(1).Build()) .WithChapter(new ChapterBuilder("53").WithPages(1).Build()) @@ -2153,7 +2137,7 @@ public class ReaderServiceTests .Build()) // 3, 4, and all loose leafs are unread should be unread .WithVolume(new VolumeBuilder("3") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("4") .WithChapter(new ChapterBuilder("40").WithPages(1).Build()) @@ -2207,11 +2191,13 @@ public class ReaderServiceTests .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) .WithChapter(new ChapterBuilder("22").WithPages(1).Build()) .Build()) - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("51").WithPages(1).Build()) .WithChapter(new ChapterBuilder("52").WithPages(1).Build()) .WithChapter(new ChapterBuilder("91").WithPages(2).Build()) - .WithChapter(new ChapterBuilder("Special").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Special").WithIsSpecial(true).WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -2376,11 +2362,13 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -2413,13 +2401,15 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2.5").WithPages(1).Build()) .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(1).Build()) .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build()) + .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -2453,10 +2443,10 @@ public class ReaderServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -2486,21 +2476,23 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("45").WithPages(5).Build()) .WithChapter(new ChapterBuilder("46").WithPages(46).Build()) .WithChapter(new ChapterBuilder("47").WithPages(47).Build()) .WithChapter(new ChapterBuilder("48").WithPages(48).Build()) .WithChapter(new ChapterBuilder("49").WithPages(49).Build()) .WithChapter(new ChapterBuilder("50").WithPages(50).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(10).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(10).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(6).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(6).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(7).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(7).Build()) .Build()) .WithVolume(new VolumeBuilder("3") .WithChapter(new ChapterBuilder("12").WithPages(5).Build()) @@ -2550,15 +2542,15 @@ public class ReaderServiceTests public async Task MarkSeriesAsReadTest() { await ResetDb(); - // TODO: Validate this is correct, shouldn't be possible to have 2 Volume 0's in a series + var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .WithChapter(new ChapterBuilder("1").WithPages(2).Build()) .Build()) - .WithVolume(new VolumeBuilder("0") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .WithChapter(new ChapterBuilder("1").WithPages(2).Build()) .Build()) .Build(); @@ -2592,8 +2584,8 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .WithChapter(new ChapterBuilder("1").WithPages(2).Build()) .Build()) .Build(); @@ -2665,22 +2657,24 @@ public class ReaderServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("10").WithPages(1).Build()) .WithChapter(new ChapterBuilder("20").WithPages(1).Build()) .WithChapter(new ChapterBuilder("30").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1997") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2002") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2003") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter).WithPages(1).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Manga).Build(); @@ -2718,11 +2712,13 @@ public class ReaderServiceTests { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("10").WithPages(1).Build()) .WithChapter(new ChapterBuilder("20").WithPages(1).Build()) .WithChapter(new ChapterBuilder("30").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Some Special Title").WithIsSpecial(true).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Some Special Title").WithSortOrder(API.Services.Tasks.Scanner.Parser.Parser.SpecialVolumeNumber + 1).WithIsSpecial(true).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1997") .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 23de53674..7a6ed3e0b 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -11,15 +11,11 @@ using API.DTOs.ReadingLists; using API.DTOs.ReadingLists.CBL; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; -using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.SignalR; -using API.Tests.Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -52,7 +48,9 @@ public class ReadingListServiceTests var mapper = config.CreateMapper(); _unitOfWork = new UnitOfWork(_context, mapper, null!); - _readingListService = new ReadingListService(_unitOfWork, Substitute.For>(), Substitute.For()); + var ds = new DirectoryService(Substitute.For>(), new MockFileSystem()); + _readingListService = new ReadingListService(_unitOfWork, Substitute.For>(), + Substitute.For(), Substitute.For(), ds); _readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), @@ -128,7 +126,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithAgeRating(AgeRating.Everyone) .Build() @@ -177,7 +175,7 @@ public class ReadingListServiceTests .WithSeries(new SeriesBuilder("Test") .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithAgeRating(AgeRating.Everyone) .Build() @@ -236,7 +234,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithAgeRating(AgeRating.Everyone) .Build() @@ -296,7 +294,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithAgeRating(AgeRating.Everyone) .Build() @@ -375,7 +373,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithAgeRating(AgeRating.Everyone) .Build() @@ -432,7 +430,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithAgeRating(AgeRating.Everyone) .Build() @@ -497,7 +495,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .Build() ) @@ -538,7 +536,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .Build() ) @@ -581,6 +579,93 @@ public class ReadingListServiceTests Assert.Equal(AgeRating.G, readingList.AgeRating); } + [Fact] + public async Task UpdateReadingListAgeRatingForSeries() + { + await ResetDb(); + var spiceAndWolf = new SeriesBuilder("Spice and Wolf") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes([ + new VolumeBuilder("1") + .WithChapters([ + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + ]).Build() + ]).Build(); + spiceAndWolf.Metadata.AgeRating = AgeRating.Everyone; + + var othersidePicnic = new SeriesBuilder("Otherside Picnic ") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithVolumes([ + new VolumeBuilder("1") + .WithChapters([ + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + ]).Build() + ]).Build(); + othersidePicnic.Metadata.AgeRating = AgeRating.Everyone; + + _context.AppUser.Add(new AppUser() + { + UserName = "Amelia", + ReadingLists = new List(), + Libraries = new List + { + new LibraryBuilder("Test Library", LibraryType.LightNovel) + .WithSeries(spiceAndWolf) + .WithSeries(othersidePicnic) + .Build(), + }, + }); + + await _context.SaveChangesAsync(); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("Amelia", AppUserIncludes.ReadingLists); + Assert.NotNull(user); + + var myTestReadingList = new ReadingListBuilder("MyReadingList").Build(); + var mySecondTestReadingList = new ReadingListBuilder("MySecondReadingList").Build(); + var myThirdTestReadingList = new ReadingListBuilder("MyThirdReadingList").Build(); + user.ReadingLists = new List() + { + myTestReadingList, + mySecondTestReadingList, + myThirdTestReadingList, + }; + + + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myThirdTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, mySecondTestReadingList); + + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + await _readingListService.CalculateReadingListAgeRating(myTestReadingList); + await _readingListService.CalculateReadingListAgeRating(mySecondTestReadingList); + Assert.Equal(AgeRating.Everyone, myTestReadingList.AgeRating); + Assert.Equal(AgeRating.Everyone, mySecondTestReadingList.AgeRating); + Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating); + + await _readingListService.UpdateReadingListAgeRatingForSeries(othersidePicnic.Id, AgeRating.Mature); + await _unitOfWork.CommitAsync(); + + // Reading lists containing Otherside Picnic are updated + myTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + Assert.NotNull(myTestReadingList); + Assert.Equal(AgeRating.Mature, myTestReadingList.AgeRating); + + mySecondTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(2); + Assert.NotNull(mySecondTestReadingList); + Assert.Equal(AgeRating.Mature, mySecondTestReadingList.AgeRating); + + // Unrelated reading list is not updated + myThirdTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(3); + Assert.NotNull(myThirdTestReadingList); + Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating); + } + #endregion #region CalculateStartAndEndDates @@ -593,7 +678,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .Build() ) @@ -645,7 +730,7 @@ public class ReadingListServiceTests .WithMetadata(new SeriesMetadataBuilder().Build()) .WithVolumes(new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1") .WithReleaseDate(new DateTime(2005, 03, 01)) .Build() @@ -711,6 +796,9 @@ public class ReadingListServiceTests Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1", "The Title"))); Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", chapterTitleName: "The Title"))); Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterTitleName: "The Title"))); + var dto = CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterNumber: "The Special Title"); + dto.IsSpecial = true; + Assert.Equal("The Special Title", ReadingListService.FormatTitle(dto)); // Book Library & Archive Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1"))); @@ -736,8 +824,8 @@ public class ReadingListServiceTests } private static ReadingListItemDto CreateListItemDto(MangaFormat seriesFormat, LibraryType libraryType, - string volumeNumber = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, - string chapterNumber = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + string volumeNumber = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, + string chapterNumber =API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, string chapterTitleName = "") { return new ReadingListItemDto() @@ -1205,6 +1293,65 @@ public class ReadingListServiceTests Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId); Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); } + + /// + /// This test is about ensuring Annuals that are a separate series can be linked up properly (ComicVine) + /// + //[Fact] + public async Task CreateReadingListFromCBL_ShouldCreateList_WithAnnuals() + { + // TODO: Implement this correctly + await ResetDb(); + var cblReadingList = LoadCblFromPath("Annual.cbl"); + + // Mock up our series + var fablesSeries = new SeriesBuilder("Fables") + .WithVolume(new VolumeBuilder("2002") + .WithMinNumber(1) + .WithChapter(new ChapterBuilder("1").Build()) + .WithChapter(new ChapterBuilder("2").Build()) + .WithChapter(new ChapterBuilder("3").Build()) + .Build()) + .Build(); + + var fables2Series = new SeriesBuilder("Fables Annual") + .WithVolume(new VolumeBuilder("2003") + .WithMinNumber(1) + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + + _context.AppUser.Add(new AppUser() + { + UserName = "majora2007", + ReadingLists = new List(), + Libraries = new List() + { + new LibraryBuilder("Test LIb 2", LibraryType.Book) + .WithSeries(fablesSeries) + .WithSeries(fables2Series) + .Build() + }, + }); + await _unitOfWork.CommitAsync(); + + var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList); + + Assert.Equal(CblImportResult.Success, importSummary.Success); + Assert.NotEmpty(importSummary.Results); + + var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + + Assert.NotNull(createdList); + Assert.Equal("Annual", createdList.Title); + + Assert.Equal(4, createdList.Items.Count); + Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); + Assert.Equal(4, createdList.Items.First(item => item.Order == 2).ChapterId); + Assert.Equal(3, createdList.Items.First(item => item.Order == 3).ChapterId); + } + #endregion #region CreateReadingListsFromSeries @@ -1239,7 +1386,7 @@ public class ReadingListServiceTests var series2 = new SeriesBuilder("Series 2") .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume) + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").Build()) .WithChapter(new ChapterBuilder("2").Build()) .Build()) diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 0d0277e3e..4554820fb 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1,71 +1,941 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading.Tasks; +using API.Data.Metadata; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; -using API.Entities.Metadata; using API.Extensions; -using API.Helpers.Builders; -using API.Services.Tasks; -using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; using API.Tests.Helpers; +using Hangfire; using Xunit; +using Xunit.Abstractions; namespace API.Tests.Services; -public class ScannerServiceTests +public class ScannerServiceTests : AbstractDbTest { - [Fact] - public void FindSeriesNotOnDisk_Should_Remove1() + private readonly ITestOutputHelper _testOutputHelper; + private readonly ScannerHelper _scannerHelper; + private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"); + + public ScannerServiceTests(ITestOutputHelper testOutputHelper) { - var infos = new Dictionary>(); + _testOutputHelper = testOutputHelper; - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Archive}); - //AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Epub}); + // Set up Hangfire to use in-memory storage for testing + GlobalConfiguration.Configuration.UseInMemoryStorage(); + _scannerHelper = new ScannerHelper(_unitOfWork, testOutputHelper); + } - var existingSeries = new List + protected override async Task ResetDb() + { + _context.Library.RemoveRange(_context.Library); + await _context.SaveChangesAsync(); + } + + + protected async Task SetAllSeriesLastScannedInThePast(Library library, TimeSpan? duration = null) + { + foreach (var series in library.Series) { - new SeriesBuilder("Darker Than Black") - .WithFormat(MangaFormat.Epub) + await SetLastScannedInThePast(series, duration, false); + } + await _context.SaveChangesAsync(); + } - .WithVolume(new VolumeBuilder("1") - .WithName("1") - .Build()) - .WithLocalizedName("Darker Than Black") - .Build() - }; + protected async Task SetLastScannedInThePast(Series series, TimeSpan? duration = null, bool save = true) + { + duration ??= TimeSpan.FromMinutes(2); + series.LastFolderScanned = DateTime.Now.Subtract(duration.Value); + _context.Series.Update(series); - Assert.Single(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); + if (save) + { + await _context.SaveChangesAsync(); + } } [Fact] - public void FindSeriesNotOnDisk_Should_RemoveNothing_Test() + public async Task ScanLibrary_ComicVine_PublisherFolder() { - var infos = new Dictionary>(); + var testcase = "Publisher - ComicVine.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Format = MangaFormat.Archive}); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "1", Format = MangaFormat.Archive}); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "10", Format = MangaFormat.Archive}); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + } - var existingSeries = new List + [Fact] + public async Task ScanLibrary_ShouldCombineNestedFolder() + { + var testcase = "Series and Series-Series Combined - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(2, postLib.Series.First().Volumes.Count); + } + + + [Fact] + public async Task ScanLibrary_FlatSeries() + { + const string testcase = "Flat Series - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + + // TODO: Trigger a deletion of ch 10 + } + + [Fact] + public async Task ScanLibrary_FlatSeriesWithSpecialFolder() + { + const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(4, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); + } + + [Fact] + public async Task ScanLibrary_FlatSeriesWithSpecialFolder_AlternativeNaming() + { + const string testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(4, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); + } + + [Fact] + public async Task ScanLibrary_FlatSeriesWithSpecial() + { + const string testcase = "Flat Special - Manga.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); + } + + [Fact] + public async Task ScanLibrary_SeriesWithUnbalancedParenthesis() + { + const string testcase = "Scan Library Parses as ( - Manga.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var series = postLib.Series.First(); + + Assert.Equal("Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", series.Name); + } + + /// + /// This is testing that if the first file is named A and has a localized name of B if all other files are named B, it should still group and name the series A + /// + [Fact] + public async Task ScanLibrary_LocalizedSeries() + { + const string testcase = "Series with Localized - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo() { - new SeriesBuilder("Cage of Eden") - .WithFormat(MangaFormat.Archive) + Series = "My Dress-Up Darling", + LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru" + }); - .WithVolume(new VolumeBuilder("1") - .WithName("1") - .Build()) - .WithLocalizedName("Darker Than Black") - .Build(), - new SeriesBuilder("Darker Than Black") - .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("1") - .WithName("1") - .Build()) - .WithLocalizedName("Darker Than Black") - .Build(), - }; + var library = await _scannerHelper.GenerateScannerData(testcase, infos); - Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries2() + { + const string testcase = "Series with Localized 2 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Equal(3, s.Volumes.Count); + } + + + /// + /// Special Keywords shouldn't be removed from the series name and thus these 2 should group + /// + [Fact] + public async Task ScanLibrary_ExtraShouldNotAffect() + { + const string testcase = "Series with Extra - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Vol.01.cbz", new ComicInfo() + { + Series = "The Novel's Extra", + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("The Novel's Extra", s.Name); + Assert.Equal(2, s.Volumes.Count); + } + + + /// + /// Files under a folder with a SP marker should group into one issue + /// + /// https://github.com/Kareadita/Kavita/issues/3299 + [Fact] + public async Task ScanLibrary_ImageSeries_SpecialGrouping() + { + const string testcase = "Image Series with SP Folder - Manga.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + } + + /// + /// This test is currently disabled because the Image parser is unable to support multiple files mapping into one single Special. + /// https://github.com/Kareadita/Kavita/issues/3299 + /// + public async Task ScanLibrary_ImageSeries_SpecialGrouping_NonEnglish() + { + const string testcase = "Image Series with SP Folder (Non English) - Image.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Equal(3, series.Volumes.Count); + var specialVolume = series.Volumes.FirstOrDefault(v => v.Name == Parser.SpecialVolume); + Assert.NotNull(specialVolume); + Assert.Single(specialVolume.Chapters); + Assert.True(specialVolume.Chapters.First().IsSpecial); + //Assert.Equal("葬送のフリーレン 公式ファンブック SP01", specialVolume.Chapters.First().Title); + } + + + [Fact] + public async Task ScanLibrary_PublishersInheritFromChapters() + { + const string testcase = "Flat Special - Manga.json"; + + var infos = new Dictionary(); + infos.Add("Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz", new ComicInfo() + { + Publisher = "Correct Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", new ComicInfo() + { + Publisher = "Special Publisher" + }); + infos.Add("Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", new ComicInfo() + { + Publisher = "Chapter Publisher" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var publishers = postLib.Series.First().Metadata.People + .Where(p => p.Role == PersonRole.Publisher); + Assert.Equal(3, publishers.Count()); + } + + + /// + /// Tests that pdf parser handles the loose chapters correctly + /// https://github.com/Kareadita/Kavita/issues/3148 + /// + [Fact] + public async Task ScanLibrary_LooseChapters_Pdf() + { + const string testcase = "PDF Comic Chapters - Comic.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Single(series.Volumes); + Assert.Equal(4, series.Volumes.First().Chapters.Count); + } + + [Fact] + public async Task ScanLibrary_LooseChapters_Pdf_LN() + { + const string testcase = "PDF Comic Chapters - LightNovel.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var series = postLib.Series.First(); + Assert.Single(series.Volumes); + Assert.Equal(4, series.Volumes.First().Chapters.Count); + } + + /// + /// This is the same as doing ScanFolder as the case where it can find the series is just ScanSeries + /// + [Fact] + public async Task ScanSeries_NewChapterInNestedFolder() + { + const string testcase = "Series with Localized - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("My Dress-Up Darling v01.cbz", new ComicInfo() + { + Series = "My Dress-Up Darling", + LocalizedSeries = "Sono Bisque Doll wa Koi wo Suru" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var series = postLib.Series.First(); + Assert.Equal(3, series.Volumes.Count); + + // Bootstrap a new file in the nested "Sono Bisque Doll wa Koi wo Suru" directory and perform a series scan + var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(testcase)); + await _scannerHelper.Scaffold(testDirectory, ["My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 11.cbz"]); + + // Now that a new file exists in the subdirectory, scan again + await scanner.ScanSeries(series.Id); + Assert.Single(postLib.Series); + Assert.Equal(3, series.Volumes.Count); + Assert.Equal(2, series.Volumes.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)).Chapters.Count); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Immoral Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_LocalizedSeries_MatchesFilename_SameNames() + { + const string testcase = "Localized Name matches Filename - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Futoku no Guild v01.cbz", new ComicInfo() + { + Series = "Futoku no Guild", + LocalizedSeries = "Futoku no Guild" + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal("Futoku no Guild", s.Name); + Assert.Equal("Futoku no Guild", s.LocalizedName); + Assert.Single(s.Volumes); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_ExcludePattern_FlippedSlashes_Works() + { + const string testcase = "Exclude Pattern 1 - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + var s = postLib.Series.First(); + Assert.Equal(2, s.Volumes.Count); + } + + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists_Forced() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Make a change (copy a file into only 1 root) + var root1PlushFolder = Path.Join(testDirectoryPath, "Root 1/Antarctic Press/Plush"); + File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz")); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, true); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(3, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } + + /// + /// Regression bug appeared where multi-root and one root gets a new file, on next scan of library, + /// the series in the other root are deleted. (This is actually failing because the file in Root 1 isn't being detected) + /// + [Fact] + public async Task ScanLibrary_MultipleRoots_MultipleScans_DataPersists_NonForced() + { + const string testcase = "Multiple Roots - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = + Path.Join( + Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests"), + testcase.Replace(".json", string.Empty)); + library.Folders = + [ + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 1")}, + new FolderPath() {Path = Path.Join(testDirectoryPath, "Root 2")} + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + var s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(2, s.Volumes.Count); + var s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + + // Make a change (copy a file into only 1 root) + var root1PlushFolder = Path.Join(testDirectoryPath, "Root 1/Antarctic Press/Plush"); + File.Copy(Path.Join(root1PlushFolder, "Plush v02.cbz"), Path.Join(root1PlushFolder, "Plush v03.cbz")); + + // Emulate time passage by updating lastFolderScan to be a min in the past + await SetLastScannedInThePast(s); + + // Rescan to ensure nothing changes yet again + await scanner.ScanLibrary(library.Id, false); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.Equal(2, postLib.Series.Count); + s = postLib.Series.First(s => s.Name == "Plush"); + Assert.Equal(3, s.Volumes.Count); + s2 = postLib.Series.First(s => s.Name == "Accel"); + Assert.Single(s2.Volumes); + } + + [Fact] + public async Task ScanLibrary_AlternatingRemoval_IssueReplication() + { + // https://github.com/Kareadita/Kavita/issues/3476#issuecomment-2661635558 + const string testcase = "Alternating Removal - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Remove Root 2, expect Accel to be removed + library.Folders = [new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + // Emulate time passage by updating lastFolderScan to be a min in the past + foreach (var s in postLib.Series) + { + s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)); + _context.Series.Update(s); + } + await _context.SaveChangesAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.DoesNotContain(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Third Scan: Re-add Root 2, Accel should come back + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + // Emulate time passage by updating lastFolderScan to be a min in the past + foreach (var s in postLib.Series) + { + s.LastFolderScanned = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)); + _context.Series.Update(s); + } + await _context.SaveChangesAsync(); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Accel should be back + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Emulate time passage by updating lastFolderScan to be a min in the past + await SetAllSeriesLastScannedInThePast(postLib); + + // Fourth Scan: Run again to check stability (should not remove Accel) + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + + [Fact] + public async Task ScanLibrary_DeleteSeriesInUI_ComeBack() + { + const string testcase = "Delete Series In UI - Manga.json"; + + // Setup: Generate test library + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + var testDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), + "../../../Services/Test Data/ScannerService/ScanTests", + testcase.Replace(".json", string.Empty)); + + library.Folders = + [ + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 1") }, + new FolderPath() { Path = Path.Combine(testDirectoryPath, "Root 2") } + ]; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + + // First Scan: Everything should be added + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Contains(postLib.Series, s => s.Name == "Accel"); + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + + // Second Scan: Delete the Series + library.Series = []; + await _unitOfWork.CommitAsync(); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Empty(postLib.Series); + + await scanner.ScanLibrary(library.Id); + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.Contains(postLib.Series, s => s.Name == "Accel"); // Ensure Accel is gone + Assert.Contains(postLib.Series, s => s.Name == "Plush"); + } + + [Fact] + public async Task SubFolders_NoRemovals_ChangesFound() + { + const string testcase = "Subfolders always scanning all series changes - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + Assert.Equal(3, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + var frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + Assert.Equal(2, frieren.Volumes.Sum(v => v.Chapters.Count)); + + var executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); + + await SetAllSeriesLastScannedInThePast(postLib); + + // Add a new chapter to a volume of the series, and scan. Validate that no chapters were lost, and the new + // chapter was added + var executionerCopyDir = Path.Join(Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"), + "The Executioner and Her Way of Life Vol. 1"); + File.Copy(Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz"), + Path.Join(executionerCopyDir, "The Executioner and Her Way of Life Vol. 1 Ch. 0002.cbz")); + + await scanner.ScanLibrary(library.Id); + await _unitOfWork.CommitAsync(); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(2, spiceAndWolf.Volumes.Count); + Assert.Equal(3, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + frieren = postLib.Series.First(x => x.Name == "Frieren - Beyond Journey's End"); + Assert.Single(frieren.Volumes); + Assert.Equal(2, frieren.Volumes.Sum(v => v.Chapters.Count)); + + executionerAndHerWayOfLife = postLib.Series.First(x => x.Name == "The Executioner and Her Way of Life"); + Assert.Equal(2, executionerAndHerWayOfLife.Volumes.Count); + Assert.Equal(3, executionerAndHerWayOfLife.Volumes.Sum(v => v.Chapters.Count)); // Incremented by 1 + } + + [Fact] + public async Task RemovalPickedUp_NoOtherChanges() + { + const string testcase = "Series removed when no other changes are made - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + var executionerCopyDir = Path.Join(testDirectoryPath, "The Executioner and Her Way of Life"); + Directory.Delete(executionerCopyDir, true); + + await scanner.ScanLibrary(library.Id); + await _unitOfWork.CommitAsync(); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Single(postLib.Series, s => s.Name == "Spice and Wolf"); + Assert.Equal(2, postLib.Series.First().Volumes.Count); + } + + [Fact] + public async Task SubFoldersNoSubFolders_CorrectPickupAfterAdd() + { + // This test case is used in multiple tests and can result in conflict if not separated + const string testcase = "Subfolders and files at root (2) - Manga.json"; + var infos = new Dictionary(); + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + var testDirectoryPath = library.Folders.First().Path; + + _unitOfWork.LibraryRepository.Update(library); + await _unitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + var spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(3, spiceAndWolf.Volumes.Count); + Assert.Equal(4, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + await SetLastScannedInThePast(spiceAndWolf); + + // Add volume to Spice and Wolf series directory + var spiceAndWolfDir = Path.Join(testDirectoryPath, "Spice and Wolf"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 1.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 4.cbz")); + + await scanner.ScanLibrary(library.Id); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(4, spiceAndWolf.Volumes.Count); + Assert.Equal(5, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + await SetLastScannedInThePast(spiceAndWolf); + + // Add file in subfolder + spiceAndWolfDir = Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3"); + File.Copy(Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0012.cbz"), + Path.Join(spiceAndWolfDir, "Spice and Wolf Vol. 3 Ch. 0013.cbz")); + + await scanner.ScanLibrary(library.Id); + + postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + + spiceAndWolf = postLib.Series.First(x => x.Name == "Spice and Wolf"); + Assert.Equal(4, spiceAndWolf.Volumes.Count); + Assert.Equal(6, spiceAndWolf.Volumes.Sum(v => v.Chapters.Count)); + + } + + + /// + /// Ensure when Kavita scans, the sort order of chapters is correct + /// + [Fact] + public async Task ScanLibrary_SortOrderWorks() + { + const string testcase = "Sort Order - Manga.json"; + + var library = await _scannerHelper.GenerateScannerData(testcase); + + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + Assert.NotNull(postLib); + + // Get the loose leaf volume and confirm each chapter aligns with expectation of Sort Order + var series = postLib.Series.First(); + Assert.NotNull(series); + + var volume = series.Volumes.FirstOrDefault(); + Assert.NotNull(volume); + + var sortedChapters = volume.Chapters.OrderBy(c => c.SortOrder).ToList(); + Assert.True(sortedChapters[0].SortOrder.Is(1f)); + Assert.True(sortedChapters[1].SortOrder.Is(4f)); + Assert.True(sortedChapters[2].SortOrder.Is(5f)); } } diff --git a/API.Tests/Services/ScrobblingServiceTests.cs b/API.Tests/Services/ScrobblingServiceTests.cs index d460ee4e5..b7a418d83 100644 --- a/API.Tests/Services/ScrobblingServiceTests.cs +++ b/API.Tests/Services/ScrobblingServiceTests.cs @@ -1,11 +1,208 @@ -using API.Services.Plus; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Helpers.Builders; +using API.Services; +using API.Services.Plus; +using API.SignalR; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace API.Tests.Services; #nullable enable -public class ScrobblingServiceTests +public class ScrobblingServiceTests : AbstractDbTest { + private readonly ScrobblingService _service; + private readonly ILicenseService _licenseService; + private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; + private readonly IEmailService _emailService; + + public ScrobblingServiceTests() + { + _licenseService = Substitute.For(); + _localizationService = Substitute.For(); + _logger = Substitute.For>(); + _emailService = Substitute.For(); + + _service = new ScrobblingService(_unitOfWork, Substitute.For(), _logger, _licenseService, _localizationService, _emailService); + } + + protected override async Task ResetDb() + { + _context.ScrobbleEvent.RemoveRange(_context.ScrobbleEvent.ToList()); + _context.Series.RemoveRange(_context.Series.ToList()); + _context.Library.RemoveRange(_context.Library.ToList()); + _context.AppUser.RemoveRange(_context.AppUser.ToList()); + + await _unitOfWork.CommitAsync(); + } + + private async Task SeedData() + { + var series = new SeriesBuilder("Test Series") + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + + var library = new LibraryBuilder("Test Library", LibraryType.Manga) + .WithAllowScrobbling(true) + .WithSeries(series) + .Build(); + + + _context.Library.Add(library); + + var user = new AppUserBuilder("testuser", "testuser") + //.WithPreferences(new UserPreferencesBuilder().WithAniListScrobblingEnabled(true).Build()) + .Build(); + + user.UserPreferences.AniListScrobblingEnabled = true; + + _unitOfWork.UserRepository.Add(user); + + await _unitOfWork.CommitAsync(); + } + + #region ScrobbleWantToReadUpdate Tests + + [Fact] + public async Task ScrobbleWantToReadUpdate_NoExistingEvents_WantToRead_ShouldCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // Act + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + Assert.Single(events); + Assert.Equal(ScrobbleEventType.AddWantToRead, events[0].ScrobbleEventType); + Assert.Equal(userId, events[0].AppUserId); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_NoExistingEvents_RemoveWantToRead_ShouldCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // Act + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + Assert.Single(events); + Assert.Equal(ScrobbleEventType.RemoveWantToRead, events[0].ScrobbleEventType); + Assert.Equal(userId, events[0].AppUserId); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_WantToRead_ShouldNotCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create an event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Act - Try to create the same event again + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.All(events, e => Assert.Equal(ScrobbleEventType.AddWantToRead, e.ScrobbleEventType)); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingWantToReadEvent_RemoveWantToRead_ShouldAddRemoveEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Act - Now remove from want-to-read + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.RemoveWantToRead); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_RemoveWantToRead_ShouldNotCreateNewEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a remove-from-want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Act - Try to create the same event again + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Assert + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.All(events, e => Assert.Equal(ScrobbleEventType.RemoveWantToRead, e.ScrobbleEventType)); + } + + [Fact] + public async Task ScrobbleWantToReadUpdate_ExistingRemoveWantToReadEvent_WantToRead_ShouldAddWantToReadEvent() + { + // Arrange + await SeedData(); + _licenseService.HasActiveLicense().Returns(Task.FromResult(true)); + + const int userId = 1; + const int seriesId = 1; + + // First, let's create a remove-from-want-to-read event through the service + await _service.ScrobbleWantToReadUpdate(userId, seriesId, false); + + // Act - Now add to want-to-read + await _service.ScrobbleWantToReadUpdate(userId, seriesId, true); + + // Assert + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + + Assert.Single(events); + Assert.Contains(events, e => e.ScrobbleEventType == ScrobbleEventType.AddWantToRead); + } + + #endregion + [Theory] [InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)] [InlineData("https://anilist.co/manga/30105", 30105)] diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 97a4306d3..5696bb76b 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -1,30 +1,29 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO.Abstractions; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.CollectionTags; using API.DTOs.Metadata; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers.Builders; using API.Services; using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using API.Tests.Helpers; -using EasyCaching.Core; using Hangfire; using Hangfire.InMemory; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Hosting.Internal; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -49,7 +48,7 @@ public class SeriesServiceTests : AbstractDbTest { private readonly ISeriesService _seriesService; - public SeriesServiceTests() : base() + public SeriesServiceTests() { var ds = new DirectoryService(Substitute.For>(), new FileSystem()); @@ -59,8 +58,9 @@ public class SeriesServiceTests : AbstractDbTest _seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), locService); + Substitute.For(), locService, Substitute.For()); } + #region Setup protected override async Task ResetDb() @@ -77,7 +77,7 @@ public class SeriesServiceTests : AbstractDbTest private static UpdateRelatedSeriesDto CreateRelationsDto(Series series) { - return new UpdateRelatedSeriesDto() + return new UpdateRelatedSeriesDto { SeriesId = series.Id, Prequels = new List(), @@ -91,7 +91,8 @@ public class SeriesServiceTests : AbstractDbTest AlternativeVersions = new List(), SideStories = new List(), SpinOffs = new List(), - Editions = new List() + Editions = new List(), + Annuals = new List() }; } @@ -108,9 +109,9 @@ public class SeriesServiceTests : AbstractDbTest .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithTitle("Omake").WithPages(1).Build()) - .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithTitle("Something").WithPages(1).Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithTitle("Omake").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 2).WithTitle("Something").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) @@ -144,7 +145,7 @@ public class SeriesServiceTests : AbstractDbTest .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .Build()) @@ -181,12 +182,12 @@ public class SeriesServiceTests : AbstractDbTest .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("3") @@ -214,12 +215,12 @@ public class SeriesServiceTests : AbstractDbTest _context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("3") @@ -252,11 +253,11 @@ public class SeriesServiceTests : AbstractDbTest .WithSeries(new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .Build()) .Build()); @@ -280,11 +281,13 @@ public class SeriesServiceTests : AbstractDbTest .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") - .WithChapter(new ChapterBuilder("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", "Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub").WithIsSpecial(true).WithPages(1).Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", "Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub") + .WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", "Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", "Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub") + .WithPages(1).WithSortOrder(Parser.SpecialVolumeNumber + 1).Build()) .Build()) .Build()) .Build()); @@ -298,7 +301,7 @@ public class SeriesServiceTests : AbstractDbTest Assert.Equal("2 - Ano Orokamono ni mo Kyakkou wo! - Volume 2", detail.Volumes.ElementAt(0).Name); Assert.NotEmpty(detail.Specials); - Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", detail.Specials.ElementAt(0).Range); + Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1", detail.Specials.ElementAt(0).Range); // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense Assert.Empty(detail.Chapters); @@ -311,19 +314,19 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Manga) + _context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1.2") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(1).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).Build()) .Build()) .Build()) .Build()); @@ -338,6 +341,255 @@ public class SeriesServiceTests : AbstractDbTest } + /// + /// Validates that the Series Detail API returns Title names as expected for Manga library type + /// + [Fact] + public async Task SeriesDetail_Manga_ShouldReturnAppropriatelyNamedTitles() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithTitle("Omake").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 2).WithTitle("Something").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithSortOrder(1).WithTitle("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2-5").WithSortOrder(2).WithTitle("2-5").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("5.5").WithSortOrder(3).WithTitle("5.5").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) + .Build()) + + .WithVolume(new VolumeBuilder("3") + .WithChapter(new ChapterBuilder("31").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("32").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + await _context.SaveChangesAsync(); + + + var detail = await _seriesService.GetSeriesDetail(1, 1); + Assert.NotEmpty(detail.Specials); + + Assert.Equal("Volume 2", detail.Volumes.First().Name); + Assert.Equal("Volume 3", detail.Volumes.Last().Name); + + var chapters = detail.Chapters.ToArray(); + Assert.Equal("Chapter 1", chapters[0].Title); + Assert.Equal("Chapter 2-5", chapters[1].Title); + Assert.Equal("Chapter 5.5", chapters[2].Title); + + Assert.Equal("Omake", detail.Specials.First().Title); + Assert.Equal("Something", detail.Specials.Last().Title); + } + + + /// + /// Validates that the Series Detail API returns Title names as expected for Comic library type + /// + [Fact] + public async Task SeriesDetail_Comic_ShouldReturnAppropriatelyNamedTitles() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Comic) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithTitle("Omake").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 2).WithTitle("Something").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithSortOrder(1).WithTitle("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2-5").WithSortOrder(2).WithTitle("2-5").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("5.5").WithSortOrder(3).WithTitle("5.5").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) + .Build()) + + .WithVolume(new VolumeBuilder("3") + .WithChapter(new ChapterBuilder("31").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("32").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + await _context.SaveChangesAsync(); + + + var detail = await _seriesService.GetSeriesDetail(1, 1); + Assert.NotEmpty(detail.Specials); + + Assert.Equal("Volume 2", detail.Volumes.First().Name); + Assert.Equal("Volume 3", detail.Volumes.Last().Name); + + var chapters = detail.Chapters.ToArray(); + Assert.Equal("Issue #1", chapters[0].Title); + Assert.Equal("Issue #2-5", chapters[1].Title); + Assert.Equal("Issue #5.5", chapters[2].Title); + + Assert.Equal("Omake", detail.Specials.First().Title); + Assert.Equal("Something", detail.Specials.Last().Title); + } + + /// + /// Validates that the Series Detail API returns Title names as expected for ComicVine library type + /// + [Fact] + public async Task SeriesDetail_ComicVine_ShouldReturnAppropriatelyNamedTitles() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.ComicVine) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithTitle("Omake").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 2).WithTitle("Something").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithSortOrder(1).WithTitle("Batman is Here").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2-5").WithSortOrder(2).WithTitle("Batman Left").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("5.5").WithSortOrder(3).WithTitle("Batman is Back").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) + .Build()) + + .WithVolume(new VolumeBuilder("3") + .WithChapter(new ChapterBuilder("31").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("32").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + await _context.SaveChangesAsync(); + + + var detail = await _seriesService.GetSeriesDetail(1, 1); + Assert.NotEmpty(detail.Specials); + + Assert.Equal("Volume 2", detail.Volumes.First().Name); + Assert.Equal("Volume 3", detail.Volumes.Last().Name); + + var chapters = detail.Chapters.ToArray(); + Assert.Equal("Issue #1 - Batman is Here", chapters[0].Title); + Assert.Equal("Issue #2-5 - Batman Left", chapters[1].Title); + Assert.Equal("Issue #5.5 - Batman is Back", chapters[2].Title); + + Assert.Equal("Omake", detail.Specials.First().Title); + Assert.Equal("Something", detail.Specials.Last().Title); + } + + /// + /// Validates that the Series Detail API returns Title names as expected for Book library type + /// + [Fact] + public async Task SeriesDetail_Book_ShouldReturnAppropriatelyNamedTitles() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithTitle("Omake").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 2).WithTitle("Something").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithSortOrder(1).WithTitle("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2-5").WithSortOrder(2).WithTitle("2-5").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("5.5").WithSortOrder(3).WithTitle("5.5").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithRange("Stone").WithPages(1).Build()) + .Build()) + + .WithVolume(new VolumeBuilder("3") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithRange("Paper").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + await _context.SaveChangesAsync(); + + + var detail = await _seriesService.GetSeriesDetail(1, 1); + Assert.NotEmpty(detail.Specials); + + Assert.Equal("2 - Stone", detail.Volumes.First().Name); + Assert.Equal("3 - Paper", detail.Volumes.Last().Name); + + var chapters = detail.StorylineChapters.ToArray(); + Assert.Equal("Book 1", chapters[0].Title); + Assert.Equal("Book 2-5", chapters[1].Title); + Assert.Equal("Book 5.5", chapters[2].Title); + + Assert.Equal("Omake", detail.Specials.First().Title); + Assert.Equal("Something", detail.Specials.Last().Title); + } + + /// + /// Validates that the Series Detail API returns Title names as expected for LightNovel library type + /// + [Fact] + public async Task SeriesDetail_LightNovel_ShouldReturnAppropriatelyNamedTitles() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.LightNovel) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("Omake").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithTitle("Omake").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("Something SP02").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 2).WithTitle("Something").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithSortOrder(1).WithTitle("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2-5").WithSortOrder(2).WithTitle("2-5").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("5.5").WithSortOrder(3).WithTitle("5.5").WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder("2") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithRange("Stone").WithPages(1).Build()) + .Build()) + + .WithVolume(new VolumeBuilder("3") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithRange("Paper").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + await _context.SaveChangesAsync(); + + + var detail = await _seriesService.GetSeriesDetail(1, 1); + Assert.NotEmpty(detail.Specials); + + Assert.Equal("2 - Stone", detail.Volumes.First().Name); + Assert.Equal("3 - Paper", detail.Volumes.Last().Name); + + var chapters = detail.StorylineChapters.ToArray(); + Assert.Equal("Book 1", chapters[0].Title); + Assert.Equal("Book 2-5", chapters[1].Title); + Assert.Equal("Book 5.5", chapters[2].Title); + + Assert.Equal("Omake", detail.Specials.First().Title); + Assert.Equal("Something", detail.Specials.Last().Title); + } + + + #endregion @@ -348,7 +600,7 @@ public class SeriesServiceTests : AbstractDbTest { await ResetDb(); - _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Manga) + _context.Library.Add(new LibraryBuilder("Test LIb") .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .WithSeries(new SeriesBuilder("Test") @@ -365,7 +617,7 @@ public class SeriesServiceTests : AbstractDbTest var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); JobStorage.Current = new InMemoryStorage(); - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto { SeriesId = 1, UserRating = 3, @@ -399,7 +651,7 @@ public class SeriesServiceTests : AbstractDbTest var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto { SeriesId = 1, UserRating = 3, @@ -415,7 +667,7 @@ public class SeriesServiceTests : AbstractDbTest // Update the DB again - var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto { SeriesId = 1, UserRating = 5, @@ -449,7 +701,7 @@ public class SeriesServiceTests : AbstractDbTest var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto { SeriesId = 1, UserRating = 10, @@ -484,7 +736,7 @@ public class SeriesServiceTests : AbstractDbTest var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings); - var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto() + var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto { SeriesId = 2, UserRating = 5, @@ -511,14 +763,14 @@ public class SeriesServiceTests : AbstractDbTest _context.Series.Add(s); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Genres = new List() {new GenreTagDto() {Id = 0, Title = "New Genre"}} + Genres = new List {new GenreTagDto {Id = 0, Title = "New Genre"}} }, - CollectionTags = new List() + }); Assert.True(success); @@ -527,46 +779,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] @@ -579,25 +791,26 @@ public class SeriesServiceTests : AbstractDbTest s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); var g = new GenreBuilder("Existing Genre").Build(); - s.Metadata.Genres = new List() {g}; + s.Metadata.Genres = new List {g}; _context.Series.Add(s); _context.Genre.Add(g); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Genres = new List() {new () {Id = 0, Title = "New Genre"}}, + Genres = new List {new () {Id = 0, Title = "New Genre"}}, }, - CollectionTags = new List() + }); Assert.True(success); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.Genres.Select(g1 => g1.Title).All(g2 => g2 == "New Genre".SentenceCase())); Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked @@ -607,32 +820,38 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople() { await ResetDb(); + var g = new PersonBuilder("Existing Person").Build(); + await _context.SaveChangesAsync(); + var s = new SeriesBuilder("Test") - .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(g, PersonRole.Publisher) + .Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - var g = new PersonBuilder("Existing Person", PersonRole.Publisher).Build(); + _context.Series.Add(s); _context.Person.Add(g); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Publishers = new List() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + Publishers = new List {new () {Id = 0, Name = "Existing Person"}}, }, - CollectionTags = new List() + }); Assert.True(success); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); - Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked } @@ -644,34 +863,96 @@ public class SeriesServiceTests : AbstractDbTest .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - var g = new PersonBuilder("Existing Person", PersonRole.Publisher).Build(); - s.Metadata.People = new List() {new PersonBuilder("Existing Writer", PersonRole.Writer).Build(), - new PersonBuilder("Existing Translator", PersonRole.Translator).Build(), new PersonBuilder("Existing Publisher 2", PersonRole.Publisher).Build()}; + var g = new PersonBuilder("Existing Person").Build(); + s.Metadata.People = new List + { + new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Writer").Build(), Role = PersonRole.Writer}, + new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Translator").Build(), Role = PersonRole.Translator}, + new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher} + }; + _context.Series.Add(s); _context.Person.Add(g); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Publishers = new List() {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + Publishers = new List {new () {Id = 0, Name = "Existing Person"}}, PublisherLocked = true }, - CollectionTags = new List() + }); Assert.True(success); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); - Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.True(series.Metadata.PublisherLocked); } + /// + /// I'm not sure how I could handle this use-case + /// + //[Fact] + public async Task UpdateSeriesMetadata_ShouldUpdate_ExistingPeople_NewName() + { + await ResetDb(); // Resets the database for a clean state + + // Arrange: Build series, metadata, and existing people + var series = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + series.Library = new LibraryBuilder("Test Library", LibraryType.Book).Build(); + + var existingPerson = new PersonBuilder("Existing Person").Build(); + var existingWriter = new PersonBuilder("ExistingWriter").Build(); // Pre-existing writer + + series.Metadata.People = new List + { + new SeriesMetadataPeople { Person = existingWriter, Role = PersonRole.Writer }, + new SeriesMetadataPeople { Person = new PersonBuilder("Existing Translator").Build(), Role = PersonRole.Translator }, + new SeriesMetadataPeople { Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher } + }; + + _context.Series.Add(series); + _context.Person.Add(existingPerson); + await _context.SaveChangesAsync(); + + // Act: Update series metadata, attempting to update the writer to "Existing Writer" + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = series.Id, // Use the series ID + Writers = new List { new() { Id = 0, Name = "Existing Writer" } }, // Trying to update writer's name + WriterLocked = true + } + }); + + // Assert: Ensure the operation was successful + Assert.True(success); + + // Reload the series from the database + var updatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id); + Assert.NotNull(updatedSeries.Metadata); + + // Assert that the people list still contains the updated person with the new name + var updatedPerson = updatedSeries.Metadata.People.FirstOrDefault(p => p.Role == PersonRole.Writer)?.Person; + Assert.NotNull(updatedPerson); // Make sure the person exists + Assert.Equal("Existing Writer", updatedPerson.Name); // Check if the person's name was updated + + // Assert that the publisher lock is still true + Assert.True(updatedSeries.Metadata.WriterLocked); + } + + [Fact] public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson() { @@ -680,29 +961,83 @@ public class SeriesServiceTests : AbstractDbTest .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - var g = new PersonBuilder("Existing Person", PersonRole.Publisher).Build(); + var g = new PersonBuilder("Existing Person").Build(); _context.Series.Add(s); _context.Person.Add(g); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Publishers = new List() {}, + Publishers = new List(), }, - CollectionTags = new List() + }); Assert.True(success); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.False(series.Metadata.People.Any()); } + /// + /// This emulates the UI operations wrt to locking + /// + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson_AfterAdding() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); + var g = new PersonBuilder("Existing Person").Build(); + _context.Series.Add(s); + + _context.Person.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = 1, + Publishers = new List() {new PersonDto() {Name = "Test"}}, + PublisherLocked = true + }, + + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.True(series.Metadata.People.Count != 0); + Assert.True(series.Metadata.PublisherLocked); + + + success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = 1, + Publishers = new List(), + PublisherLocked = false + }, + + }); + + Assert.True(success); + Assert.Empty(series.Metadata.People); + Assert.False(series.Metadata.PublisherLocked); + } + [Fact] public async Task UpdateSeriesMetadata_ShouldLockIfTold() { @@ -712,27 +1047,28 @@ public class SeriesServiceTests : AbstractDbTest .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); var g = new GenreBuilder("Existing Genre").Build(); - s.Metadata.Genres = new List() {g}; + s.Metadata.Genres = new List {g}; s.Metadata.GenresLocked = true; _context.Series.Add(s); _context.Genre.Add(g); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Genres = new List() {new () {Id = 1, Title = "Existing Genre"}}, + Genres = new List {new () {Id = 1, Title = "Existing Genre"}}, GenresLocked = true }, - CollectionTags = new List() + }); Assert.True(success); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.True(series.Metadata.Genres.Select(g => g.Title).All(g => g == "Existing Genre".SentenceCase())); Assert.True(series.Metadata.GenresLocked); @@ -749,19 +1085,20 @@ public class SeriesServiceTests : AbstractDbTest _context.Series.Add(s); await _context.SaveChangesAsync(); - var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto() + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto { - SeriesMetadata = new SeriesMetadataDto() + SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, ReleaseYear = 100, }, - CollectionTags = new List() + }); Assert.True(success); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(series); Assert.NotNull(series.Metadata); Assert.Equal(0, series.Metadata.ReleaseYear); Assert.False(series.Metadata.ReleaseYearLocked); @@ -769,6 +1106,205 @@ public class SeriesServiceTests : AbstractDbTest #endregion + #region UpdateGenres + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewGenre_NoExistingGenres() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .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 = s.Id, + Genres = new List {new () {Id = 0, Title = "New Genre"}}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); + Assert.False(series.Metadata.GenresLocked); // Ensure the lock is not activated unless specified. + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldReplaceExistingGenres() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var g = new GenreBuilder("Existing Genre").Build(); + s.Metadata.Genres = new List { g }; + + _context.Series.Add(s); + _context.Genre.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Genres = new List { new() { Id = 0, Title = "New Genre" }}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.DoesNotContain("Existing Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); + Assert.Contains("New Genre".SentenceCase(), series.Metadata.Genres.Select(g => g.Title)); + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveAllGenres() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var g = new GenreBuilder("Existing Genre").Build(); + s.Metadata.Genres = new List { g }; + + _context.Series.Add(s); + _context.Genre.Add(g); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Genres = new List(), // Removing all genres + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.Empty(series.Metadata.Genres); + } + + #endregion + + #region UpdateTags + [Fact] + public async Task UpdateSeriesMetadata_ShouldAddNewTag_NoExistingTags() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .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 = s.Id, + Tags = new List { new() { Id = 0, Title = "New Tag" }}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldReplaceExistingTags() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var t = new TagBuilder("Existing Tag").Build(); + s.Metadata.Tags = new List { t }; + + _context.Series.Add(s); + _context.Tag.Add(t); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Tags = new List { new() { Id = 0, Title = "New Tag" }}, + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.DoesNotContain("Existing Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); + Assert.Contains("New Tag".SentenceCase(), series.Metadata.Tags.Select(t => t.Title)); + } + + [Fact] + public async Task UpdateSeriesMetadata_ShouldRemoveAllTags() + { + await ResetDb(); + var s = new SeriesBuilder("Test") + .WithMetadata(new SeriesMetadataBuilder().Build()) + .Build(); + s.Library = new LibraryBuilder("Test Lib", LibraryType.Book).Build(); + + var t = new TagBuilder("Existing Tag").Build(); + s.Metadata.Tags = new List { t }; + + _context.Series.Add(s); + _context.Tag.Add(t); + await _context.SaveChangesAsync(); + + var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto + { + SeriesMetadata = new SeriesMetadataDto + { + SeriesId = s.Id, + Tags = new List(), // Removing all tags + }, + }); + + Assert.True(success); + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(s.Id); + Assert.NotNull(series); + Assert.NotNull(series.Metadata); + Assert.Empty(series.Metadata.Tags); + } + + #endregion + #region GetFirstChapterForMetadata private static Series CreateSeriesMock() @@ -776,10 +1312,12 @@ public class SeriesServiceTests : AbstractDbTest var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).WithFile(file).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).WithFile(file).Build()) - .WithChapter(new ChapterBuilder("A Special Case").WithIsSpecial(true).WithFile(file).WithPages(1).Build()) + .Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume) + .WithChapter(new ChapterBuilder("A Special Case").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).WithFile(file).WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") .WithChapter(new ChapterBuilder("1").WithPages(1).WithFile(file).Build()) @@ -808,11 +1346,11 @@ public class SeriesServiceTests : AbstractDbTest var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(1).WithFile(file).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(1).WithFile(file).Build()) .Build()) .WithVolume(new VolumeBuilder("1.5") - .WithChapter(new ChapterBuilder("0").WithPages(2).WithFile(file).Build()) + .WithChapter(new ChapterBuilder(Parser.DefaultChapter).WithPages(2).WithFile(file).Build()) .Build()) .Build(); series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); @@ -829,6 +1367,7 @@ public class SeriesServiceTests : AbstractDbTest var firstChapter = SeriesService.GetFirstChapterForMetadata(series); Assert.NotNull(firstChapter); + Assert.NotNull(firstChapter); Assert.Same("1", firstChapter.Range); } @@ -838,18 +1377,19 @@ public class SeriesServiceTests : AbstractDbTest var series = CreateSeriesMock(); var firstChapter = SeriesService.GetFirstChapterForMetadata(series); - Assert.Same("1", firstChapter.Range); + Assert.NotNull(firstChapter); + Assert.Equal(1, firstChapter.MinNumber); } [Fact] public void GetFirstChapterForMetadata_NonBook_ShouldReturnVolume1_WhenFirstChapterIsFloat() { var series = CreateSeriesMock(); - var files = new List() + var files = new List { new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build() }; - series.Volumes[1].Chapters = new List() + series.Volumes[2].Chapters = new List { new ChapterBuilder("2").WithFiles(files).WithPages(1).Build(), new ChapterBuilder("1.1").WithFiles(files).WithPages(1).Build(), @@ -857,7 +1397,8 @@ public class SeriesServiceTests : AbstractDbTest }; var firstChapter = SeriesService.GetFirstChapterForMetadata(series); - Assert.Same("1.1", firstChapter.Range); + Assert.NotNull(firstChapter); + Assert.True(firstChapter.MinNumber.Is(1.1f)); } [Fact] @@ -866,7 +1407,7 @@ public class SeriesServiceTests : AbstractDbTest var file = new MangaFileBuilder("Test.cbz", MangaFormat.Archive, 1).Build(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithPages(1).WithFile(file).Build()) .WithChapter(new ChapterBuilder("2").WithPages(1).WithFile(file).Build()) .Build()) @@ -882,28 +1423,29 @@ public class SeriesServiceTests : AbstractDbTest series.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); var firstChapter = SeriesService.GetFirstChapterForMetadata(series); - Assert.Same("1", firstChapter.Range); + Assert.NotNull(firstChapter); + Assert.Equal(1, firstChapter.MinNumber); } #endregion - #region SeriesRelation + #region Series Relation [Fact] public async Task UpdateRelatedSeries_ShouldAddAllRelations() { await ResetDb(); - _context.Library.Add(new Library() + _context.Library.Add(new Library { - AppUsers = new List() + AppUsers = new List { - new AppUser() + new AppUser { UserName = "majora2007" } }, Name = "Test LIb", Type = LibraryType.Book, - Series = new List() + Series = new List { new SeriesBuilder("Test Series").Build(), new SeriesBuilder("Test Series Prequels").Build(), @@ -919,26 +1461,63 @@ public class SeriesServiceTests : AbstractDbTest addRelationDto.Adaptations.Add(2); addRelationDto.Sequels.Add(3); await _seriesService.UpdateRelatedSeries(addRelationDto); + Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); Assert.Equal(3, series1.Relations.Single(s => s.TargetSeriesId == 3).TargetSeriesId); } + [Fact] + public async Task UpdateRelatedSeries_ShouldAddPrequelWhenAddingSequel() + { + await ResetDb(); + _context.Library.Add(new Library + { + AppUsers = new List + { + new AppUser + { + UserName = "majora2007" + } + }, + Name = "Test LIb", + Type = LibraryType.Book, + Series = new List + { + new SeriesBuilder("Test Series").Build(), + new SeriesBuilder("Test Series Prequels").Build(), + } + }); + + await _context.SaveChangesAsync(); + + var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); + var series2 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related); + // Add relations + var addRelationDto = CreateRelationsDto(series1); + addRelationDto.Sequels.Add(2); + await _seriesService.UpdateRelatedSeries(addRelationDto); + Assert.NotNull(series1); + Assert.NotNull(series2); + Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); + Assert.Equal(1, series2.Relations.Single(s => s.TargetSeriesId == 1).TargetSeriesId); + } + [Fact] public async Task UpdateRelatedSeries_DeleteAllRelations() { await ResetDb(); - _context.Library.Add(new Library() + _context.Library.Add(new Library { - AppUsers = new List() + AppUsers = new List { - new AppUser() + new AppUser { UserName = "majora2007" } }, Name = "Test LIb", Type = LibraryType.Book, - Series = new List() + Series = new List { new SeriesBuilder("Test Series").Build(), new SeriesBuilder("Test Series Prequels").Build(), @@ -954,14 +1533,16 @@ public class SeriesServiceTests : AbstractDbTest addRelationDto.Adaptations.Add(2); addRelationDto.Sequels.Add(3); await _seriesService.UpdateRelatedSeries(addRelationDto); + Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); Assert.Equal(3, series1.Relations.Single(s => s.TargetSeriesId == 3).TargetSeriesId); // Remove relations var removeRelationDto = CreateRelationsDto(series1); await _seriesService.UpdateRelatedSeries(removeRelationDto); - Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 1)); - Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 2)); + Assert.NotNull(series1); + Assert.DoesNotContain(series1.Relations, s => s.TargetSeriesId == 1); + Assert.DoesNotContain(series1.Relations, s => s.TargetSeriesId == 2); } @@ -969,18 +1550,18 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_DeleteTargetSeries_ShouldSucceed() { await ResetDb(); - _context.Library.Add(new Library() + _context.Library.Add(new Library { - AppUsers = new List() + AppUsers = new List { - new AppUser() + new AppUser { UserName = "majora2007" } }, Name = "Test LIb", Type = LibraryType.Book, - Series = new List() + Series = new List { new SeriesBuilder("Series A").Build(), new SeriesBuilder("Series B").Build(), @@ -994,6 +1575,8 @@ public class SeriesServiceTests : AbstractDbTest var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); await _seriesService.UpdateRelatedSeries(addRelationDto); + + Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); _context.Series.Remove(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2)); @@ -1014,18 +1597,18 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_DeleteSourceSeries_ShouldSucceed() { await ResetDb(); - _context.Library.Add(new Library() + _context.Library.Add(new Library { - AppUsers = new List() + AppUsers = new List { - new AppUser() + new AppUser { UserName = "majora2007" } }, Name = "Test LIb", Type = LibraryType.Book, - Series = new List() + Series = new List { new SeriesBuilder("Series A").Build(), new SeriesBuilder("Series B").Build(), @@ -1039,9 +1622,12 @@ public class SeriesServiceTests : AbstractDbTest var addRelationDto = CreateRelationsDto(series1); addRelationDto.Adaptations.Add(2); await _seriesService.UpdateRelatedSeries(addRelationDto); + Assert.NotNull(series1); Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId); - _context.Series.Remove(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1)); + var seriesToRemove = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); + Assert.NotNull(seriesToRemove); + _context.Series.Remove(seriesToRemove); try { await _context.SaveChangesAsync(); @@ -1059,18 +1645,18 @@ public class SeriesServiceTests : AbstractDbTest public async Task UpdateRelatedSeries_ShouldNotAllowDuplicates() { await ResetDb(); - _context.Library.Add(new Library() + _context.Library.Add(new Library { - AppUsers = new List() + AppUsers = new List { - new AppUser() + new AppUser { UserName = "majora2007" } }, Name = "Test LIb", Type = LibraryType.Book, - Series = new List() + Series = new List { new SeriesBuilder("Test Series").Build(), new SeriesBuilder("Test Series Prequels").Build(), @@ -1080,7 +1666,7 @@ public class SeriesServiceTests : AbstractDbTest await _context.SaveChangesAsync(); var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related); - var relation = new SeriesRelation() + var relation = new SeriesRelation { Series = series1, SeriesId = series1.Id, @@ -1104,18 +1690,18 @@ public class SeriesServiceTests : AbstractDbTest public async Task GetRelatedSeries_EditionPrequelSequel_ShouldNotHaveParent() { await ResetDb(); - _context.Library.Add(new Library() + _context.Library.Add(new Library { - AppUsers = new List() + AppUsers = new List { - new AppUser() + new AppUser { UserName = "majora2007" } }, Name = "Test LIb", Type = LibraryType.Book, - Series = new List() + Series = new List { new SeriesBuilder("Test Series").Build(), new SeriesBuilder("Test Series Editions").Build(), @@ -1183,7 +1769,7 @@ public class SeriesServiceTests : AbstractDbTest await ResetDb(); var lib1 = new LibraryBuilder("Test LIb") .WithSeries(new SeriesBuilder("Test Series") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithFile( new MangaFileBuilder($"{DataDirectory}1.zip", MangaFormat.Archive) .WithPages(1) @@ -1200,7 +1786,7 @@ public class SeriesServiceTests : AbstractDbTest var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book) .WithSeries(new SeriesBuilder("Test Series 2").Build()) .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build()) - .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build())// TODO: Is this a bug + .WithSeries(new SeriesBuilder("Test Series Prequels 3").Build())// TODO: Is this a bug .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); _context.Library.Add(lib2); @@ -1260,138 +1846,139 @@ public class SeriesServiceTests : AbstractDbTest #endregion - #region FormatChapterTitle - - [Fact] - public async Task FormatChapterTitle_Manga_NonSpecial() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Chapter Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Manga, false)); - } - - [Fact] - public async Task FormatChapterTitle_Manga_Special() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Manga, false)); - } - - [Fact] - public async Task FormatChapterTitle_Comic_NonSpecial_WithoutHash() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Issue Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, false)); - } - - [Fact] - public async Task FormatChapterTitle_Comic_Special_WithoutHash() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, false)); - } - - [Fact] - public async Task FormatChapterTitle_Comic_NonSpecial_WithHash() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Issue #Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, true)); - } - - [Fact] - public async Task FormatChapterTitle_Comic_Special_WithHash() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, true)); - } - - [Fact] - public async Task FormatChapterTitle_Book_NonSpecial() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); - Assert.Equal("Book Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Book, false)); - } - - [Fact] - public async Task FormatChapterTitle_Book_Special() - { - await ResetDb(); - - _context.Library.Add(new LibraryBuilder("Test LIb") - .WithAppUser(new AppUserBuilder("majora2007", string.Empty) - .WithLocale("en") - .Build()) - .Build()); - - await _context.SaveChangesAsync(); - var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).Build(); - Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Book, false)); - } - - #endregion + // This is now handled in SeriesDetail Tests + // #region FormatChapterTitle + // + // [Fact] + // public async Task FormatChapterTitle_Manga_NonSpecial() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); + // Assert.Equal("Chapter Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Manga, false)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Manga_Special() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).Build(); + // Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Manga, false)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Comic_NonSpecial_WithoutHash() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); + // Assert.Equal("Issue Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, false)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Comic_Special_WithoutHash() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).Build(); + // Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic, false)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Comic_NonSpecial_WithHash() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); + // Assert.Equal("Issue #Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Comic_Special_WithHash() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).Build(); + // Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Comic)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Book_NonSpecial() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(false).Build(); + // Assert.Equal("Book Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Book, false)); + // } + // + // [Fact] + // public async Task FormatChapterTitle_Book_Special() + // { + // await ResetDb(); + // + // _context.Library.Add(new LibraryBuilder("Test LIb") + // .WithAppUser(new AppUserBuilder("majora2007", string.Empty) + // .WithLocale("en") + // .Build()) + // .Build()); + // + // await _context.SaveChangesAsync(); + // var chapter = new ChapterBuilder("1").WithTitle("Some title").WithIsSpecial(true).WithSortOrder(Parser.SpecialVolumeNumber + 1).Build(); + // Assert.Equal("Some title", await _seriesService.FormatChapterTitle(1, chapter, LibraryType.Book, false)); + // } + // + // #endregion #region DeleteMultipleSeries @@ -1401,11 +1988,11 @@ public class SeriesServiceTests : AbstractDbTest await ResetDb(); var lib1 = new LibraryBuilder("Test LIb") .WithSeries(new SeriesBuilder("Test Series") - .WithMetadata(new SeriesMetadata() + .WithMetadata(new SeriesMetadata { AgeRating = AgeRating.Everyone }) - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("1").WithFile( new MangaFileBuilder($"{DataDirectory}1.zip", MangaFormat.Archive) .WithPages(1) @@ -1422,7 +2009,6 @@ public class SeriesServiceTests : AbstractDbTest var lib2 = new LibraryBuilder("Test LIb 2", LibraryType.Book) .WithSeries(new SeriesBuilder("Test Series 2").Build()) .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build()) - .WithSeries(new SeriesBuilder("Test Series Prequels 2").Build())// TODO: Is this a bug .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) .Build(); _context.Library.Add(lib2); @@ -1439,25 +2025,25 @@ public class SeriesServiceTests : AbstractDbTest // Setup External Metadata stuff series1.ExternalSeriesMetadata ??= new ExternalSeriesMetadata(); - series1.ExternalSeriesMetadata.ExternalRatings = new List() + series1.ExternalSeriesMetadata.ExternalRatings = new List { - new ExternalRating() + new ExternalRating { SeriesId = 1, Provider = ScrobbleProvider.Mal, AverageScore = 1 } }; - series1.ExternalSeriesMetadata.ExternalRecommendations = new List() + series1.ExternalSeriesMetadata.ExternalRecommendations = new List { - new ExternalRecommendation() + new ExternalRecommendation { SeriesId = 2, Name = "Series 2", Url = "", CoverUrl = "" }, - new ExternalRecommendation() + new ExternalRecommendation { SeriesId = 0, // Causes a FK constraint Name = "Series 2", @@ -1465,9 +2051,9 @@ public class SeriesServiceTests : AbstractDbTest CoverUrl = "" } }; - series1.ExternalSeriesMetadata.ExternalReviews = new List() + series1.ExternalSeriesMetadata.ExternalReviews = new List { - new ExternalReview() + new ExternalReview { Body = "", Provider = ScrobbleProvider.Mal, @@ -1484,4 +2070,117 @@ public class SeriesServiceTests : AbstractDbTest } #endregion + + #region GetEstimatedChapterCreationDate + + [Fact] + public async Task GetEstimatedChapterCreationDate_NoNextChapter_InvalidType() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await _context.SaveChangesAsync(); + + var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); + Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); + Assert.Equal(0, nextChapter.ChapterNumber); + } + + [Fact] + public async Task GetEstimatedChapterCreationDate_NoNextChapter_InvalidPublicationStatus() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + .WithPublicationStatus(PublicationStatus.Completed) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await _context.SaveChangesAsync(); + + var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); + Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); + Assert.Equal(0, nextChapter.ChapterNumber); + } + + [Fact] + public async Task GetEstimatedChapterCreationDate_NoNextChapter_Only2Chapters() + { + await ResetDb(); + + _context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book) + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2").WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await _context.SaveChangesAsync(); + + var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); + Assert.NotNull(nextChapter); + Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); + Assert.Equal(0, nextChapter.ChapterNumber); + } + + [Fact] + public async Task GetEstimatedChapterCreationDate_NextChapter_ChaptersMonthApart() + { + await ResetDb(); + var now = DateTime.Parse("2021-01-01", CultureInfo.InvariantCulture); // 10/31/2024 can trigger an edge case bug + + _context.Library.Add(new LibraryBuilder("Test LIb") + .WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build()) + .WithSeries(new SeriesBuilder("Test") + .WithPublicationStatus(PublicationStatus.OnGoing) + .WithVolume(new VolumeBuilder(Parser.LooseLeafVolume) + .WithChapter(new ChapterBuilder("1").WithCreated(now).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("2").WithCreated(now.AddMonths(1)).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("3").WithCreated(now.AddMonths(2)).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("4").WithCreated(now.AddMonths(3)).WithPages(1).Build()) + .Build()) + .Build()) + .Build()); + + + await _context.SaveChangesAsync(); + + var nextChapter = await _seriesService.GetEstimatedChapterCreationDate(1, 1); + Assert.NotNull(nextChapter); + Assert.Equal(Parser.LooseLeafVolumeNumber, nextChapter.VolumeNumber); + Assert.Equal(5, nextChapter.ChapterNumber); + Assert.NotNull(nextChapter.ExpectedDate); + + var expected = now.AddMonths(4); + Assert.Equal(expected.Month, nextChapter.ExpectedDate.Value.Month); + Assert.True(nextChapter.ExpectedDate.Value.Day >= expected.Day - 1 || nextChapter.ExpectedDate.Value.Day <= expected.Day + 1); + } + + #endregion + } diff --git a/API.Tests/Services/SettingsServiceTests.cs b/API.Tests/Services/SettingsServiceTests.cs new file mode 100644 index 000000000..a3c6b67b8 --- /dev/null +++ b/API.Tests/Services/SettingsServiceTests.cs @@ -0,0 +1,292 @@ +using System.Collections.Generic; +using System.IO.Abstractions; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.Metadata; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using API.Services; +using API.Services.Tasks.Scanner; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class SettingsServiceTests +{ + private readonly ISettingsService _settingsService; + private readonly IUnitOfWork _mockUnitOfWork; + + public SettingsServiceTests() + { + var ds = new DirectoryService(Substitute.For>(), new FileSystem()); + + _mockUnitOfWork = Substitute.For(); + _settingsService = new SettingsService(_mockUnitOfWork, ds, + Substitute.For(), Substitute.For(), + Substitute.For>()); + } + + #region UpdateMetadataSettings + + [Fact] + public async Task UpdateMetadataSettings_ShouldUpdateExistingSettings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Enabled = false, + EnableSummary = false, + EnableLocalizedName = false, + EnablePublicationStatus = false, + EnableRelationships = false, + EnablePeople = false, + EnableStartDate = false, + EnableGenres = false, + EnableTags = false, + FirstLastPeopleNaming = false, + EnableCoverImage = false, + AgeRatingMappings = new Dictionary(), + Blacklist = [], + Whitelist = [], + Overrides = [], + PersonRoles = [], + FieldMappings = [] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + Enabled = true, + EnableSummary = true, + EnableLocalizedName = true, + EnablePublicationStatus = true, + EnableRelationships = true, + EnablePeople = true, + EnableStartDate = true, + EnableGenres = true, + EnableTags = true, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + AgeRatingMappings = new Dictionary { { "Adult", AgeRating.R18Plus } }, + Blacklist = ["blacklisted-tag"], + Whitelist = ["whitelisted-tag"], + Overrides = [MetadataSettingField.Summary], + PersonRoles = [PersonRole.Writer], + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Tag, + SourceValue = "Action", + DestinationValue = "Fight", + ExcludeFromSource = true + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify properties were updated + Assert.True(existingSettings.Enabled); + Assert.True(existingSettings.EnableSummary); + Assert.True(existingSettings.EnableLocalizedName); + Assert.True(existingSettings.EnablePublicationStatus); + Assert.True(existingSettings.EnableRelationships); + Assert.True(existingSettings.EnablePeople); + Assert.True(existingSettings.EnableStartDate); + Assert.True(existingSettings.EnableGenres); + Assert.True(existingSettings.EnableTags); + Assert.True(existingSettings.FirstLastPeopleNaming); + Assert.True(existingSettings.EnableCoverImage); + + // Verify collections were updated + Assert.Single(existingSettings.AgeRatingMappings); + Assert.Equal(AgeRating.R18Plus, existingSettings.AgeRatingMappings["Adult"]); + + Assert.Single(existingSettings.Blacklist); + Assert.Equal("blacklisted-tag", existingSettings.Blacklist[0]); + + Assert.Single(existingSettings.Whitelist); + Assert.Equal("whitelisted-tag", existingSettings.Whitelist[0]); + + Assert.Single(existingSettings.Overrides); + Assert.Equal(MetadataSettingField.Summary, existingSettings.Overrides[0]); + + Assert.Single(existingSettings.PersonRoles); + Assert.Equal(PersonRole.Writer, existingSettings.PersonRoles[0]); + + Assert.Single(existingSettings.FieldMappings); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("Action", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("Fight", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithNullCollections_ShouldUseEmptyCollections() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = [new MetadataFieldMapping {Id = 1, SourceValue = "OldValue"}] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + AgeRatingMappings = null, + Blacklist = null, + Whitelist = null, + Overrides = null, + PersonRoles = null, + FieldMappings = null + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Empty(existingSettings.AgeRatingMappings); + Assert.Empty(existingSettings.Blacklist); + Assert.Empty(existingSettings.Whitelist); + Assert.Empty(existingSettings.Overrides); + Assert.Empty(existingSettings.PersonRoles); + + // Verify existing field mappings were cleared + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Empty(existingSettings.FieldMappings); + } + + [Fact] + public async Task UpdateMetadataSettings_WithFieldMappings_ShouldReplaceExistingMappings() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + FieldMappings = + [ + new MetadataFieldMapping + { + Id = 1, + SourceType = MetadataFieldType.Genre, + DestinationType = MetadataFieldType.Genre, + SourceValue = "OldValue", + DestinationValue = "OldDestination", + ExcludeFromSource = false + } + ] + }; + + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + FieldMappings = + [ + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Genre, + SourceValue = "NewValue", + DestinationValue = "NewDestination", + ExcludeFromSource = true + }, + + new MetadataFieldMappingDto + { + SourceType = MetadataFieldType.Tag, + DestinationType = MetadataFieldType.Tag, + SourceValue = "AnotherValue", + DestinationValue = "AnotherDestination", + ExcludeFromSource = false + } + ] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + // Verify existing field mappings were cleared and new ones added + settingsRepo.Received(1).RemoveRange(Arg.Any>()); + Assert.Equal(2, existingSettings.FieldMappings.Count); + + // Verify first mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[0].SourceType); + Assert.Equal(MetadataFieldType.Genre, existingSettings.FieldMappings[0].DestinationType); + Assert.Equal("NewValue", existingSettings.FieldMappings[0].SourceValue); + Assert.Equal("NewDestination", existingSettings.FieldMappings[0].DestinationValue); + Assert.True(existingSettings.FieldMappings[0].ExcludeFromSource); + + // Verify second mapping + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].SourceType); + Assert.Equal(MetadataFieldType.Tag, existingSettings.FieldMappings[1].DestinationType); + Assert.Equal("AnotherValue", existingSettings.FieldMappings[1].SourceValue); + Assert.Equal("AnotherDestination", existingSettings.FieldMappings[1].DestinationValue); + Assert.False(existingSettings.FieldMappings[1].ExcludeFromSource); + } + + [Fact] + public async Task UpdateMetadataSettings_WithBlacklistWhitelist_ShouldNormalizeAndDeduplicateEntries() + { + // Arrange + var existingSettings = new MetadataSettings + { + Id = 1, + Blacklist = [], + Whitelist = [] + }; + + // We need to mock the repository and provide a custom implementation for ToNormalized + var settingsRepo = Substitute.For(); + settingsRepo.GetMetadataSettings().Returns(Task.FromResult(existingSettings)); + settingsRepo.GetMetadataSettingDto().Returns(Task.FromResult(new MetadataSettingsDto())); + _mockUnitOfWork.SettingsRepository.Returns(settingsRepo); + + var updateDto = new MetadataSettingsDto + { + // Include duplicates with different casing and whitespace + Blacklist = ["tag1", "Tag1", " tag2 ", "", " ", "tag3"], + Whitelist = ["allowed1", "Allowed1", " allowed2 ", "", "allowed3"] + }; + + // Act + await _settingsService.UpdateMetadataSettings(updateDto); + + // Assert + await _mockUnitOfWork.Received(1).CommitAsync(); + + Assert.Equal(3, existingSettings.Blacklist.Count); + Assert.Equal(3, existingSettings.Whitelist.Count); + } + + #endregion +} diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index 0ff6681dd..463d49df4 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -9,6 +9,7 @@ using API.Services; using API.Services.Tasks; using API.SignalR; using Kavita.Common; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -44,13 +45,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + Substitute.For>(), Substitute.For()); _context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), - Provider = ThemeProvider.User, + Provider = ThemeProvider.Custom, FileName = "custom.css", IsDefault = false }); @@ -61,63 +63,6 @@ public abstract class SiteThemeServiceTest : AbstractDbTest } - [Fact] - public async Task Scan_ShouldFindCustomFile() - { - await ResetDb(); - _testOutputHelper.WriteLine($"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); - await siteThemeService.Scan(); - - Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); - } - - [Fact] - public async Task Scan_ShouldOnlyInsertOnceOnSecondScan() - { - await ResetDb(); - _testOutputHelper.WriteLine( - $"[Scan_ShouldOnlyInsertOnceOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); - await siteThemeService.Scan(); - - Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); - - await siteThemeService.Scan(); - - var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t => - t.Name.ToNormalized().Equals("custom".ToNormalized())); - - Assert.Single(customThemes); - } - - [Fact] - public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan() - { - await ResetDb(); - _testOutputHelper.WriteLine($"[Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan] All Themes: {(await _unitOfWork.SiteThemeRepository.GetThemes()).Count(t => t.IsDefault)}"); - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); - var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); - await siteThemeService.Scan(); - - Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); - - filesystem.RemoveFile($"{SiteThemeDirectory}custom.css"); - await siteThemeService.Scan(); - - var themes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()); - - Assert.Equal(0, themes.Count(t => - t.Name.ToNormalized().Equals("custom".ToNormalized()))); - } [Fact] public async Task GetContent_ShouldReturnContent() @@ -127,13 +72,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + Substitute.For>(), Substitute.For()); _context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), - Provider = ThemeProvider.User, + Provider = ThemeProvider.Custom, FileName = "custom.css", IsDefault = false }); @@ -153,13 +99,14 @@ public abstract class SiteThemeServiceTest : AbstractDbTest var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub, Substitute.For(), + Substitute.For>(), Substitute.For()); _context.SiteTheme.Add(new SiteTheme() { Name = "Custom", NormalizedName = "Custom".ToNormalized(), - Provider = ThemeProvider.User, + Provider = ThemeProvider.Custom, FileName = "custom.css", IsDefault = false }); diff --git a/API.Tests/Services/TachiyomiServiceTests.cs b/API.Tests/Services/TachiyomiServiceTests.cs index c94ff1c48..17e26139c 100644 --- a/API.Tests/Services/TachiyomiServiceTests.cs +++ b/API.Tests/Services/TachiyomiServiceTests.cs @@ -1,7 +1,5 @@ -using API.Extensions; -using API.Helpers.Builders; +using API.Helpers.Builders; using API.Services.Plus; -using API.Services.Tasks; namespace API.Tests.Services; using System.Collections.Generic; @@ -16,7 +14,6 @@ using API.Entities.Enums; using API.Helpers; using API.Services; using SignalR; -using Helpers; using AutoMapper; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; @@ -52,7 +49,7 @@ public class TachiyomiServiceTests Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For()); - _tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), _readerService); + _tachiyomiService = new TachiyomiService(_unitOfWork, _mapper, Substitute.For>(), _readerService); } @@ -125,12 +122,12 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) @@ -170,12 +167,12 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) @@ -221,7 +218,7 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) @@ -265,18 +262,19 @@ public class TachiyomiServiceTests Assert.Equal("21", latestChapter.Number); } + [Fact] public async Task GetLatestChapter_ShouldReturnEncodedVolume_Progress() { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) @@ -323,13 +321,16 @@ public class TachiyomiServiceTests var series = new SeriesBuilder("Test") .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("0").WithPages(199).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithPages(199).Build()) .Build()) .WithVolume(new VolumeBuilder("2") - .WithChapter(new ChapterBuilder("0").WithPages(192).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithPages(192).Build()) .Build()) .WithVolume(new VolumeBuilder("3") - .WithChapter(new ChapterBuilder("0").WithPages(255).Build()) + .WithChapter(new ChapterBuilder(API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter) + .WithPages(255).Build()) .Build()) .WithPages(646) .Build(); @@ -368,7 +369,7 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) @@ -421,12 +422,12 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) @@ -464,12 +465,12 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("3").WithPages(1).Build()) @@ -514,7 +515,7 @@ public class TachiyomiServiceTests await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) @@ -562,12 +563,12 @@ public class TachiyomiServiceTests { await ResetDb(); var series = new SeriesBuilder("Test") - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(new ChapterBuilder("95").WithPages(1).Build()) .WithChapter(new ChapterBuilder("96").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("1") - .WithChapter(new ChapterBuilder("1").WithIsSpecial(true).WithPages(1).Build()) + .WithChapter(new ChapterBuilder("1").WithPages(1).Build()) .Build()) .WithVolume(new VolumeBuilder("2") .WithChapter(new ChapterBuilder("21").WithPages(1).Build()) diff --git a/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf b/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf new file mode 100644 index 000000000..9fe4811a7 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Bizet-Variations_Chromatiques_de_concert_Theme_A4.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf b/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf new file mode 100644 index 000000000..0e0ffa8c7 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Rollo at Work SP01.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/encrypted.pdf b/API.Tests/Services/Test Data/BookService/encrypted.pdf new file mode 100644 index 000000000..64249b728 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/encrypted.pdf differ diff --git a/API.Tests/Services/Test Data/BookService/indirect.pdf b/API.Tests/Services/Test Data/BookService/indirect.pdf new file mode 100644 index 000000000..11ecdcb76 Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/indirect.pdf differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/blue-2.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue-2.png new file mode 100644 index 000000000..8a386b2b8 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/blue.jpg b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue.jpg new file mode 100644 index 000000000..ed53f6649 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/blue.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/green-red.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/green-red.png new file mode 100644 index 000000000..5b3d45386 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/green-red.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/green.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/green.png new file mode 100644 index 000000000..8ed6c4fe4 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/green.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue-2.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue-2.png new file mode 100644 index 000000000..68b71ce39 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue.png new file mode 100644 index 000000000..9569f80d4 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/lightblue.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/pink.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/pink.png new file mode 100644 index 000000000..a62988963 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/pink.png differ diff --git a/API.Tests/Services/Test Data/ImageService/ColorScapes/yellow-blue.png b/API.Tests/Services/Test Data/ImageService/ColorScapes/yellow-blue.png new file mode 100644 index 000000000..0a4f36f30 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/ColorScapes/yellow-blue.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg new file mode 100644 index 000000000..b185d6e41 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-2.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg new file mode 100644 index 000000000..99aafb10a Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal-3.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg new file mode 100644 index 000000000..91a8f9b8e Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-normal.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg new file mode 100644 index 000000000..6ee3931b3 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-square.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg b/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg new file mode 100644 index 000000000..3442c8b32 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/comic-wide.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png b/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png new file mode 100644 index 000000000..eae5138c6 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/manga-cover.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg b/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg new file mode 100644 index 000000000..449400181 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/spread-cover.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png new file mode 100644 index 000000000..e89641384 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip-2.png differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg new file mode 100644 index 000000000..469cb9bc3 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/webtoon-strip.jpg differ diff --git a/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png b/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png new file mode 100644 index 000000000..2ad5103fe Binary files /dev/null and b/API.Tests/Services/Test Data/ImageService/Covers/wide-ad.png differ diff --git a/API.Tests/Services/Test Data/ReadingListService/Annual.cbl b/API.Tests/Services/Test Data/ReadingListService/Annual.cbl new file mode 100644 index 000000000..a6dd3167e --- /dev/null +++ b/API.Tests/Services/Test Data/ReadingListService/Annual.cbl @@ -0,0 +1,19 @@ + + + Fables + + + 5bd3dd55-2a85-4325-aefa-21e9f19b12c9 + + + 3831761c-604a-4420-bed2-9f5ac4e94bd4 + + + 23acefd4-1bc7-4c3c-99df-133045d1f266 + + + 27a5d7db-9f7e-4be1-aca6-998a1cc1488f + + + + diff --git a/API.Tests/Services/Test Data/ScannerService/1x1.png b/API.Tests/Services/Test Data/ScannerService/1x1.png new file mode 100644 index 000000000..94381b429 Binary files /dev/null and b/API.Tests/Services/Test Data/ScannerService/1x1.png differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Books/PDFs/Rollo at Work SP01.pdf b/API.Tests/Services/Test Data/ScannerService/Library/Books/PDFs/Rollo at Work SP01.pdf deleted file mode 100644 index 35983f4e0..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Books/PDFs/Rollo at Work SP01.pdf and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Books/The Golden Harpoon/The Golden Harpoon.epub b/API.Tests/Services/Test Data/ScannerService/Library/Books/The Golden Harpoon/The Golden Harpoon.epub deleted file mode 100644 index 7388bc85e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Books/The Golden Harpoon/The Golden Harpoon.epub and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Books/Vertical Reading/01.epub b/API.Tests/Services/Test Data/ScannerService/Library/Books/Vertical Reading/01.epub deleted file mode 100644 index 2850eed96..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Books/Vertical Reading/01.epub and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 1.cbz b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 1.cbz deleted file mode 100644 index d1eb76880..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 1.cbz and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 2.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 2.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 1 Chapter 2.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 2 Chapter 3.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 2 Chapter 3.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/Accel World Vol 2 Chapter 3.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/ComicInfo.xml b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/ComicInfo.xml deleted file mode 100644 index 6bc41f434..000000000 --- a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Accel World/ComicInfo.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - Accel World - 2 - \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/ComicInfo.xml b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/ComicInfo.xml deleted file mode 100644 index d0494448f..000000000 --- a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/ComicInfo.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - Hajime no Ippo - 3 - M - \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 1.cbz b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 1.cbz deleted file mode 100644 index 895cfc415..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 1.cbz and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 2.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 2.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 2.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 3.zip b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 3.zip deleted file mode 100644 index 40ebeb13e..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Hajime no Ippo/Hajime no Ippo Chapter 3.zip and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/001.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/001.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/001.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/002.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/002.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/002.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/003.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/003.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/003.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/004.jpg b/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/004.jpg deleted file mode 100644 index b5c6de2aa..000000000 Binary files a/API.Tests/Services/Test Data/ScannerService/Library/Manga/Pumpkin Night (images)/Chapter 01/004.jpg and /dev/null differ diff --git a/API.Tests/Services/Test Data/ScannerService/Library/README.md b/API.Tests/Services/Test Data/ScannerService/Library/README.md deleted file mode 100644 index 2969111b4..000000000 --- a/API.Tests/Services/Test Data/ScannerService/Library/README.md +++ /dev/null @@ -1 +0,0 @@ -This is an example of a layout. All files in here have non-copyrighted data but emulate real series to ensure the Process series Works as expected. \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v04.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v04.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v05.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v05.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v06.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v06.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v07.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v07.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v10.cbz b/API.Tests/Services/Test Data/ScannerService/Manga/BTOOOM!/Btooom! v10.cbz deleted file mode 100644 index e69de29bb..000000000 diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Alternating Removal - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json new file mode 100644 index 000000000..791dcdc44 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Delete Series In UI - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json new file mode 100644 index 000000000..fe931174e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Exclude Pattern 1 - Manga.json @@ -0,0 +1,5 @@ +[ + "Antarctic Press/Plush/Plush v01.cbz", + "Antarctic Press/Plush/Plush v02.cbz", + "Antarctic Press/Plush/Extra/Plush v03.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series - Manga.json new file mode 100644 index 000000000..6b4b70160 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series - Manga.json @@ -0,0 +1,5 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder - Manga.json new file mode 100644 index 000000000..12e80ea95 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/Specials/Official Anime Fanbook SP05 (2024) (Digital).cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json new file mode 100644 index 000000000..06ed0f1f9 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/Specials/My Dress-Up Darling - Omakes SP01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json new file mode 100644 index 000000000..3fa9eebf7 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json @@ -0,0 +1,5 @@ +[ + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json new file mode 100644 index 000000000..d283a3460 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder (Non English) - Image.json @@ -0,0 +1,5 @@ +[ + "葬送のフィリーレン/葬送のフィリーレン vol 1/0001.png", + "葬送のフィリーレン/葬送のフィリーレン vol 2/0002.png", + "葬送のフィリーレン/Specials/葬送のフリーレン 公式ファンブック SP01/0001.png" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json new file mode 100644 index 000000000..62106703c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Image Series with SP Folder - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling vol 1/0001.png", + "My Dress-Up Darling/My Dress-Up Darling vol 1/0002.png", + "My Dress-Up Darling/My Dress-Up Darling vol 2/0001.png", + "My Dress-Up Darling/Specials/My Dress-Up Darling SP01/0001.png" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json new file mode 100644 index 000000000..feb1fd99f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Localized Name matches Filename - Manga.json @@ -0,0 +1,3 @@ +[ + "Immoral Guild/Futoku no Guild v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json new file mode 100644 index 000000000..c9d4b14b6 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Multiple Roots - Manga.json @@ -0,0 +1,5 @@ +[ + "Root 1/Antarctic Press/Plush/Plush v01.cbz", + "Root 1/Antarctic Press/Plush/Plush v02.cbz", + "Root 2/Accel/Accel v01.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Nested Chapters - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Nested Chapters - Manga.json new file mode 100644 index 000000000..586ae90f5 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Nested Chapters - Manga.json @@ -0,0 +1,4 @@ +[ + "My Dress-Up Darling/Chapter 1/01.cbz", + "My Dress-Up Darling/Chapter 2/02.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json new file mode 100644 index 000000000..f5097b369 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - Comic.json @@ -0,0 +1,6 @@ +[ + "Dreadwolf/Dreadwolf Chapter 1-12.pdf", + "Dreadwolf/Dreadwolf Chapter 13-24.pdf", + "Dreadwolf/Dreadwolf Chapter 25.pdf", + "Dreadwolf/Dreadwolf Chapter 26.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json new file mode 100644 index 000000000..f5097b369 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/PDF Comic Chapters - LightNovel.json @@ -0,0 +1,6 @@ +[ + "Dreadwolf/Dreadwolf Chapter 1-12.pdf", + "Dreadwolf/Dreadwolf Chapter 13-24.pdf", + "Dreadwolf/Dreadwolf Chapter 25.pdf", + "Dreadwolf/Dreadwolf Chapter 26.pdf" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Publisher - ComicVine.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Publisher - ComicVine.json new file mode 100644 index 000000000..f73beffff --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Publisher - ComicVine.json @@ -0,0 +1,22 @@ +[ + "Antarctic Press/Plush (2018)/Plush 002 (2019).cbz", + "Antarctic Press/Plush (2018)/Plush 001 (2018).cbz", + "12-Gauge Comics/Plush (2022)/Plush 1 (2022).cbz", + "12-Gauge Comics/Plush (2022)/Plush 2 (2022).cbz", + "12-Gauge Comics/Plush (2022)/Plush 3 (2023).cbz", + "12-Gauge Comics/Plush (2022)/Plush 004 (2023).cbz", + "12-Gauge Comics/Plush (2022)/Plush 005 (2023).cbz", + "12-Gauge Comics/Plush (2022)/Plush 006 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 009 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 1 (2022).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 2 (2022).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 3 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 004 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 005 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 006 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 007 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 008 (2023).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 010 (2024).cbz", + "Ablaze/Traveling to Mars (2022)/Traveling to Mars 011 (2024).cbz", + "Blood Hunters V2024 (2024)/Blood Hunters 001 (2024).cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Scan Library Parses as ( - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Scan Library Parses as ( - Manga.json new file mode 100644 index 000000000..803f92586 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Scan Library Parses as ( - Manga.json @@ -0,0 +1,4 @@ +[ + "[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE/[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE.zip", + "[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE/[218565]-(C92) [BRIO (Puyocha)] Something Else (THE IDOLM@STE.zip" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series and Series-Series Combined - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series and Series-Series Combined - Manga.json new file mode 100644 index 000000000..410994952 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series and Series-Series Combined - Manga.json @@ -0,0 +1,4 @@ +[ + "Dress Up Darling/Dress Up Darling Ch 01.cbz", + "Dress Up Darling/Dress Up Darling/Dress Up Darling Vol 01.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json new file mode 100644 index 000000000..c6ea3bc88 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series removed when no other changes are made - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Extra - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Extra - Manga.json new file mode 100644 index 000000000..7ddcaecf4 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Extra - Manga.json @@ -0,0 +1,4 @@ +[ + "The Novel's Extra (Remake)/Vol.01.cbz", + "The Novel's Extra (Remake)/The Novel's Extra Chapter 100.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json new file mode 100644 index 000000000..6495c294f --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized - Manga.json @@ -0,0 +1,5 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru v02.cbz", + "My Dress-Up Darling/Sono Bisque Doll wa Koi wo Suru ch 10.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json new file mode 100644 index 000000000..26619df88 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized 2 - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json new file mode 100644 index 000000000..0b2dd765d --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Sort Order - Manga.json @@ -0,0 +1,5 @@ +[ + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 1-3.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 4.cbz", + "Uzaki-chan Wants to Hang Out!/Uzaki-chan Wants to Hang Out! Ch 5.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json new file mode 100644 index 000000000..4574ddb4e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolder always scanning fix publisher layout - Comic.json @@ -0,0 +1,8 @@ +[ + "VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1.cbz", + "VizMedia/Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 2.cbz", + "VizMedia/Seraph of the End/Seraph of the End Vol. 1.cbz", + "YenPress/Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "YenPress/Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "YenPress/The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json new file mode 100644 index 000000000..3d7c74d5c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders always scanning all series changes - Manga.json @@ -0,0 +1,11 @@ +[ + "Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1/Frieren - Beyond Journey's End Ch. 0001.cbz", + "Frieren - Beyond Journey's End/Frieren - Beyond Journey's End Vol. 1/Frieren - Beyond Journey's End Ch. 0002.cbz", + "Seraph of the End/Seraph of the End Vol. 1/Seraph of the End Ch. 0001.cbz", + "Spice and Wolf/Spice and Wolf Vol. 1/Spice and Wolf Vol. 1 Ch. 0001.cbz", + "Spice and Wolf/Spice and Wolf Vol. 1/Spice and Wolf Vol. 1 Ch. 0002.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2/Spice and Wolf Vol. 2 Ch. 0003.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 1/The Executioner and Her Way of Life Vol. 1 Ch. 0001.cbz", + "The Executioner and Her Way of Life/The Executioner and Her Way of Life Vol. 2/The Executioner and Her Way of Life Vol. 2 Ch. 0003.cbz" +] + diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json new file mode 100644 index 000000000..103ea421a --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root (2) - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz" +] diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json new file mode 100644 index 000000000..103ea421a --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Subfolders and files at root - Manga.json @@ -0,0 +1,6 @@ +[ + "Spice and Wolf/Spice and Wolf Vol. 1.cbz", + "Spice and Wolf/Spice and Wolf Vol. 2.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0011.cbz", + "Spice and Wolf/Spice and Wolf Vol. 3/Spice and Wolf Vol. 3 Ch. 0012.cbz" +] diff --git a/API.Tests/Services/TokenServiceTests.cs b/API.Tests/Services/TokenServiceTests.cs new file mode 100644 index 000000000..d40be5bc9 --- /dev/null +++ b/API.Tests/Services/TokenServiceTests.cs @@ -0,0 +1,31 @@ +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class TokenServiceTests +{ + [Fact] + public void HasTokenExpired_OldToken() + { + // ValidTo: 1/1/1990 + var result = TokenService.HasTokenExpired("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjYzMzgzMDM5OX0.KM_cUKSaCJL3ts0Qim3ZHUeJT7yf-wKoLdKb0rx0VbU"); + + Assert.True(result); + } + + [Fact] + public void HasTokenExpired_ValidInFuture() + { + // ValidTo: 4/11/2200 + var result = TokenService.HasTokenExpired("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjcyNjg0ODYzOTl9.nZrN5USbUmMYDKwkPoMtEAhTeYTeaikgAeSzDPj5kZQ"); + Assert.False(result); + } + + [Fact] + public void HasTokenExpired_NoToken() + { + var result = TokenService.HasTokenExpired(""); + Assert.True(result); + } +} diff --git a/API.Tests/Services/VersionUpdaterServiceTests.cs b/API.Tests/Services/VersionUpdaterServiceTests.cs new file mode 100644 index 000000000..c7a8a14d8 --- /dev/null +++ b/API.Tests/Services/VersionUpdaterServiceTests.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using API.DTOs.Update; +using API.Services; +using API.Services.Tasks; +using API.SignalR; +using Flurl.Http.Testing; +using Kavita.Common.EnvironmentInfo; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services; + +public class VersionUpdaterServiceTests : IDisposable +{ + private readonly ILogger _logger; + private readonly IEventHub _eventHub; + private readonly IDirectoryService _directoryService; + private readonly VersionUpdaterService _service; + private readonly string _tempPath; + private readonly HttpTest _httpTest; + + public VersionUpdaterServiceTests() + { + _logger = Substitute.For>(); + _eventHub = Substitute.For(); + _directoryService = Substitute.For(); + + // Create temp directory for cache + _tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempPath); + _directoryService.LongTermCacheDirectory.Returns(_tempPath); + + _service = new VersionUpdaterService(_logger, _eventHub, _directoryService); + + // Setup HTTP testing + _httpTest = new HttpTest(); + + // Mock BuildInfo.Version for consistent testing + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + } + + public void Dispose() + { + _httpTest.Dispose(); + + // Cleanup temp directory + if (Directory.Exists(_tempPath)) + { + Directory.Delete(_tempPath, true); + } + + // Reset BuildInfo.Version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, null); + } + + [Fact] + public async Task CheckForUpdate_ShouldReturnNull_WhenGithubApiReturnsNull() + { + + _httpTest.RespondWith("null"); + + + var result = await _service.CheckForUpdate(); + + + Assert.Null(result); + } + + // Depends on BuildInfo.CurrentVersion + //[Fact] + public async Task CheckForUpdate_ShouldReturnUpdateNotification_WhenNewVersionIsAvailable() + { + + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.Equal("0.6.0", result.UpdateVersion); + Assert.Equal("0.5.0.0", result.CurrentVersion); + Assert.True(result.IsReleaseNewer); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + } + + //[Fact] + public async Task CheckForUpdate_ShouldDetectEqualVersion() + { + // I can't figure this out + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.5.0.0")); + + + var githubResponse = new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.True(result.IsReleaseEqual); + Assert.False(result.IsReleaseNewer); + } + + + //[Fact] + public async Task PushUpdate_ShouldSendUpdateEvent_WhenNewerVersionAvailable() + { + + var update = new UpdateNotificationDto + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + + await _service.PushUpdate(update); + + + await _eventHub.Received(1).SendMessageAsync( + Arg.Is(MessageFactory.UpdateAvailable), + Arg.Any(), + Arg.Is(true) + ); + } + + [Fact] + public async Task PushUpdate_ShouldNotSendUpdateEvent_WhenVersionIsEqual() + { + + var update = new UpdateNotificationDto + { + UpdateVersion = "0.5.0.0", + CurrentVersion = "0.5.0.0", + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null, + PublishDate = null + }; + + + await _service.PushUpdate(update); + + + await _eventHub.DidNotReceive().SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public async Task GetAllReleases_ShouldReturnReleases_LimitedByCount() + { + + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetAllReleases(2); + + + Assert.Equal(2, result.Count); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.Equal("0.6.0", result[1].UpdateVersion); + } + + [Fact] + public async Task GetAllReleases_ShouldUseCachedData_WhenCacheIsValid() + { + + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + } + }; + releases.Add(new() + { + UpdateVersion = "0.7.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-1) + .ToString("o"), + UpdateBody = "", + UpdateTitle = null, + UpdateUrl = null + }); + + // Create cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow); // Ensure it's fresh + + + var result = await _service.GetAllReleases(); + + + Assert.Equal(2, result.Count); + Assert.Empty(_httpTest.CallLog); // No HTTP calls made + } + + [Fact] + public async Task GetAllReleases_ShouldFetchNewData_WhenCacheIsExpired() + { + + var releases = new List + { + new() + { + UpdateVersion = "0.6.0", + CurrentVersion = "0.5.0.0", + PublishDate = DateTime.UtcNow.AddDays(-10) + .ToString("o"), + UpdateBody = null, + UpdateTitle = null, + UpdateUrl = null + } + }; + + // Create expired cache file + var cacheFilePath = Path.Combine(_tempPath, "github_releases_cache.json"); + await File.WriteAllTextAsync(cacheFilePath, System.Text.Json.JsonSerializer.Serialize(releases)); + File.SetLastWriteTimeUtc(cacheFilePath, DateTime.UtcNow.AddHours(-2)); // Expired (older than 1 hour) + + // Setup HTTP response for new fetch + var newReleases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.ToString("o") + } + }; + + _httpTest.RespondWithJson(newReleases); + + + var result = await _service.GetAllReleases(); + + + Assert.Equal(1, result.Count); + Assert.Equal("0.7.0.0", result[0].UpdateVersion); + Assert.NotEmpty(_httpTest.CallLog); // HTTP call was made + } + + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount() + { + + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + new + { + tag_name = "v0.5.0", + name = "Release 0.5.0", + body = "# Added\n- Feature C", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.5.0", + published_at = DateTime.UtcNow.AddDays(-20).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetNumberOfReleasesBehind(); + + + Assert.Equal(2 + 1, result); // Behind 0.7.0 and 0.6.0 - We have to add 1 because the current release is > 0.7.0 + } + + public async Task GetNumberOfReleasesBehind_ShouldReturnCorrectCount_WithNightlies() + { + + var releases = new List + { + new + { + tag_name = "v0.7.1", + name = "Release 0.7.1", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.1", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + }, + }; + + _httpTest.RespondWithJson(releases); + + + var result = await _service.GetNumberOfReleasesBehind(); + + + Assert.Equal(2, result); // We have to add 1 because the current release is > 0.7.0 + } + + [Fact] + public async Task ParseReleaseBody_ShouldExtractSections() + { + + var githubResponse = new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "This is a great release with many improvements!\n\n# Added\n- Feature 1\n- Feature 2\n# Fixed\n- Bug 1\n- Bug 2\n# Changed\n- Change 1\n# Developer\n- Dev note 1", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.ToString("o") + }; + + _httpTest.RespondWithJson(githubResponse); + + + var result = await _service.CheckForUpdate(); + + + Assert.NotNull(result); + Assert.Equal(2, result.Added.Count); + Assert.Equal(2, result.Fixed.Count); + Assert.Equal(1, result.Changed.Count); + Assert.Equal(1, result.Developer.Count); + Assert.Contains("This is a great release", result.BlogPart); + } + + [Fact] + public async Task GetAllReleases_ShouldHandleNightlyBuilds() + { + + // Set BuildInfo.Version to a nightly build version + typeof(BuildInfo).GetProperty(nameof(BuildInfo.Version))?.SetValue(null, new Version("0.7.1.0")); + + // Mock regular releases + var releases = new List + { + new + { + tag_name = "v0.7.0", + name = "Release 0.7.0", + body = "# Added\n- Feature A", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.7.0", + published_at = DateTime.UtcNow.AddDays(-1).ToString("o") + }, + new + { + tag_name = "v0.6.0", + name = "Release 0.6.0", + body = "# Added\n- Feature B", + html_url = "https://github.com/Kareadita/Kavita/releases/tag/v0.6.0", + published_at = DateTime.UtcNow.AddDays(-10).ToString("o") + } + }; + + _httpTest.RespondWithJson(releases); + + // Mock commit info for develop branch + _httpTest.RespondWithJson(new List()); + + + var result = await _service.GetAllReleases(); + + + Assert.NotNull(result); + Assert.True(result[0].IsOnNightlyInRelease); + } +} diff --git a/API.Tests/Services/WordCountAnalysisTests.cs b/API.Tests/Services/WordCountAnalysisTests.cs index 6d17f3834..8c8c4193c 100644 --- a/API.Tests/Services/WordCountAnalysisTests.cs +++ b/API.Tests/Services/WordCountAnalysisTests.cs @@ -1,19 +1,17 @@ using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Entities; using API.Entities.Enums; +using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services; using API.Services.Plus; -using API.Services.Tasks; using API.Services.Tasks.Metadata; using API.SignalR; -using API.Tests.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -26,7 +24,7 @@ public class WordCountAnalysisTests : AbstractDbTest private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); private const long WordCount = 33608; // 37417 if splitting on space, 33608 if just character count private const long MinHoursToRead = 1; - private const long AvgHoursToRead = 2; + private const float AvgHoursToRead = 1.66954792f; private const long MaxHoursToRead = 3; public WordCountAnalysisTests() : base() { @@ -64,7 +62,7 @@ public class WordCountAnalysisTests : AbstractDbTest series.Volumes = new List() { - new VolumeBuilder("0") + new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(chapter) .Build(), }; @@ -74,14 +72,14 @@ public class WordCountAnalysisTests : AbstractDbTest var cacheService = new CacheHelper(new FileService()); var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, - Substitute.For(), cacheService, _readerService); + Substitute.For(), cacheService, _readerService, Substitute.For()); await service.ScanSeries(1, 1); Assert.Equal(WordCount, series.WordCount); Assert.Equal(MinHoursToRead, series.MinHoursToRead); - Assert.Equal(AvgHoursToRead, series.AvgHoursToRead); + Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead)); Assert.Equal(MaxHoursToRead, series.MaxHoursToRead); // Validate the Chapter gets updated correctly @@ -111,7 +109,7 @@ public class WordCountAnalysisTests : AbstractDbTest .Build(); var series = new SeriesBuilder("Test Series") .WithFormat(MangaFormat.Epub) - .WithVolume(new VolumeBuilder("0") + .WithVolume(new VolumeBuilder(API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume) .WithChapter(chapter) .Build()) .Build(); @@ -126,7 +124,7 @@ public class WordCountAnalysisTests : AbstractDbTest var cacheService = new CacheHelper(new FileService()); var service = new WordCountAnalyzerService(Substitute.For>(), _unitOfWork, - Substitute.For(), cacheService, _readerService); + Substitute.For(), cacheService, _readerService, Substitute.For()); await service.ScanSeries(1, 1); var chapter2 = new ChapterBuilder("2") @@ -148,13 +146,11 @@ public class WordCountAnalysisTests : AbstractDbTest Assert.Equal(WordCount * 2L, series.WordCount); Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead); - //Assert.Equal(AvgHoursToRead * 2, series.AvgHoursToRead); - //Assert.Equal((MaxHoursToRead * 2) - 1, series.MaxHoursToRead); // This is just a rounding issue var firstVolume = series.Volumes.ElementAt(0); Assert.Equal(WordCount, firstVolume.WordCount); Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead); - Assert.Equal(AvgHoursToRead, firstVolume.AvgHoursToRead); + Assert.True(series.AvgHoursToRead.Is(AvgHoursToRead * 2)); Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead); var secondVolume = series.Volumes.ElementAt(1); diff --git a/API/API.csproj b/API/API.csproj index aee5fa856..1ddb37d7f 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -2,7 +2,7 @@ Default - net8.0 + net9.0 true Linux true @@ -12,9 +12,6 @@ latestmajor - - - false @@ -53,58 +50,58 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + - - + + - - - - - - - - + + + + + + + + - - - - - - - - + + + + + + + - - + + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + + @@ -117,6 +114,7 @@ + @@ -190,10 +188,16 @@ + Always + + Always + + + diff --git a/API/API.csproj.DotSettings b/API/API.csproj.DotSettings index c7410bba2..ced14c154 100644 --- a/API/API.csproj.DotSettings +++ b/API/API.csproj.DotSettings @@ -1,3 +1,4 @@  True + True True \ No newline at end of file diff --git a/API/Assets/anilist-no-image-placeholder.jpg b/API/Assets/anilist-no-image-placeholder.jpg new file mode 100644 index 000000000..54c1066b6 Binary files /dev/null and b/API/Assets/anilist-no-image-placeholder.jpg differ diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs index beac530fb..f5d566cb1 100644 --- a/API/Comparators/ChapterSortComparer.cs +++ b/API/Comparators/ChapterSortComparer.cs @@ -1,32 +1,34 @@ using System.Collections.Generic; +using API.Extensions; +using API.Services.Tasks.Scanner.Parser; namespace API.Comparators; #nullable enable /// -/// Sorts chapters based on their Number. Uses natural ordering of doubles. +/// Sorts chapters based on their Number. Uses natural ordering of doubles. Specials always LAST. /// -public class ChapterSortComparer : IComparer +public class ChapterSortComparerDefaultLast : IComparer { /// - /// Normal sort for 2 doubles. 0 always comes last + /// Normal sort for 2 doubles. DefaultChapterNumber always comes last /// /// /// /// - public int Compare(double x, double y) + public int Compare(float x, float y) { - if (x == 0.0 && y == 0.0) return 0; + if (x.Is(Parser.DefaultChapterNumber) && y.Is(Parser.DefaultChapterNumber)) return 0; // if x is 0, it comes second - if (x == 0.0) return 1; + if (x.Is(Parser.DefaultChapterNumber)) return 1; // if y is 0, it comes second - if (y == 0.0) return -1; + if (y.Is(Parser.DefaultChapterNumber)) return -1; return x.CompareTo(y); } - public static readonly ChapterSortComparer Default = new ChapterSortComparer(); + public static readonly ChapterSortComparerDefaultLast Default = new ChapterSortComparerDefaultLast(); } /// @@ -36,33 +38,43 @@ public class ChapterSortComparer : IComparer /// This is represented by Chapter 0, Chapter 81. /// /// -public class ChapterSortComparerZeroFirst : IComparer +public class ChapterSortComparerDefaultFirst : IComparer { - public int Compare(double x, double y) + public int Compare(float x, float y) { - if (x == 0.0 && y == 0.0) return 0; + if (x.Is(Parser.DefaultChapterNumber) && y.Is(Parser.DefaultChapterNumber)) return 0; // if x is 0, it comes first - if (x == 0.0) return -1; + if (x.Is(Parser.DefaultChapterNumber)) return -1; // if y is 0, it comes first - if (y == 0.0) return 1; + if (y.Is(Parser.DefaultChapterNumber)) return 1; return x.CompareTo(y); } - public static readonly ChapterSortComparerZeroFirst Default = new ChapterSortComparerZeroFirst(); + public static readonly ChapterSortComparerDefaultFirst Default = new ChapterSortComparerDefaultFirst(); } -public class SortComparerZeroLast : IComparer +/// +/// Sorts chapters based on their Number. Uses natural ordering of doubles. Specials always LAST. +/// +public class ChapterSortComparerSpecialsLast : IComparer { - public int Compare(double x, double y) + /// + /// Normal sort for 2 doubles. DefaultSpecialNumber always comes last + /// + /// + /// + /// + public int Compare(float x, float y) { - if (x == 0.0 && y == 0.0) return 0; - // if x is 0, it comes last - if (x == 0.0) return 1; - // if y is 0, it comes last - if (y == 0.0) return -1; + if (x.Is(Parser.SpecialVolumeNumber) && y.Is(Parser.SpecialVolumeNumber)) return 0; + // if x is 0, it comes second + if (x.Is(Parser.SpecialVolumeNumber)) return 1; + // if y is 0, it comes second + if (y.Is(Parser.SpecialVolumeNumber)) return -1; return x.CompareTo(y); } - public static readonly SortComparerZeroLast Default = new SortComparerZeroLast(); + + public static readonly ChapterSortComparerSpecialsLast Default = new ChapterSortComparerSpecialsLast(); } diff --git a/API/Constants/CacheProfiles.cs b/API/Constants/CacheProfiles.cs index ee2cd204e..ccbbf2479 100644 --- a/API/Constants/CacheProfiles.cs +++ b/API/Constants/CacheProfiles.cs @@ -12,6 +12,10 @@ public static class EasyCacheProfiles /// public const string License = "license"; /// + /// License Information + /// + public const string LicenseInfo = "licenseInfo"; + /// /// Cache the libraries on the server /// public const string Library = "library"; @@ -19,4 +23,12 @@ public static class EasyCacheProfiles /// External Series metadata for Kavita+ recommendation /// public const string KavitaPlusExternalSeries = "kavita+externalSeries"; + /// + /// Match Series metadata for Kavita+ metadata download + /// + public const string KavitaPlusMatchSeries = "kavita+matchSeries"; + /// + /// All Locales on the Server + /// + public const string LocaleOptions = "locales"; } 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..c504e1ce7 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -15,6 +16,7 @@ using API.Errors; using API.Extensions; using API.Helpers.Builders; using API.Services; +using API.Services.Plus; using API.SignalR; using AutoMapper; using Hangfire; @@ -37,6 +39,9 @@ namespace API.Controllers; /// public class AccountController : BaseApiController { + // Hardcoded to avoid localization multiple enumeration: https://github.com/Kareadita/Kavita/issues/2829 + private const string BadCredentialsMessage = "Your credentials are not correct"; + private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ITokenService _tokenService; @@ -79,6 +84,7 @@ public class AccountController : BaseApiController { var user = await _userManager.Users.SingleOrDefaultAsync(x => x.UserName == resetPasswordDto.UserName); if (user == null) return Ok(); // Don't report BadRequest as that would allow brute forcing to find accounts on system + _logger.LogInformation("{UserName} is changing {ResetUser}'s password", User.GetUsername(), resetPasswordDto.UserName); if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); @@ -132,6 +138,12 @@ public class AccountController : BaseApiController return BadRequest(usernameValidation); } + // If Email is empty, default to the username + if (string.IsNullOrEmpty(registerDto.Email)) + { + registerDto.Email = registerDto.Username; + } + var user = new AppUserBuilder(registerDto.Username, registerDto.Email, await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build(); @@ -204,7 +216,7 @@ public class AccountController : BaseApiController if (user == null) { _logger.LogWarning("Attempted login by {UserName} failed due to unable to find account", loginDto.Username); - return Unauthorized(await _localizationService.Get("en", "bad-credentials")); + return Unauthorized(BadCredentialsMessage); } var roles = await _userManager.GetRolesAsync(user); if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account")); @@ -225,10 +237,10 @@ public class AccountController : BaseApiController if (!result.Succeeded) { - var errorStr = await _localizationService.Translate(user.Id, - result.IsNotAllowed ? "confirm-email" : "bad-credentials"); - _logger.LogWarning("{UserName} failed to log in at {Time}: {Issue}", user.UserName, user.LastActive, - errorStr); + string errorStr = result.IsNotAllowed + ? await _localizationService.Translate(user.Id, "confirm-email") + : BadCredentialsMessage; + _logger.LogWarning("{UserName} failed to log in at {Time}: {Issue}", user.UserName, user.LastActive, errorStr); return Unauthorized(errorStr); } } @@ -346,10 +358,11 @@ public class AccountController : BaseApiController /// /// Returns just if the email was sent or server isn't reachable [HttpPost("update/email")] - public async Task UpdateEmail(UpdateEmailDto? dto) + public async Task> UpdateEmail(UpdateEmailDto? dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (user == null || User.IsInRole(PolicyConstants.ReadOnlyRole)) + return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "invalid-payload")); @@ -358,12 +371,13 @@ public class AccountController : BaseApiController // Validate this user's password if (! await _userManager.CheckPasswordAsync(user, dto.Password)) { - _logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); + _logger.LogWarning("A user tried to change {UserName}'s email, but password didn't validate", user.UserName); return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); } // 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); @@ -380,18 +394,25 @@ public class AccountController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generate-token")); } + var isValidEmailAddress = _emailService.IsValidEmail(user.Email); var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - var shouldEmailUser = serverSettings.IsEmailSetup() || !_emailService.IsValidEmail(user.Email); + var shouldEmailUser = serverSettings.IsEmailSetup() || !isValidEmailAddress; + user.EmailConfirmed = !shouldEmailUser; 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, - EmailSent = false + EmailSent = false, + InvalidEmail = !isValidEmailAddress }); } @@ -399,10 +420,7 @@ 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)) + if (!isValidEmailAddress) { _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); return Ok(new InviteUserResponse @@ -434,7 +452,8 @@ public class AccountController : BaseApiController return Ok(new InviteUserResponse { EmailLink = string.Empty, - EmailSent = true + EmailSent = true, + InvalidEmail = !isValidEmailAddress }); } catch (Exception ex) @@ -452,6 +471,7 @@ public class AccountController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); if (!await _accountService.CanChangeAgeRestriction(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); @@ -489,6 +509,7 @@ public class AccountController : BaseApiController var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (adminUser == null) return Unauthorized(); if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams); if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user")); @@ -504,6 +525,21 @@ public class AccountController : BaseApiController _unitOfWork.UserRepository.Update(user); } + // Check if email is changing for a non-admin user + var isUpdatingAnotherAccount = user.Id != adminUser.Id; + if (isUpdatingAnotherAccount && !string.IsNullOrEmpty(dto.Email) && user.Email != dto.Email) + { + // Validate username change + var errors = await _accountService.ValidateEmail(dto.Email); + if (errors.Any()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "email-taken")); + + user.Email = dto.Email; + user.EmailConfirmed = true; // When an admin performs the flow, we assume the email address is able to receive data + + await _userManager.UpdateNormalizedEmailAsync(user); + _unitOfWork.UserRepository.Update(user); + } + // Update roles var existingRoles = await _userManager.GetRolesAsync(user); var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); @@ -607,8 +643,7 @@ public class AccountController : BaseApiController if (adminUser == null) return Unauthorized(await _localizationService.Translate(userId, "permission-denied")); dto.Email = dto.Email.Trim(); - if (string.IsNullOrEmpty(dto.Email)) - return BadRequest(await _localizationService.Translate(userId, "invalid-payload")); + if (string.IsNullOrEmpty(dto.Email)) return BadRequest(await _localizationService.Translate(userId, "invalid-payload")); _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); @@ -618,7 +653,7 @@ public class AccountController : BaseApiController { var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (await _userManager.IsEmailConfirmedAsync(invitedUser!)) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser.UserName)); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-registered", invitedUser!.UserName)); return BadRequest(await _localizationService.Translate(User.GetUserId(), "user-already-invited")); } @@ -768,6 +803,7 @@ public class AccountController : BaseApiController { validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); } + validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password)); if (validationErrors.Any()) @@ -839,6 +875,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(); @@ -856,7 +893,7 @@ public class AccountController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); if (user == null) { - return BadRequest(await _localizationService.Get("en", "bad-credentials")); + return BadRequest(BadCredentialsMessage); } try @@ -866,7 +903,7 @@ public class AccountController : BaseApiController if (!result) { _logger.LogInformation("Unable to reset password, your email token is not correct: {@Dto}", dto); - return BadRequest(await _localizationService.Translate(user.Id, "bad-credentials")); + return BadRequest(BadCredentialsMessage); } var errors = await _accountService.ChangeUserPassword(user, dto.Password); @@ -890,10 +927,7 @@ public class AccountController : BaseApiController [EnableRateLimiting("Authentication")] public async Task> ForgotPassword([FromQuery] string email) { - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled")); - var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); if (user == null) { @@ -908,11 +942,7 @@ public class AccountController : BaseApiController if (string.IsNullOrEmpty(user.Email) || !user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "confirm-email")); - if (!_emailService.IsValidEmail(user.Email)) - { - _logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI", user.Email); - return Ok(await _localizationService.Translate(user.Id, "invalid-email")); - } + var token = await _userManager.GeneratePasswordResetTokenAsync(user); var emailLink = await _emailService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email); @@ -921,6 +951,13 @@ public class AccountController : BaseApiController await _unitOfWork.CommitAsync(); _logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink); + if (!settings.IsEmailSetup()) return Ok(await _localizationService.Get("en", "email-not-enabled")); + if (!_emailService.IsValidEmail(user.Email)) + { + _logger.LogCritical("[Forgot Password]: User is trying to do a forgot password flow, but their email ({Email}) isn't valid. No email will be send. Admin must change it in UI or from url above", user.Email); + return Ok(await _localizationService.Translate(user.Id, "invalid-email")); + } + var installId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value; BackgroundJob.Enqueue(() => _emailService.SendForgotPasswordEmail(new PasswordResetEmailDto() { @@ -946,12 +983,12 @@ public class AccountController : BaseApiController public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); - if (user == null) return BadRequest(await _localizationService.Get("en", "bad-credentials")); + if (user == null) return BadRequest(BadCredentialsMessage); if (!await ConfirmEmailToken(dto.Token, user)) { _logger.LogInformation("confirm-migration-email email token is invalid"); - return BadRequest(await _localizationService.Translate(user.Id, "bad-credentials")); + return BadRequest(BadCredentialsMessage); } await _unitOfWork.CommitAsync(); @@ -990,6 +1027,8 @@ public class AccountController : BaseApiController await _localizationService.Translate(user.Id, "user-migration-needed")); if (user.EmailConfirmed) return BadRequest(await _localizationService.Translate(user.Id, "user-already-confirmed")); + // TODO: If the target user is read only, we might want to just forgo this + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); user.ConfirmationToken = token; _unitOfWork.UserRepository.Update(user); diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 7a7d5b06a..4f8edd511 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -1,4 +1,10 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.ManualMigrations; +using API.DTOs; +using API.DTOs.Progress; using API.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; @@ -25,7 +31,7 @@ public class AdminController : BaseApiController [HttpGet("exists")] public async Task> AdminExists() { - var users = await _userManager.GetUsersInRoleAsync("Admin"); + var users = await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); return users.Count > 0; } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 623f145ed..251811346 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -50,7 +50,7 @@ public class BookController : BaseApiController case MangaFormat.Epub: { var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0]; - using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions); bookTitle = book.Title; break; } @@ -103,7 +103,7 @@ public class BookController : BaseApiController var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist")); - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions); var key = BookService.CoalesceKeyForAnyFile(book, file); if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing")); diff --git a/API/Controllers/CBLController.cs b/API/Controllers/CBLController.cs index 7952f3790..150628ced 100644 --- a/API/Controllers/CBLController.cs +++ b/API/Controllers/CBLController.cs @@ -2,11 +2,13 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; +using API.Constants; using API.DTOs.ReadingLists.CBL; using API.Extensions; using API.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; namespace API.Controllers; @@ -19,35 +21,40 @@ public class CblController : BaseApiController { private readonly IReadingListService _readingListService; private readonly IDirectoryService _directoryService; + private readonly ILocalizationService _localizationService; - public CblController(IReadingListService readingListService, IDirectoryService directoryService) + public CblController(IReadingListService readingListService, IDirectoryService directoryService, ILocalizationService localizationService) { _readingListService = readingListService; _directoryService = directoryService; + _localizationService = localizationService; } /// /// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful. /// If this returns errors, the cbl will always be rejected by Kavita. /// - /// FormBody with parameter name of cbl + /// FormBody with parameter name of cbl + /// Use comic vine matching or not. Defaults to false /// [HttpPost("validate")] - public async Task> ValidateCbl([FromForm(Name = "cbl")] IFormFile file) + [SwaggerIgnore] + public async Task> ValidateCbl(IFormFile cbl, [FromQuery] bool useComicVineMatching = false) { var userId = User.GetUserId(); try { - var cbl = await SaveAndLoadCblFile(file); - var importSummary = await _readingListService.ValidateCblFile(userId, cbl); - importSummary.FileName = file.FileName; + var cblReadingList = await SaveAndLoadCblFile(cbl); + var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, useComicVineMatching); + importSummary.FileName = cbl.FileName; + return Ok(importSummary); } catch (ArgumentNullException) { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { @@ -62,7 +69,7 @@ public class CblController : BaseApiController { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { @@ -79,24 +86,29 @@ public class CblController : BaseApiController /// /// Performs the actual import (assuming dryRun = false) /// - /// FormBody with parameter name of cbl + /// FormBody with parameter name of cbl /// If true, will only emulate the import but not perform. This should be done to preview what will happen + /// Use comic vine matching or not. Defaults to false /// [HttpPost("import")] - public async Task> ImportCbl([FromForm(Name = "cbl")] IFormFile file, [FromForm(Name = "dryRun")] bool dryRun = false) + [SwaggerIgnore] + public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { var userId = User.GetUserId(); - var cbl = await SaveAndLoadCblFile(file); - var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun); - importSummary.FileName = file.FileName; + var cblReadingList = await SaveAndLoadCblFile(cbl); + var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, useComicVineMatching); + importSummary.FileName = cbl.FileName; + return Ok(importSummary); } catch (ArgumentNullException) { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { @@ -111,7 +123,7 @@ public class CblController : BaseApiController { return Ok(new CblImportSummaryDto() { - FileName = file.FileName, + FileName = cbl.FileName, Success = CblImportResult.Fail, Results = new List() { diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs new file mode 100644 index 000000000..4110cd907 --- /dev/null +++ b/API/Controllers/ChapterController.cs @@ -0,0 +1,396 @@ +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; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.Helpers; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Nager.ArticleNumber; + +namespace API.Controllers; + +public class ChapterController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IEventHub _eventHub; + private readonly ILogger _logger; + + public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _eventHub = eventHub; + _logger = logger; + } + + /// + /// Gets a single chapter + /// + /// + /// + [HttpGet] + public async Task> GetChapter(int chapterId) + { + var chapter = + await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, + ChapterIncludes.People | ChapterIncludes.Files); + + return Ok(chapter); + } + + /// + /// Removes a Chapter + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete] + public async Task> DeleteChapter(int chapterId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); + if (chapter == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + + var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters); + if (vol == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + + // If there is only 1 chapter within the volume, then we need to remove the volume + var needToRemoveVolume = vol.Chapters.Count == 1; + if (needToRemoveVolume) + { + _unitOfWork.VolumeRepository.Remove(vol); + } + else + { + _unitOfWork.ChapterRepository.Remove(chapter); + } + + + if (!await _unitOfWork.CommitAsync()) return Ok(false); + + await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false); + if (needToRemoveVolume) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(chapter.VolumeId, vol.SeriesId), false); + } + + return Ok(true); + } + + /// + /// Deletes multiple chapters and any volumes with no leftover chapters + /// + /// The ID of the series + /// The IDs of the chapters to be deleted + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("delete-multiple")] + public async Task> DeleteMultipleChapters([FromQuery] int seriesId, DeleteChaptersDto dto) + { + try + { + var chapterIds = dto.ChapterIds; + if (chapterIds == null || chapterIds.Count == 0) + { + return BadRequest("ChapterIds required"); + } + + // Fetch all chapters to be deleted + var chapters = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)).ToList(); + + // Group chapters by their volume + var volumesToUpdate = chapters.GroupBy(c => c.VolumeId).ToList(); + var removedVolumes = new List(); + + foreach (var volumeGroup in volumesToUpdate) + { + var volumeId = volumeGroup.Key; + var chaptersToDelete = volumeGroup.ToList(); + + // Fetch the volume + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + if (volume == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + + // Check if all chapters in the volume are being deleted + var isVolumeToBeRemoved = volume.Chapters.Count == chaptersToDelete.Count; + + if (isVolumeToBeRemoved) + { + _unitOfWork.VolumeRepository.Remove(volume); + removedVolumes.Add(volume.Id); + } + else + { + // Remove only the specified chapters + _unitOfWork.ChapterRepository.Remove(chaptersToDelete); + } + } + + if (!await _unitOfWork.CommitAsync()) return Ok(false); + + // Send events for removed chapters + foreach (var chapter in chapters) + { + await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, + MessageFactory.ChapterRemovedEvent(chapter.Id, seriesId), false); + } + + // Send events for removed volumes + foreach (var volumeId in removedVolumes) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, + MessageFactory.VolumeRemovedEvent(volumeId, seriesId), false); + } + + return Ok(true); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occured while deleting chapters"); + return BadRequest(_localizationService.Translate(User.GetUserId(), "generic-error")); + } + + } + + + /// + /// Update chapter metadata + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateChapterMetadata(UpdateChapterDto dto) + { + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.Id, + ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags); + if (chapter == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); + + if (chapter.AgeRating != dto.AgeRating) + { + chapter.AgeRating = dto.AgeRating; + } + + dto.Summary ??= string.Empty; + + if (chapter.Summary != dto.Summary.Trim()) + { + chapter.Summary = dto.Summary.Trim(); + } + + if (chapter.Language != dto.Language) + { + chapter.Language = dto.Language ?? string.Empty; + } + + if (chapter.SortOrder.IsNot(dto.SortOrder)) + { + chapter.SortOrder = dto.SortOrder; // TODO: Figure out validation + } + + if (chapter.TitleName != dto.TitleName) + { + chapter.TitleName = dto.TitleName; + } + + if (chapter.ReleaseDate != dto.ReleaseDate) + { + chapter.ReleaseDate = dto.ReleaseDate; + } + + if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) || + ArticleNumberHelper.IsValidIsbn13(dto.ISBN)) + { + chapter.ISBN = dto.ISBN; + } + + if (string.IsNullOrEmpty(dto.WebLinks)) + { + chapter.WebLinks = string.Empty; + } else + { + chapter.WebLinks = string.Join(',', dto.WebLinks + .Split(',') + .Where(s => !string.IsNullOrEmpty(s)) + .Select(s => s.Trim())! + ); + } + + + #region Genres + chapter.Genres ??= []; + await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork); + #endregion + + #region Tags + chapter.Tags ??= []; + await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork); + #endregion + + #region People + chapter.People ??= []; + + // Update writers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Writers.Select(p => p.Name).ToList(), + PersonRole.Writer, + _unitOfWork + ); + + // Update characters + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Characters.Select(p => p.Name).ToList(), + PersonRole.Character, + _unitOfWork + ); + + // Update pencillers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Pencillers.Select(p => p.Name).ToList(), + PersonRole.Penciller, + _unitOfWork + ); + + // Update inkers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Inkers.Select(p => p.Name).ToList(), + PersonRole.Inker, + _unitOfWork + ); + + // Update colorists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Colorists.Select(p => p.Name).ToList(), + PersonRole.Colorist, + _unitOfWork + ); + + // Update letterers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Letterers.Select(p => p.Name).ToList(), + PersonRole.Letterer, + _unitOfWork + ); + + // Update cover artists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.CoverArtists.Select(p => p.Name).ToList(), + PersonRole.CoverArtist, + _unitOfWork + ); + + // Update editors + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Editors.Select(p => p.Name).ToList(), + PersonRole.Editor, + _unitOfWork + ); + + // Update publishers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Publishers.Select(p => p.Name).ToList(), + PersonRole.Publisher, + _unitOfWork + ); + + // Update translators + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Translators.Select(p => p.Name).ToList(), + PersonRole.Translator, + _unitOfWork + ); + + // Update imprints + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Imprints.Select(p => p.Name).ToList(), + PersonRole.Imprint, + _unitOfWork + ); + + // Update teams + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Teams.Select(p => p.Name).ToList(), + PersonRole.Team, + _unitOfWork + ); + + // Update locations + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Locations.Select(p => p.Name).ToList(), + PersonRole.Location, + _unitOfWork + ); + #endregion + + #region Locks + chapter.AgeRatingLocked = dto.AgeRatingLocked; + chapter.LanguageLocked = dto.LanguageLocked; + chapter.TitleNameLocked = dto.TitleNameLocked; + chapter.SortOrderLocked = dto.SortOrderLocked; + chapter.GenresLocked = dto.GenresLocked; + chapter.TagsLocked = dto.TagsLocked; + chapter.CharacterLocked = dto.CharacterLocked; + chapter.ColoristLocked = dto.ColoristLocked; + chapter.EditorLocked = dto.EditorLocked; + chapter.InkerLocked = dto.InkerLocked; + chapter.ImprintLocked = dto.ImprintLocked; + chapter.LettererLocked = dto.LettererLocked; + chapter.PencillerLocked = dto.PencillerLocked; + chapter.PublisherLocked = dto.PublisherLocked; + chapter.TranslatorLocked = dto.TranslatorLocked; + chapter.CoverArtistLocked = dto.CoverArtistLocked; + chapter.WriterLocked = dto.WriterLocked; + chapter.SummaryLocked = dto.SummaryLocked; + chapter.ISBNLocked = dto.ISBNLocked; + chapter.ReleaseDateLocked = dto.ReleaseDateLocked; + #endregion + + + _unitOfWork.ChapterRepository.Update(chapter); + + if (!_unitOfWork.HasChanges()) + { + return Ok(); + } + + // TODO: Emit a ChapterMetadataUpdate out + + await _unitOfWork.CommitAsync(); + + + return Ok(); + } + + + +} diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 26f6871d1..2c0abc609 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -1,15 +1,22 @@ 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 API.SignalR; +using Hangfire; using Kavita.Common; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -23,61 +30,70 @@ public class CollectionController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly ICollectionTagService _collectionService; private readonly ILocalizationService _localizationService; + private readonly IExternalMetadataService _externalMetadataService; + private readonly ISmartCollectionSyncService _collectionSyncService; + private readonly ILogger _logger; + private readonly IEventHub _eventHub; /// public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService, - ILocalizationService localizationService) + ILocalizationService localizationService, IExternalMetadataService externalMetadataService, + ISmartCollectionSyncService collectionSyncService, ILogger logger, + IEventHub eventHub) { _unitOfWork = unitOfWork; _collectionService = collectionService; _localizationService = localizationService; + _externalMetadataService = externalMetadataService; + _collectionSyncService = collectionSyncService; + _logger = logger; + _eventHub = eventHub; } /// - /// 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 a single Collection tag by Id for a given user /// - /// Search term + /// /// - [Authorize(Policy = "RequireAdminRole")] - [HttpGet("search")] - public async Task>> SearchTags(string? queryString) + [HttpGet("single")] + public async Task>> GetTag(int collectionId) { - queryString ??= string.Empty; - queryString = queryString.Replace(@"%", string.Empty); - if (queryString.Length == 0) return await GetAllTags(); - - return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId())); + var collections = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), false); + return Ok(collections.FirstOrDefault(c => c.Id == collectionId)); } + /// + /// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned) + /// + /// + /// + /// + [HttpGet("all-series")] + public async Task>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false) + { + 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 +102,19 @@ public class CollectionController : BaseApiController /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] - public async Task UpdateTag(CollectionTagDto updatedTag) + public async Task UpdateTag(AppUserCollectionDto updatedTag) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + try { - if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); + if (await _collectionService.UpdateTag(updatedTag, User.GetUserId())) + { + await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated, + MessageFactory.CollectionUpdatedEvent(updatedTag.Id), false); + return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully")); + } } catch (KavitaException ex) { @@ -103,18 +125,100 @@ 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) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + // 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(); + } + + + /// + /// Delete multiple collections in one go + /// + /// + /// + [HttpPost("delete-multiple")] + public async Task DeleteMultipleCollections(DeleteCollectionsDto dto) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + // 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); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); - if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok(); + // Create a new tag and save + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); + + 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(), false); + 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 +228,14 @@ public class CollectionController : BaseApiController /// /// /// - [Authorize(Policy = "RequireAdminRole")] [HttpPost("update-series")] public async Task RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + 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 +250,89 @@ 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) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + 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() + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId())); + } + + /// + /// Imports a MAL Stack into Kavita + /// + /// + /// + [HttpPost("import-stack")] + public async Task ImportMalStack(MalStackDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections); + if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + + // Validation check to ensure stack doesn't exist already + if (await _unitOfWork.CollectionTagRepository.CollectionExists(dto.Title, user.Id)) + { + return BadRequest(_localizationService.Translate(user.Id, "collection-already-exists")); + } + + try + { + // Create new collection + var newCollection = new AppUserCollectionBuilder(dto.Title) + .WithSource(ScrobbleProvider.Mal) + .WithSourceUrl(dto.Url) + .Build(); + user.Collections.Add(newCollection); + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + // Trigger Stack Refresh for just one stack (not all) + BackgroundJob.Enqueue(() => _collectionSyncService.Sync(newCollection.Id)); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue importing MAL Stack"); + } + + return BadRequest(_localizationService.Translate(user.Id, "error-import-stack")); + } } diff --git a/API/Controllers/ColorScapeController.cs b/API/Controllers/ColorScapeController.cs new file mode 100644 index 000000000..04827658d --- /dev/null +++ b/API/Controllers/ColorScapeController.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Theme; +using API.Entities.Interfaces; +using API.Extensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Authorize] +public class ColorScapeController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public ColorScapeController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + /// + /// Returns the color scape for a series + /// + /// + /// + [HttpGet("series")] + public async Task> GetColorScapeForSeries(int id) + { + var entity = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(id, User.GetUserId()); + return GetColorSpaceDto(entity); + } + + /// + /// Returns the color scape for a volume + /// + /// + /// + [HttpGet("volume")] + public async Task> GetColorScapeForVolume(int id) + { + var entity = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(id, User.GetUserId()); + return GetColorSpaceDto(entity); + } + + /// + /// Returns the color scape for a chapter + /// + /// + /// + [HttpGet("chapter")] + public async Task> GetColorScapeForChapter(int id) + { + var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id); + return GetColorSpaceDto(entity); + } + + + private ActionResult GetColorSpaceDto(IHasCoverImage entity) + { + if (entity == null) return Ok(ColorScapeDto.Empty); + return Ok(new ColorScapeDto(entity.PrimaryColor, entity.SecondaryColor)); + } +} diff --git a/API/Controllers/DeviceController.cs b/API/Controllers/DeviceController.cs index 61a847b6e..8c8081d98 100644 --- a/API/Controllers/DeviceController.cs +++ b/API/Controllers/DeviceController.cs @@ -7,6 +7,7 @@ using API.DTOs.Device; using API.Extensions; using API.Services; using API.SignalR; +using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Mvc; @@ -24,20 +25,27 @@ public class DeviceController : BaseApiController private readonly IEmailService _emailService; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly IMapper _mapper; public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, - IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService) + IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService, IMapper mapper) { _unitOfWork = unitOfWork; _deviceService = deviceService; _emailService = emailService; _eventHub = eventHub; _localizationService = localizationService; + _mapper = mapper; } + /// + /// Creates a new Device + /// + /// + /// [HttpPost("create")] - public async Task CreateOrUpdateDevice(CreateDeviceDto dto) + public async Task> CreateOrUpdateDevice(CreateDeviceDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); if (user == null) return Unauthorized(); @@ -46,20 +54,22 @@ public class DeviceController : BaseApiController var device = await _deviceService.Create(dto, user); if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-create")); + + return Ok(_mapper.Map(device)); } catch (KavitaException ex) { return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } - - - - - return Ok(); } + /// + /// Updates an existing Device + /// + /// + /// [HttpPost("update")] - public async Task UpdateDevice(UpdateDeviceDto dto) + public async Task> UpdateDevice(UpdateDeviceDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices); if (user == null) return Unauthorized(); @@ -67,7 +77,7 @@ public class DeviceController : BaseApiController if (device == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-device-update")); - return Ok(); + return Ok(_mapper.Map(device)); } /// @@ -100,18 +110,18 @@ public class DeviceController : BaseApiController [HttpPost("send-to")] public async Task SendToDevice(SendToDeviceDto dto) { - if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "ChapterIds")); - if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); + var userId = User.GetUserId(); + if (dto.ChapterIds.Any(i => i < 0)) return BadRequest(await _localizationService.Translate(userId, "greater-0", "ChapterIds")); + if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId")); var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice(); if (!isEmailSetup) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); + return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email")); // // Validate that the device belongs to the user - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Devices); - if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-unallowed")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.Devices); + if (user == null || user.Devices.All(d => d.Id != dto.DeviceId)) return BadRequest(await _localizationService.Translate(userId, "send-to-unallowed")); - var userId = User.GetUserId(); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "started"), userId); @@ -135,26 +145,30 @@ public class DeviceController : BaseApiController } - + /// + /// Attempts to send a whole series to a device. + /// + /// + /// [HttpPost("send-series-to")] public async Task SendSeriesToDevice(SendSeriesToDeviceDto dto) { - if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "SeriesId")); - if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "DeviceId")); + var userId = User.GetUserId(); + if (dto.SeriesId <= 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "SeriesId")); + if (dto.DeviceId < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "DeviceId")); var isEmailSetup = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetupForSendToDevice(); if (!isEmailSetup) - return BadRequest(await _localizationService.Translate(User.GetUserId(), "send-to-kavita-email")); + return BadRequest(await _localizationService.Translate(userId, "send-to-kavita-email")); - var userId = User.GetUserId(); await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, - MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "started"), userId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Volumes | SeriesIncludes.Chapters); - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); + if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist")); var chapterIds = series.Volumes.SelectMany(v => v.Chapters.Select(c => c.Id)).ToList(); try { @@ -163,16 +177,16 @@ public class DeviceController : BaseApiController } catch (KavitaException ex) { - return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); + return BadRequest(await _localizationService.Translate(userId, ex.Message)); } finally { await _eventHub.SendMessageToAsync(MessageFactory.NotificationProgress, - MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(User.GetUserId(), "send-to-device-status"), + MessageFactory.SendingToDeviceEvent(await _localizationService.Translate(userId, "send-to-device-status"), "ended"), userId); } - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-send-to")); + return BadRequest(await _localizationService.Translate(userId, "generic-send-to")); } } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 05fd7ea27..5a249c9a8 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Downloads; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Services; using API.SignalR; @@ -140,7 +141,7 @@ public class DownloadController : BaseApiController var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId); try { - return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series!.Name} - Chapter {chapter.Number}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series!.Name} - Chapter {chapter.GetNumberTitle()}.zip"); } catch (KavitaException ex) { @@ -157,7 +158,8 @@ public class DownloadController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, $"Downloading {filename}", 0F, "started")); - if (files.Count == 1) + + if (files.Count == 1 && files.First().Format != MangaFormat.Image) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, @@ -166,15 +168,17 @@ public class DownloadController : BaseApiController } var filePath = _archiveService.CreateZipFromFoldersForDownload(files.Select(c => c.FilePath).ToList(), tempFolder, ProgressCallback); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(username, filename, "Download Complete", 1F, "ended")); + return PhysicalFile(filePath, DefaultContentType, Uri.EscapeDataString(downloadName), true); async Task ProgressCallback(Tuple progressInfo) { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.DownloadProgressEvent(username, filename, $"Extracting {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", + MessageFactory.DownloadProgressEvent(username, filename, $"Processing {Path.GetFileNameWithoutExtension(progressInfo.Item1)}", Math.Clamp(progressInfo.Item2, 0F, 1F))); } } @@ -192,8 +196,10 @@ public class DownloadController : BaseApiController public async Task DownloadSeries(int seriesId) { if (!await HasDownloadPermission()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) return BadRequest("Invalid Series"); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); try { diff --git a/API/Controllers/EmailController.cs b/API/Controllers/EmailController.cs new file mode 100644 index 000000000..c1e3ad413 --- /dev/null +++ b/API/Controllers/EmailController.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.Email; +using API.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Authorize(Policy = "RequireAdminRole")] +public class EmailController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public EmailController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + [HttpGet("all")] + public async Task>> GetEmails() + { + return Ok(await _unitOfWork.EmailHistoryRepository.GetEmailDtos(UserParams.Default)); + } +} diff --git a/API/Controllers/FilterController.cs b/API/Controllers/FilterController.cs index e8cb71117..7fcffb7da 100644 --- a/API/Controllers/FilterController.cs +++ b/API/Controllers/FilterController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs.Dashboard; @@ -9,7 +10,9 @@ using API.DTOs.Filtering.v2; using API.Entities; using API.Extensions; using API.Helpers; +using API.Services; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -21,10 +24,17 @@ namespace API.Controllers; public class FilterController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IStreamService _streamService; + private readonly ILogger _logger; - public FilterController(IUnitOfWork unitOfWork) + public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService, + ILogger logger) { _unitOfWork = unitOfWork; + _localizationService = localizationService; + _streamService = streamService; + _logger = logger; } /// @@ -37,6 +47,7 @@ public class FilterController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.SmartFilters); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); if (string.IsNullOrWhiteSpace(dto.Name)) return BadRequest("Name must be set"); if (Seed.DefaultStreams.Any(s => s.Name.Equals(dto.Name, StringComparison.InvariantCultureIgnoreCase))) @@ -78,6 +89,8 @@ public class FilterController : BaseApiController [HttpDelete] public async Task DeleteFilter(int filterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); if (filter == null) return Ok(); // This needs to delete any dashboard filters that have it too @@ -113,4 +126,57 @@ public class FilterController : BaseApiController { return Ok(SmartFilterHelper.Decode(dto.EncodedFilter)); } + + /// + /// Rename a Smart Filter given the filterId and new name + /// + /// + /// + /// + [HttpPost("rename")] + public async Task RenameFilter([FromQuery] int filterId, [FromQuery] string name) + { + try + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), + AppUserIncludes.SmartFilters); + if (user == null) return Unauthorized(); + + name = name.Trim(); + + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) + { + return BadRequest(await _localizationService.Translate(user.Id, "permission-denied")); + } + + if (string.IsNullOrWhiteSpace(name)) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required")); + } + + if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) + { + return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name")); + } + + var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId); + if (filter == null) + { + return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found")); + } + + filter.Name = name; + _unitOfWork.AppUserSmartFilterRepository.Update(filter); + await _unitOfWork.CommitAsync(); + + await _streamService.RenameSmartFilterStreams(filter); + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + + } } diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 837ad999c..87e0542d1 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -7,6 +7,7 @@ using API.Data; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MimeTypes; @@ -25,15 +26,20 @@ public class ImageController : BaseApiController private readonly IDirectoryService _directoryService; private readonly IImageService _imageService; private readonly ILocalizationService _localizationService; + private readonly IReadingListService _readingListService; + private readonly ICoverDbService _coverDbService; /// public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryService, - IImageService imageService, ILocalizationService localizationService) + IImageService imageService, ILocalizationService localizationService, + IReadingListService readingListService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _directoryService = directoryService; _imageService = imageService; _localizationService = localizationService; + _readingListService = readingListService; + _coverDbService = coverDbService; } /// @@ -60,7 +66,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("library-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["libraryId", "apiKey"])] public async Task GetLibraryCoverImage(int libraryId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -78,7 +84,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("volume-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["volumeId", "apiKey"])] public async Task GetVolumeCoverImage(int volumeId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -95,7 +101,7 @@ public class ImageController : BaseApiController /// /// Id of Series /// - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["seriesId", "apiKey"])] [HttpGet("series-cover")] public async Task GetSeriesCoverImage(int seriesId, string apiKey) { @@ -111,21 +117,23 @@ public class ImageController : BaseApiController } /// - /// Returns cover image for Collection Tag + /// Returns cover image for Collection /// /// /// [HttpGet("collection-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["collectionTagId", "apiKey"])] public async Task GetCollectionCoverImage(int collectionTagId, string apiKey) { 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)) { var destFile = await GenerateCollectionCoverImage(collectionTagId); if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); + return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile)); } @@ -140,15 +148,17 @@ public class ImageController : BaseApiController /// /// [HttpGet("readinglist-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["readingListId", "apiKey"])] public async Task GetReadingListCoverImage(int readingListId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return BadRequest(); + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) { - var destFile = await GenerateReadingListCoverImage(readingListId); + var destFile = await _readingListService.GenerateReadingListCoverImage(readingListId); if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile)); } @@ -157,22 +167,6 @@ public class ImageController : BaseApiController return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); } - private async Task GenerateReadingListCoverImage(int readingListId) - { - var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId); - var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, - ImageService.GetReadingListFormat(readingListId)); - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - destFile += settings.EncodeMediaAs.GetExtension(); - - if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; - ImageService.CreateMergedImage( - covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), - settings.CoverImageSize, - destFile); - return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; - } - private async Task GenerateCollectionCoverImage(int collectionId) { var covers = await _unitOfWork.CollectionTagRepository.GetRandomCoverImagesAsync(collectionId); @@ -180,11 +174,13 @@ public class ImageController : BaseApiController ImageService.GetCollectionTagFormat(collectionId)); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); destFile += settings.EncodeMediaAs.GetExtension(); + if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; ImageService.CreateMergedImage( covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), settings.CoverImageSize, destFile); + // TODO: Refactor this so that collections have a dedicated cover image so we can calculate primary/secondary colors return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; } @@ -197,7 +193,8 @@ public class ImageController : BaseApiController /// API Key for user. Needed to authenticate request /// [HttpGet("bookmark")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey" + ])] public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -219,7 +216,7 @@ public class ImageController : BaseApiController /// /// [HttpGet("web-link")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = new []{"url", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["url", "apiKey"])] public async Task GetWebLinkImage(string url, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -236,7 +233,47 @@ public class ImageController : BaseApiController try { domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.FaviconDirectory, - await _imageService.DownloadFaviconAsync(url, encodeFormat)); + await _coverDbService.DownloadFaviconAsync(url, encodeFormat)); + } + catch (Exception) + { + return BadRequest(await _localizationService.Translate(userId, "generic-favicon")); + } + } + + var file = new FileInfo(domainFilePath); + var format = Path.GetExtension(file.FullName); + + return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName)); + } + + + /// + /// Returns the image associated with a publisher + /// + /// + /// + /// + [HttpGet("publisher")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["publisherName", "apiKey"])] + public async Task GetPublisherImage(string publisherName, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + if (string.IsNullOrEmpty(publisherName)) return BadRequest(await _localizationService.Translate(userId, "must-be-defined", "publisherName")); + if (publisherName.Contains("..")) return BadRequest(); + + var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs; + + // Check if the domain exists + var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, ImageService.GetPublisherFormat(publisherName, encodeFormat)); + if (!_directoryService.FileSystem.File.Exists(domainFilePath)) + { + // We need to request the favicon and save it + try + { + domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, + await _coverDbService.DownloadPublisherImageAsync(publisherName, encodeFormat)); } catch (Exception) { @@ -250,6 +287,43 @@ public class ImageController : BaseApiController return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName)); } + /// + /// Returns cover image for Person + /// + /// + /// + [HttpGet("person-cover")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["personId", "apiKey"])] + public async Task GetPersonCoverImage(int personId, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.PersonRepository.GetCoverImageAsync(personId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); + var format = _directoryService.FileSystem.Path.GetExtension(path); + + return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); + } + + /// + /// Returns cover image for Person + /// + /// + /// + [HttpGet("person-cover-by-name")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["personId", "apiKey"])] + public async Task GetPersonCoverImageByName(string name, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.PersonRepository.GetCoverImageByNameAsync(name)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); + var format = _directoryService.FileSystem.Path.GetExtension(path); + + return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); + } + /// /// Returns a temp coverupload image /// @@ -257,7 +331,7 @@ public class ImageController : BaseApiController /// [Authorize(Policy="RequireAdminRole")] [HttpGet("cover-upload")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"filename", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["filename", "apiKey"])] public async Task GetCoverUploadImage(string filename, string apiKey) { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 57e30ad02..dbd809cee 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -20,9 +20,10 @@ using API.Services.Tasks.Scanner; using API.SignalR; using AutoMapper; using EasyCaching.Core; +using Hangfire; +using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; @@ -79,10 +80,10 @@ public class LibraryController : BaseApiController .WithFolders(dto.Folders.Select(x => new FolderPath {Path = x}).Distinct().ToList()) .WithFolderWatching(dto.FolderWatching) .WithIncludeInDashboard(dto.IncludeInDashboard) - .WithIncludeInRecommended(dto.IncludeInRecommended) .WithManageCollections(dto.ManageCollections) .WithManageReadingLists(dto.ManageReadingLists) - .WIthAllowScrobbling(dto.AllowScrobbling) + .WithAllowScrobbling(dto.AllowScrobbling) + .WithAllowMetadataMatching(dto.AllowMetadataMatching) .Build(); library.LibraryFileTypes = dto.FileGroupTypes @@ -134,13 +135,19 @@ public class LibraryController : BaseApiController if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); - await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); + + if (library.FolderWatching) + { + await _libraryWatcher.RestartWatching(); + } + + BackgroundJob.Enqueue(() => _taskScheduler.ScanLibrary(library.Id, false)); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); - await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); + return Ok(); } @@ -167,11 +174,35 @@ 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)); + + 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(); @@ -183,7 +214,6 @@ public class LibraryController : BaseApiController var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username); await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24)); - _logger.LogDebug("Caching libraries for {Key}", cacheKey); return Ok(ret); } @@ -268,7 +298,23 @@ public class LibraryController : BaseApiController public async Task Scan(int libraryId, bool force = false) { if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId")); - _taskScheduler.ScanLibrary(libraryId, force); + await _taskScheduler.ScanLibrary(libraryId, force); + return Ok(); + } + + /// + /// Enqueues a bunch of library scans + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("scan-multiple")] + public async Task ScanMultiple(BulkActionDto dto) + { + foreach (var libraryId in dto.Ids) + { + await _taskScheduler.ScanLibrary(libraryId, dto.Force ?? false); + } + return Ok(); } @@ -287,17 +333,63 @@ public class LibraryController : BaseApiController [Authorize(Policy = "RequireAdminRole")] [HttpPost("refresh-metadata")] - public ActionResult RefreshMetadata(int libraryId, bool force = true) + public ActionResult RefreshMetadata(int libraryId, bool force = true, bool forceColorscape = true) { - _taskScheduler.RefreshMetadata(libraryId, force); + _taskScheduler.RefreshMetadata(libraryId, force, forceColorscape); return Ok(); } [Authorize(Policy = "RequireAdminRole")] - [HttpPost("analyze")] - public ActionResult Analyze(int libraryId) + [HttpPost("refresh-metadata-multiple")] + public ActionResult RefreshMetadataMultiple(BulkActionDto dto, bool forceColorscape = true) { - _taskScheduler.AnalyzeFilesForLibrary(libraryId, true); + foreach (var libraryId in dto.Ids) + { + _taskScheduler.RefreshMetadata(libraryId, dto.Force ?? false, forceColorscape); + } + + return Ok(); + } + + /// + /// Copy the library settings (adv tab + optional type) to a set of other libraries. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("copy-settings-from")] + public async Task CopySettingsFromLibraryToLibraries(CopySettingsFromLibraryDto dto) + { + var sourceLibrary = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.SourceLibraryId, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes); + if (sourceLibrary == null) return BadRequest("SourceLibraryId must exist"); + + var libraries = await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.TargetLibraryIds, LibraryIncludes.ExcludePatterns | LibraryIncludes.FileTypes | LibraryIncludes.Folders); + foreach (var targetLibrary in libraries) + { + UpdateLibrarySettings(new UpdateLibraryDto() + { + Folders = targetLibrary.Folders.Select(s => s.Path), + Name = targetLibrary.Name, + Id = targetLibrary.Id, + Type = sourceLibrary.Type, + AllowScrobbling = sourceLibrary.AllowScrobbling, + ExcludePatterns = sourceLibrary.LibraryExcludePatterns.Select(p => p.Pattern).ToList(), + FolderWatching = sourceLibrary.FolderWatching, + ManageCollections = sourceLibrary.ManageCollections, + FileGroupTypes = sourceLibrary.LibraryFileTypes.Select(t => t.FileTypeGroup).ToList(), + IncludeInDashboard = sourceLibrary.IncludeInDashboard, + IncludeInSearch = sourceLibrary.IncludeInSearch, + ManageReadingLists = sourceLibrary.ManageReadingLists + }, targetLibrary, dto.IncludeType); + } + + await _unitOfWork.CommitAsync(); + + if (sourceLibrary.FolderWatching) + { + BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching()); + } + return Ok(); } @@ -327,20 +419,65 @@ public class LibraryController : BaseApiController .Distinct() .Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); - var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, - new List() {dto.FolderPath}); + var seriesFolder = _directoryService.FindHighestDirectoriesFromFiles(libraryFolder, [dto.FolderPath]); _taskScheduler.ScanFolder(seriesFolder.Keys.Count == 1 ? seriesFolder.Keys.First() : dto.FolderPath); return Ok(); } + /// + /// Deletes the library and all series within it. + /// + /// This does not touch any files + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpDelete("delete")] public async Task> DeleteLibrary(int libraryId) + { + _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, User.GetUsername()); + + try + { + return Ok(await DeleteLibrary(libraryId, User.GetUserId())); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes multiple libraries and all series within it. + /// + /// This does not touch any files + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("delete-multiple")] + public async Task> DeleteMultipleLibraries([FromQuery] List libraryIds) { var username = User.GetUsername(); - _logger.LogInformation("Library {LibraryId} is being deleted by {UserName}", libraryId, username); + _logger.LogInformation("Libraries {LibraryIds} are being deleted by {UserName}", libraryIds, username); + + foreach (var libraryId in libraryIds) + { + try + { + await DeleteLibrary(libraryId, User.GetUserId()); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + + return Ok(); + } + + private async Task DeleteLibrary(int libraryId, int userId) + { var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); var seriesIds = series.Select(x => x.Id).ToArray(); var chapterIds = @@ -351,16 +488,19 @@ public class LibraryController : BaseApiController if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) { _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); - return BadRequest(await _localizationService.Translate(User.GetUserId(), "delete-library-while-scan")); + throw new KavitaException(await _localizationService.Translate(userId, "delete-library-while-scan")); } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); - if (library == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "library-doesnt-exist")); + if (library == null) + { + throw new KavitaException(await _localizationService.Translate(userId, "library-doesnt-exist")); + } + // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library // Aka SeriesRelation has an invalid foreign key - foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id, - SeriesIncludes.Related)) + foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id, SeriesIncludes.Related)) { s.Relations = new List(); _unitOfWork.SeriesRepository.Update(s); @@ -377,7 +517,7 @@ public class LibraryController : BaseApiController await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, - MessageFactory.SideNavUpdateEvent(User.GetUserId()), false); + MessageFactory.SideNavUpdateEvent(userId), false); if (chapterIds.Any()) { @@ -386,7 +526,7 @@ public class LibraryController : BaseApiController _taskScheduler.CleanupChapters(chapterIds); } - await _libraryWatcher.RestartWatching(); + BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching()); foreach (var seriesId in seriesIds) { @@ -396,13 +536,13 @@ public class LibraryController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false); - return Ok(true); + return true; } catch (Exception ex) { _logger.LogError(ex, "There was a critical issue. Please try again"); await _unitOfWork.RollbackAsync(); - return Ok(false); + return false; } } @@ -444,14 +584,46 @@ public class LibraryController : BaseApiController var typeUpdate = library.Type != dto.Type; var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching; - library.Type = dto.Type; + UpdateLibrarySettings(dto, library); + + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update")); + + if (folderWatchingUpdate || originalFoldersCount != dto.Folders.Count() || typeUpdate) + { + BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching()); + } + + if (originalFoldersCount != dto.Folders.Count() || typeUpdate) + { + await _taskScheduler.ScanLibrary(library.Id); + } + + await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, + MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); + + await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, + MessageFactory.SideNavUpdateEvent(userId), false); + + await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); + + return Ok(); + + } + + private void UpdateLibrarySettings(UpdateLibraryDto dto, Library library, bool updateType = true) + { + if (updateType) + { + library.Type = dto.Type; + } + library.FolderWatching = dto.FolderWatching; library.IncludeInDashboard = dto.IncludeInDashboard; - library.IncludeInRecommended = dto.IncludeInRecommended; library.IncludeInSearch = dto.IncludeInSearch; library.ManageCollections = dto.ManageCollections; library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; + library.AllowMetadataMatching = dto.AllowMetadataMatching; library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() @@ -463,7 +635,7 @@ public class LibraryController : BaseApiController .ToList(); // Override Scrobbling for Comic libraries since there are no providers to scrobble to - if (library.Type == LibraryType.Comic) + if (library.Type is LibraryType.Comic or LibraryType.ComicVine) { _logger.LogInformation("Overrode Library {Name} to disable scrobbling since there are no providers for Comics", dto.Name.Replace(Environment.NewLine, string.Empty)); library.AllowScrobbling = false; @@ -471,28 +643,6 @@ public class LibraryController : BaseApiController _unitOfWork.LibraryRepository.Update(library); - - if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update")); - if (originalFoldersCount != dto.Folders.Count() || typeUpdate) - { - await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); - } - - if (folderWatchingUpdate) - { - await _libraryWatcher.RestartWatching(); - } - await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, - MessageFactory.LibraryModifiedEvent(library.Id, "update"), false); - - await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, - MessageFactory.SideNavUpdateEvent(userId), false); - - await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey); - - return Ok(); - } /// diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs index 30c85d22c..30ed68771 100644 --- a/API/Controllers/LicenseController.cs +++ b/API/Controllers/LicenseController.cs @@ -2,14 +2,17 @@ using System.Threading.Tasks; using API.Constants; using API.Data; -using API.DTOs.License; +using API.DTOs.KavitaPlus.License; using API.Entities.Enums; using API.Extensions; using API.Services; using API.Services.Plus; +using EasyCaching.Core; +using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers; @@ -20,7 +23,8 @@ public class LicenseController( ILogger logger, ILicenseService licenseService, ILocalizationService localizationService, - ITaskScheduler taskScheduler) + ITaskScheduler taskScheduler, + IEasyCachingProviderFactory cachingProviderFactory) : BaseApiController { /// @@ -31,13 +35,22 @@ public class LicenseController( [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] public async Task> HasValidLicense(bool forceCheck = false) { + var result = await licenseService.HasActiveLicense(forceCheck); - await taskScheduler.ScheduleKavitaPlusTasks(); + + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + var cacheValue = await licenseInfoProvider.GetAsync(LicenseService.CacheKey); + + if (result && !cacheValue.IsNull && !cacheValue.Value) + { + await taskScheduler.ScheduleKavitaPlusTasks(); + } + return Ok(result); } /// - /// Has any license + /// Has any license registered with the instance. Does not check Kavita+ API /// /// [Authorize("RequireAdminRole")] @@ -49,6 +62,30 @@ public class LicenseController( (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value)); } + /// + /// Asks Kavita+ for the latest license info + /// + /// Force checking the API and skip the 8 hour cache + /// + [Authorize("RequireAdminRole")] + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] + public async Task> GetLicenseInfo(bool forceCheck = false) + { + try + { + return Ok(await licenseService.GetLicenseInfo(forceCheck)); + } + catch (Exception) + { + return Ok(null); + } + } + + /// + /// Remove the Kavita+ License on the Server + /// + /// [Authorize("RequireAdminRole")] [HttpDelete] [ResponseCache(CacheProfileName = ResponseCacheProfiles.LicenseCache)] @@ -59,10 +96,13 @@ public class LicenseController( setting.Value = null; unitOfWork.SettingsRepository.Update(setting); await unitOfWork.CommitAsync(); - await taskScheduler.ScheduleKavitaPlusTasks(); + + TaskScheduler.RemoveKavitaPlusTasks(); + return Ok(); } + [Authorize("RequireAdminRole")] [HttpPost("reset")] public async Task ResetLicense(UpdateLicenseDto dto) diff --git a/API/Controllers/LocaleController.cs b/API/Controllers/LocaleController.cs index d96419b0f..6e3a2ec78 100644 --- a/API/Controllers/LocaleController.cs +++ b/API/Controllers/LocaleController.cs @@ -2,9 +2,16 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.DTOs; using API.DTOs.Filtering; using API.Services; +using EasyCaching.Core; +using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; namespace API.Controllers; @@ -13,38 +20,34 @@ namespace API.Controllers; public class LocaleController : BaseApiController { private readonly ILocalizationService _localizationService; + private readonly IEasyCachingProvider _localeCacheProvider; - public LocaleController(ILocalizationService localizationService) + private static readonly string CacheKey = "locales_" + BuildInfo.Version; + + public LocaleController(ILocalizationService localizationService, IEasyCachingProviderFactory cachingProviderFactory) { _localizationService = localizationService; + _localeCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LocaleOptions); } + /// + /// Returns all applicable locales on the server + /// + /// This can be cached as it will not change per version. + /// + [AllowAnonymous] [HttpGet] - public ActionResult> GetAllLocales() + public async Task>> GetAllLocales() { - var languages = _localizationService.GetLocales().Select(c => - { - try - { - var cult = new CultureInfo(c); - return new LanguageDto() - { - Title = cult.DisplayName, - IsoCode = cult.IetfLanguageTag - }; - } - catch (Exception ex) - { - // Some OS' don't have all culture codes supported like PT_BR, thus we need to default - return new LanguageDto() - { - Title = c, - IsoCode = c - }; - } - }) - .Where(l => !string.IsNullOrEmpty(l.IsoCode)) - .OrderBy(d => d.Title); - return Ok(languages); + var result = await _localeCacheProvider.GetAsync>(CacheKey); + if (result.HasValue) + { + return Ok(result.Value); + } + + var ret = _localizationService.GetLocales().Where(l => l.TranslationCompletion > 0f); + await _localeCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromDays(1)); + + return Ok(ret); } } diff --git a/API/Controllers/ManageController.cs b/API/Controllers/ManageController.cs new file mode 100644 index 000000000..3641ddd74 --- /dev/null +++ b/API/Controllers/ManageController.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.DTOs.KavitaPlus.Manage; +using API.Services.Plus; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +/// +/// All things centered around Managing the Kavita instance, that isn't aligned with an entity +/// +[Authorize("RequireAdminRole")] +public class ManageController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILicenseService _licenseService; + + public ManageController(IUnitOfWork unitOfWork, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _licenseService = licenseService; + } + + /// + /// Returns a list of all Series that is Kavita+ applicable to metadata match and the status of it + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("series-metadata")] + public async Task>> SeriesMetadata(ManageMatchFilterDto filter) + { + if (!await _licenseService.HasActiveLicense()) return Ok(Array.Empty()); + + return Ok(await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeries(filter)); + } +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 24dedef47..9757186bb 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; @@ -12,6 +13,7 @@ using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Plus; using Kavita.Common.Extensions; @@ -31,18 +33,17 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// Fetches genres from the instance /// /// String separated libraryIds or null for all genres + /// Context from which this API was invoked /// [HttpGet("genres")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = new []{"libraryIds"})] - public async Task>> GetAllGenres(string? libraryIds) + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Instant, VaryByQueryKeys = ["libraryIds", "context"])] + public async Task>> GetAllGenres(string? libraryIds, QueryContext context = QueryContext.None) { - var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); - if (ids is {Count: > 0}) - { - return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, User.GetUserId())); - } + var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(int.Parse) + .ToList(); - return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosAsync(User.GetUserId())); + return Ok(await unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(User.GetUserId(), ids, context)); } /// @@ -71,9 +72,9 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids is {Count: > 0}) { - return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, User.GetUserId())); + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); } - return Ok(await unitOfWork.PersonRepository.GetAllPersonDtosAsync(User.GetUserId())); + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId())); } /// @@ -88,9 +89,9 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); if (ids is {Count: > 0}) { - return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, User.GetUserId())); + return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId(), ids)); } - return Ok(await unitOfWork.TagRepository.GetAllTagDtosAsync(User.GetUserId())); + return Ok(await unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(User.GetUserId())); } /// @@ -122,7 +123,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// String separated libraryIds or null for all publication status /// This API is cached for 1 hour, varying by libraryIds /// - [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])] [HttpGet("publication-status")] public ActionResult> GetAllPublicationStatus(string? libraryIds) { @@ -146,7 +147,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// String separated libraryIds or null for all ratings /// [HttpGet("languages")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new []{"libraryIds"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = ["libraryIds"])] public async Task>> GetAllLanguages(string? libraryIds) { var ids = libraryIds?.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToList(); @@ -169,20 +170,21 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc }).Where(l => !string.IsNullOrEmpty(l.IsoCode)); } - /// - /// Returns summary for the chapter + /// Given a language code returns the display name /// - /// + /// /// - [HttpGet("chapter-summary")] - public async Task> GetChapterSummary(int chapterId) + [HttpGet("language-title")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["code"])] + public ActionResult GetLanguageTitle(string code) { - // TODO: This doesn't seem used anywhere - if (chapterId <= 0) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - if (chapter == null) return BadRequest(await localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - return Ok(chapter.Summary); + if (string.IsNullOrEmpty(code)) return BadRequest("Code must be provided"); + + return CultureInfo.GetCultures(CultureTypes.AllCultures) + .Where(l => code.Equals(l.IetfLanguageTag)) + .Select(c => c.DisplayName) + .FirstOrDefault(); } /// @@ -191,12 +193,12 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc /// /// /// - [HttpPost("force-refresh")] - public async Task ForceRefresh(int seriesId) - { - await metadataService.ForceKavitaPlusRefresh(seriesId); - return Ok(); - } + // [HttpPost("force-refresh")] + // public async Task ForceRefresh(int seriesId) + // { + // await metadataService.ForceKavitaPlusRefresh(seriesId); + // return Ok(); + // } /// /// Fetches the details needed from Kavita+ for Series Detail page @@ -224,7 +226,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc var isAdmin = User.IsInRole(PolicyConstants.AdminRole); var user = await unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId())!; - userReviews.AddRange(ReviewService.SelectSpectrumOfReviews(ret.Reviews.ToList())); + userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(ret.Reviews.ToList())); ret.Reviews = userReviews; if (!isAdmin && ret.Recommendations != null && user != null) diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 2482ef714..fcc4ca58f 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -4,24 +4,30 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; +using System.Xml; using System.Xml.Serialization; 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; using API.Extensions; using API.Helpers; using API.Services; +using API.Services.Tasks.Scanner.Parser; +using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using MimeTypes; namespace API.Controllers; @@ -31,6 +37,7 @@ namespace API.Controllers; [AllowAnonymous] public class OpdsController : BaseApiController { + private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; @@ -39,6 +46,7 @@ public class OpdsController : BaseApiController private readonly ISeriesService _seriesService; private readonly IAccountService _accountService; private readonly ILocalizationService _localizationService; + private readonly IMapper _mapper; private readonly XmlSerializer _xmlSerializer; @@ -69,13 +77,14 @@ public class OpdsController : BaseApiController }; private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto(); - private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default; + private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default; private const int PageSize = 20; public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, ISeriesService seriesService, - IAccountService accountService, ILocalizationService localizationService) + IAccountService accountService, ILocalizationService localizationService, + IMapper mapper, ILogger logger) { _unitOfWork = unitOfWork; _downloadService = downloadService; @@ -85,6 +94,8 @@ public class OpdsController : BaseApiController _seriesService = seriesService; _accountService = accountService; _localizationService = localizationService; + _mapper = mapper; + _logger = logger; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -183,10 +194,11 @@ public class OpdsController : BaseApiController { Text = stream.Name }, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{stream.SmartFilterId}/"), - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/smart-filters/{stream.SmartFilterId}/") + ] }); break; } @@ -286,7 +298,7 @@ public class OpdsController : BaseApiController { var baseUrl = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BaseUrl)).Value; var prefix = "/api/opds/"; - if (!Configuration.DefaultBaseUrl.Equals(baseUrl)) + if (!Configuration.DefaultBaseUrl.Equals(baseUrl, StringComparison.InvariantCultureIgnoreCase)) { // We need to update the Prefix to account for baseUrl prefix = baseUrl + "api/opds/"; @@ -299,7 +311,7 @@ public class OpdsController : BaseApiController /// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records. /// /// - [HttpGet("{apiKey}/smart-filter/{filterId}")] + [HttpGet("{apiKey}/smart-filters/{filterId}")] [Produces("application/xml")] public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 0) { @@ -311,8 +323,8 @@ public class OpdsController : BaseApiController var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId); if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist")); - var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilter-" + filter.Id), $"{prefix}{apiKey}/smart-filter/{filter.Id}/", apiKey, prefix); - SetFeedId(feed, "smartFilter-" + filter.Id); + var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters-" + filter.Id), $"{apiKey}/smart-filters/{filter.Id}/", apiKey, prefix); + SetFeedId(feed, "smartFilters-" + filter.Id); var decodedFilter = SmartFilterHelper.Decode(filter.Filter); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), @@ -324,7 +336,7 @@ public class OpdsController : BaseApiController feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)); } - AddPagination(feed, series, $"{prefix}{apiKey}/smart-filter/{filterId}/"); + AddPagination(feed, series, $"{prefix}{apiKey}/smart-filters/{filterId}/"); return CreateXmlResult(SerializeXml(feed)); } @@ -338,18 +350,20 @@ public class OpdsController : BaseApiController var (_, prefix) = await GetPrefix(); var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId); - var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{prefix}{apiKey}/smart-filters", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix); SetFeedId(feed, "smartFilters"); + foreach (var filter in filters) { feed.Entries.Add(new FeedEntry() { Id = filter.Id.ToString(), Title = filter.Name, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filter/{filter.Id}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/smart-filters/{filter.Id}") + ] }); } @@ -367,7 +381,7 @@ public class OpdsController : BaseApiController var (_, prefix) = await GetPrefix(); var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId); - var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{prefix}{apiKey}/external-sources", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{apiKey}/external-sources", apiKey, prefix); SetFeedId(feed, "externalSources"); foreach (var externalSource in externalSources) { @@ -397,7 +411,7 @@ public class OpdsController : BaseApiController if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (baseUrl, prefix) = await GetPrefix(); - var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{apiKey}/libraries", apiKey, prefix); SetFeedId(feed, "libraries"); // Ensure libraries follow SideNav order @@ -408,12 +422,15 @@ public class OpdsController : BaseApiController { Id = library!.Id.ToString(), Title = library.Name!, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/libraries/{library.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}") + ] }); } @@ -448,29 +465,31 @@ 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 feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix); + var (baseUrl, prefix) = await GetPrefix(); + var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix); SetFeedId(feed, "collections"); + feed.Entries.AddRange(tags.Select(tag => new FeedEntry() { Id = tag.Id.ToString(), Title = tag.Title, Summary = tag.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{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)); @@ -487,20 +506,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"); } @@ -508,7 +516,7 @@ public class OpdsController : BaseApiController var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, GetUserParams(pageNumber)); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id)); - var feed = CreateFeed(tag.Title + " Collection", $"{prefix}{apiKey}/collections/{collectionId}", apiKey, prefix); + var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey, prefix); SetFeedId(feed, $"collections-{collectionId}"); AddPagination(feed, series, $"{prefix}{apiKey}/collections/{collectionId}"); @@ -534,8 +542,10 @@ public class OpdsController : BaseApiController true, GetUserParams(pageNumber), false); - var feed = CreateFeed("All Reading Lists", $"{prefix}{apiKey}/reading-list", apiKey, prefix); + var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix); SetFeedId(feed, "reading-list"); + AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/"); + foreach (var readingListDto in readingLists) { feed.Entries.Add(new FeedEntry() @@ -543,15 +553,19 @@ public class OpdsController : BaseApiController Id = readingListDto.Id.ToString(), Title = readingListDto.Title, Summary = readingListDto.Summary, - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}") + ] }); } + return CreateXmlResult(SerializeXml(feed)); } @@ -566,31 +580,50 @@ public class OpdsController : BaseApiController [HttpGet("{apiKey}/reading-list/{readingListId}")] [Produces("application/xml")] - public async Task GetReadingListItems(int readingListId, string apiKey) + public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) { 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); - var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems); - if (userWithLists == null) return Unauthorized(); - var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + { + return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); + } + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) + { + return Unauthorized(); + } + + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id); if (readingList == null) { return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted")); } - var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{prefix}{apiKey}/reading-list/{readingListId}", apiKey, prefix); + var (baseUrl, prefix) = await GetPrefix(); + var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); SetFeedId(feed, $"reading-list-{readingListId}"); - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); + var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); foreach (var item in items) { - feed.Entries.Add( - CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", - string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl)); + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId); + + // If there is only one file underneath, add a direct acquisition link, otherwise add a subsection + if (chapterDto != null && chapterDto.Files.Count == 1) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId); + feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId, + chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl)); + } + else + { + feed.Entries.Add( + CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", + item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl)); + } + } return CreateXmlResult(SerializeXml(feed)); } @@ -647,7 +680,7 @@ public class OpdsController : BaseApiController var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id)); - var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{prefix}{apiKey}/recently-added", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{apiKey}/recently-added", apiKey, prefix); SetFeedId(feed, "recently-added"); AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added"); @@ -671,7 +704,7 @@ public class OpdsController : BaseApiController var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber)); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id)); - var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{prefix}{apiKey}/more-in-genre", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{apiKey}/more-in-genre", apiKey, prefix); SetFeedId(feed, "more-in-genre"); AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/more-in-genre"); @@ -694,9 +727,8 @@ public class OpdsController : BaseApiController var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, PageSize)).ToList(); var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.SeriesId)); - var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{prefix}{apiKey}/recently-updated", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{apiKey}/recently-updated", apiKey, prefix); SetFeedId(feed, "recently-updated"); - //AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/recently-updated"); foreach (var groupedSeries in seriesDtos) { @@ -730,7 +762,7 @@ public class OpdsController : BaseApiController Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{prefix}{apiKey}/on-deck", apiKey, prefix); + var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{apiKey}/on-deck", apiKey, prefix); SetFeedId(feed, "on-deck"); AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck"); @@ -742,6 +774,12 @@ public class OpdsController : BaseApiController return CreateXmlResult(SerializeXml(feed)); } + /// + /// OPDS Search endpoint + /// + /// + /// + /// [HttpGet("{apiKey}/series")] [Produces("application/xml")] public async Task SearchSeries(string apiKey, [FromQuery] string query) @@ -759,20 +797,21 @@ public class OpdsController : BaseApiController query = query.Replace(@"%", string.Empty); // Get libraries user has access to var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList(); - if (!libraries.Any()) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); + if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted")); var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query); + var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, + libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false); - var feed = CreateFeed(query, $"{prefix}{apiKey}/series?query=" + query, apiKey, prefix); + var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix); SetFeedId(feed, "search-series"); - foreach (var seriesDto in series.Series) + foreach (var seriesDto in searchResults.Series) { feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl)); } - foreach (var collection in series.Collections) + foreach (var collection in searchResults.Collections) { feed.Entries.Add(new FeedEntry() { @@ -791,7 +830,7 @@ public class OpdsController : BaseApiController }); } - foreach (var readingListDto in series.ReadingLists) + foreach (var readingListDto in searchResults.ReadingLists) { feed.Entries.Add(new FeedEntry() { @@ -805,6 +844,7 @@ public class OpdsController : BaseApiController }); } + // TODO: Search should allow Chapters/Files and more return CreateXmlResult(SerializeXml(feed)); } @@ -849,45 +889,64 @@ public class OpdsController : BaseApiController var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var feed = CreateFeed(series!.Name + " - Storyline", $"{prefix}{apiKey}/series/{series.Id}", apiKey, prefix); + var feed = CreateFeed(series!.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}"); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); + var chapterDict = new Dictionary(); + var fileDict = new Dictionary(); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); foreach (var volume in seriesDetail.Volumes) { - var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id)).OrderBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), - _chapterSortComparer); + var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People); - foreach (var chapterId in chapters.Select(c => c.Id)) + foreach (var chapter in chaptersForVolume) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); - foreach (var mangaFile in files) + var chapterId = chapter.Id; + if (!chapterDict.TryAdd(chapterId, 0)) continue; + + var chapterDto = _mapper.Map(chapter); + foreach (var mangaFile in chapter.Files) { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, + chapterDto, apiKey, prefix, baseUrl)); } } - } - foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial)) + var chapters = seriesDetail.StorylineChapters; + if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any()) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(storylineChapter.Id); - var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(storylineChapter.Id); + chapters = seriesDetail.Chapters; + } + + foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id))) + { + var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); + var chapterDto = _mapper.Map(chapter); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, storylineChapter.VolumeId, storylineChapter.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map(mangaFile), series, + chapterDto, apiKey, prefix, baseUrl)); } } foreach (var special in seriesDetail.Specials) { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id); - var chapterTest = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(special.Id); + var chapterDto = _mapper.Map(special); foreach (var mangaFile in files) { - feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, mangaFile, series, chapterTest, apiKey, prefix, baseUrl)); + // If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception + if (!fileDict.TryAdd(mangaFile.Id, 0)) continue; + + feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map(mangaFile), series, + chapterDto, apiKey, prefix, baseUrl)); } } @@ -904,18 +963,16 @@ public class OpdsController : BaseApiController var (baseUrl, prefix) = await GetPrefix(); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - var chapters = - (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture), - _chapterSortComparer); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ", - $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); + $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s"); - foreach (var chapter in chapters) + + foreach (var chapter in volume.Chapters) { - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); - var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id); - foreach (var mangaFile in files) + var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapter.Id, ChapterIncludes.Files | ChapterIncludes.People); + foreach (var mangaFile in chapterDto.Files) { feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapter.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl)); } @@ -932,17 +989,20 @@ public class OpdsController : BaseApiController if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) return BadRequest(await _localizationService.Translate(userId, "opds-disabled")); var (baseUrl, prefix) = await GetPrefix(); + var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); + var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People); + if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist")); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); - var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s", - $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix); SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files"); - foreach (var mangaFile in files) + + foreach (var mangaFile in chapter.Files) { feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl)); } @@ -968,7 +1028,7 @@ public class OpdsController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey)); if (!await _accountService.HasDownloadPermission(user)) { - return BadRequest("User does not have download permissions"); + return Forbid("User does not have download permissions"); } var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); @@ -986,7 +1046,7 @@ public class OpdsController : BaseApiController }; } - private static void AddPagination(Feed feed, PagedList list, string href) + private static void AddPagination(Feed feed, PagedList list, string href) { var url = href; if (href.Contains('?')) @@ -1032,22 +1092,21 @@ public class OpdsController : BaseApiController Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary) ? string.Empty : $" Summary: {metadata.Summary}"), - Authors = metadata.Writers.Select(p => new FeedAuthor() - { - Name = p.Name, - Uri = "http://opds-spec.org/author/" + p.Id - }).ToList(), + Authors = metadata.Writers.Select(CreateAuthor).ToList(), Categories = metadata.Genres.Select(g => new FeedCategory() { Label = g.Title, Term = string.Empty }).ToList(), - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{seriesDto.Id}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/series/{seriesDto.Id}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}") + ] }; } @@ -1058,35 +1117,49 @@ public class OpdsController : BaseApiController Id = searchResultDto.SeriesId.ToString(), Title = $"{searchResultDto.Name}", Summary = $"Format: {searchResultDto.Format}", - Links = new List() - { - CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"), - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}") - } + Links = + [ + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, + $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"), + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}") + ] }; } - private static FeedEntry CreateChapter(string apiKey, string title, string summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl) + private static FeedAuthor CreateAuthor(PersonDto person) + { + return new FeedAuthor() + { + Name = person.Name, + Uri = "http://opds-spec.org/author/" + person.Id + }; + } + + private static FeedEntry CreateChapter(string apiKey, string title, string? summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl) { return new FeedEntry() { Id = chapterId.ToString(), Title = title, Summary = summary ?? string.Empty, - Links = new List() - { + + Links = + [ CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, - $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"), + $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"), CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}") - } + ] }; } - private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) + private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId, + MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl) { var fileSize = mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) : @@ -1095,23 +1168,23 @@ public class OpdsController : BaseApiController var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath)); var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey)); + var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId); var title = $"{series.Name}"; - if (volume!.Chapters.Count == 1) + if (volume!.Chapters.Count == 1 && !volume.IsSpecial()) { var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty); - SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType, volumeLabel); - if (volume.Name != "0") + SeriesService.RenameVolumeName(volume, libraryType, volumeLabel); + if (!volume.IsLooseLeaf()) { title += $" - {volume.Name}"; } } - else if (volume.MinNumber != 0) + else if (!volume.IsLooseLeaf() && !volume.IsSpecial()) { - title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}"; + title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}"; } else { @@ -1130,23 +1203,33 @@ 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() - { - CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), - CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), - // We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly - accLink, - await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix) - }, + Links = + [ + CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, + $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), + CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, + $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"), + // We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly + accLink + ], Content = new FeedEntryContent() { Text = fileType, Type = "text" - } + }, + Authors = chapter.Writers.Select(CreateAuthor).ToList() }; + var canPageStream = mangaFile.Extension != ".epub"; + if (canPageStream) + { + entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix)); + } + return entry; } @@ -1167,7 +1250,7 @@ public class OpdsController : BaseApiController { var userId = await GetUser(apiKey); if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page")); - var chapter = await _cacheService.Ensure(chapterId); + var chapter = await _cacheService.Ensure(chapterId, true); if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "cache-file-find")); try @@ -1193,10 +1276,9 @@ public class OpdsController : BaseApiController SeriesId = seriesId, VolumeId = volumeId, LibraryId =libraryId - }, await GetUser(apiKey)); + }, userId); } - return File(content, MimeTypeMap.GetMimeType(format)); } catch (Exception) @@ -1228,8 +1310,7 @@ public class OpdsController : BaseApiController { try { - var user = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); - return user; + return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); } catch { @@ -1238,7 +1319,7 @@ public class OpdsController : BaseApiController throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist")); } - private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey, string prefix) + private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, string apiKey, string prefix) { var userId = await GetUser(apiKey); var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId); @@ -1247,12 +1328,14 @@ public class OpdsController : BaseApiController var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}"); link.TotalPages = mangaFile.Pages; + link.IsPageStream = true; + if (progress != null) { link.LastRead = progress.PageNum; link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601 } - link.IsPageStream = true; + return link; } @@ -1277,20 +1360,61 @@ public class OpdsController : BaseApiController { Title = title, Icon = $"{prefix}{apiKey}/favicon", - Links = new List() - { + Links = + [ link, CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"), CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search") - }, + ], }; } - private string SerializeXml(Feed feed) + private string SerializeXml(Feed? feed) { if (feed == null) return string.Empty; + + // Remove invalid XML characters from the feed object + SanitizeFeed(feed); + using var sm = new StringWriter(); _xmlSerializer.Serialize(sm, feed); - return sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds + + return ret; + } + + // Recursively sanitize all string properties in the object + private static void SanitizeFeed(object? obj) + { + if (obj == null) return; + + var properties = obj.GetType().GetProperties(); + foreach (var property in properties) + { + // Skip properties that require an index (e.g., indexed collections) + if (property.GetIndexParameters().Length > 0) + continue; + + if (property.PropertyType == typeof(string) && property.CanWrite) + { + var value = (string?)property.GetValue(obj); + if (!string.IsNullOrEmpty(value)) + { + property.SetValue(obj, RemoveInvalidXmlChars(value)); + } + } + else if (property.PropertyType.IsClass) // Handle nested objects + { + var nestedObject = property.GetValue(obj); + if (nestedObject != null) + SanitizeFeed(nestedObject); + } + } + } + + private static string RemoveInvalidXmlChars(string input) + { + return new string(input.Where(XmlConvert.IsXmlChar).ToArray()); } } 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/PersonController.cs b/API/Controllers/PersonController.cs new file mode 100644 index 000000000..1094a1137 --- /dev/null +++ b/API/Controllers/PersonController.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers; +using API.Services; +using API.Services.Tasks.Metadata; +using API.SignalR; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Nager.ArticleNumber; + +namespace API.Controllers; +#nullable enable + +public class PersonController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IMapper _mapper; + private readonly ICoverDbService _coverDbService; + private readonly IImageService _imageService; + private readonly IEventHub _eventHub; + + public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, + ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _mapper = mapper; + _coverDbService = coverDbService; + _imageService = imageService; + _eventHub = eventHub; + } + + + [HttpGet] + public async Task> GetPersonByName(string name) + { + return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); + } + + /// + /// Returns all roles for a Person + /// + /// + /// + [HttpGet("roles")] + public async Task>> GetRolesForPersonByName(int personId) + { + return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId())); + } + + /// + /// Returns a list of authors and artists for browsing + /// + /// + /// + [HttpPost("all")] + public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + return Ok(list); + } + + /// + /// Updates the Person + /// + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("update")] + public async Task> UpdatePerson(UpdatePersonDto dto) + { + // This needs to get all people and update them equally + var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); + if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + + if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); + + + // Validate the name is unique + if (dto.Name != person.Name && !(await _unitOfWork.PersonRepository.IsNameUnique(dto.Name))) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); + } + + person.Name = dto.Name?.Trim(); + person.Description = dto.Description ?? string.Empty; + person.CoverImageLocked = dto.CoverImageLocked; + + if (dto.MalId is > 0) + { + person.MalId = (long) dto.MalId; + } + if (dto.AniListId is > 0) + { + person.AniListId = (int) dto.AniListId; + } + + if (!string.IsNullOrEmpty(dto.HardcoverId?.Trim())) + { + person.HardcoverId = dto.HardcoverId.Trim(); + } + + var asin = dto.Asin?.Trim(); + if (!string.IsNullOrEmpty(asin) && + (ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin))) + { + person.Asin = asin; + } + + _unitOfWork.PersonRepository.Update(person); + await _unitOfWork.CommitAsync(); + + return Ok(_mapper.Map(person)); + } + + /// + /// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita) + /// + /// + /// + [HttpPost("fetch-cover")] + public async Task> DownloadCoverImage([FromQuery] int personId) + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var person = await _unitOfWork.PersonRepository.GetPersonById(personId); + if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + + var personImage = await _coverDbService.DownloadPersonImageAsync(person, settings.EncodeMediaAs); + + if (string.IsNullOrEmpty(personImage)) + { + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-image-doesnt-exist")); + } + + person.CoverImage = personImage; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(person.Id, "person"), false); + + return Ok(personImage); + } + + /// + /// Returns the top 20 series that the "person" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort + /// + /// + /// + [HttpGet("series-known-for")] + public async Task>> GetKnownSeries(int personId) + { + return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); + } + + /// + /// Returns all individual chapters by role. Limited to 20 results. + /// + /// + /// + /// + [HttpGet("chapters-by-role")] + public async Task>> GetChaptersByRole(int personId, PersonRole role) + { + return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); + } + + +} diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index ce2e4eced..87cfaf2c2 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -46,6 +46,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService } var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId); logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId); + return new UserDto { Username = user.UserName!, diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 3d7ac75bf..207dbabb5 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; @@ -21,7 +21,6 @@ using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Tokens; using MimeTypes; namespace API.Controllers; @@ -68,11 +67,11 @@ public class ReaderController : BaseApiController /// /// [HttpGet("pdf")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "apiKey"})] - public async Task GetPdf(int chapterId, string apiKey) + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "apiKey"])] + public async Task GetPdf(int chapterId, string apiKey, bool extractPdf = false) { if (await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey) == 0) return BadRequest(); - var chapter = await _cacheService.Ensure(chapterId); + var chapter = await _cacheService.Ensure(chapterId, extractPdf); if (chapter == null) return NoContent(); // Validate the user has access to the PDF @@ -90,7 +89,7 @@ public class ReaderController : BaseApiController } catch (Exception) { - _cacheService.CleanupChapters(new []{ chapterId }); + _cacheService.CleanupChapters([chapterId]); throw; } } @@ -105,7 +104,8 @@ public class ReaderController : BaseApiController /// Should Kavita extract pdf into images. Defaults to false. /// [HttpGet("image")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "page", "extractPdf", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "page", "extractPdf", "apiKey" + ])] [AllowAnonymous] public async Task GetImage(int chapterId, int page, string apiKey, bool extractPdf = false) { @@ -117,7 +117,7 @@ public class ReaderController : BaseApiController { var chapter = await _cacheService.Ensure(chapterId, extractPdf); if (chapter == null) return NoContent(); - _logger.LogInformation("Fetching Page {PageNum} on Chapter {ChapterId}", page, chapterId); + var path = _cacheService.GetCachedPagePath(chapter.Id, page); if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-image-for-page", page)); @@ -127,7 +127,7 @@ public class ReaderController : BaseApiController } catch (Exception) { - _cacheService.CleanupChapters(new []{ chapterId }); + _cacheService.CleanupChapters([chapterId]); throw; } } @@ -140,7 +140,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("thumbnail")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "pageNum", "apiKey"])] [AllowAnonymous] public async Task GetThumbnail(int chapterId, int pageNum, string apiKey) { @@ -164,14 +164,14 @@ public class ReaderController : BaseApiController /// We must use api key as bookmarks could be leaked to other users via the API /// [HttpGet("bookmark-image")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"seriesId", "page", "apiKey"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "page", "apiKey"])] [AllowAnonymous] public async Task GetBookmarkImage(int seriesId, string apiKey, int page) { - if (page < 0) page = 0; var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); if (userId == 0) return Unauthorized(); + if (page < 0) page = 0; var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId); if (page > totalPages) { @@ -188,7 +188,7 @@ public class ReaderController : BaseApiController } catch (Exception) { - _cacheService.CleanupBookmarks(new []{ seriesId }); + _cacheService.CleanupBookmarks([seriesId]); throw; } } @@ -202,12 +202,13 @@ public class ReaderController : BaseApiController /// /// [HttpGet("file-dimensions")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf"])] public async Task>> GetFileDimensions(int chapterId, bool extractPdf = false) { if (chapterId <= 0) return ArraySegment.Empty; var chapter = await _cacheService.Ensure(chapterId, extractPdf); if (chapter == null) return NoContent(); + return Ok(_cacheService.GetCachedFileDimensions(_cacheService.GetCachePath(chapterId))); } @@ -220,7 +221,8 @@ public class ReaderController : BaseApiController /// Include file dimensions. Only useful for image based reading /// [HttpGet("chapter-info")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf", "includeDimensions"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["chapterId", "extractPdf", "includeDimensions" + ])] public async Task> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false) { if (chapterId <= 0) return Ok(null); // This can happen occasionally from UI, we should just ignore @@ -261,13 +263,14 @@ public class ReaderController : BaseApiController } if (info.ChapterTitle is {Length: > 0}) { + // TODO: Can we rework the logic of generating titles for the UI and instead calculate that in the DB? info.Title += " - " + info.ChapterTitle; } - if (info.IsSpecial && dto.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) + if (info.IsSpecial) { - info.Subtitle = info.FileName; - } else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) + info.Subtitle = Path.GetFileNameWithoutExtension(info.FileName); + } else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)) { info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber; } @@ -293,7 +296,7 @@ public class ReaderController : BaseApiController /// Include file dimensions (extra I/O). Defaults to true. /// [HttpGet("bookmark-info")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"seriesId", "includeDimensions"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId", "includeDimensions"])] public async Task> GetBookmarkInfo(int seriesId, bool includeDimensions = true) { var totalPages = await _cacheService.CacheBookmarkForSeries(User.GetUserId(), seriesId); @@ -377,13 +380,10 @@ public class ReaderController : BaseApiController var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId); await _readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters); - if (await _unitOfWork.CommitAsync()) - { - BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId)); - return Ok(); - } + if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress")); + BackgroundJob.Enqueue(() => _scrobblingService.ScrobbleReadingUpdate(user.Id, markVolumeReadDto.SeriesId)); + return Ok(); } /// @@ -542,6 +542,8 @@ public class ReaderController : BaseApiController public async Task> GetProgress(int chapterId) { var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, User.GetUserId()); + _logger.LogDebug("Get Progress for {ChapterId} is {Pages}", chapterId, progress?.PageNum ?? 0); + if (progress == null) return Ok(new ProgressDto() { PageNum = 0, @@ -553,7 +555,7 @@ public class ReaderController : BaseApiController } /// - /// Save page against Chapter for logged in user + /// Save page against Chapter for authenticated user /// /// /// @@ -750,7 +752,7 @@ public class ReaderController : BaseApiController { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks); if (user == null) return new UnauthorizedResult(); - if (user.Bookmarks.IsNullOrEmpty()) return Ok(); + if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(); if (!await _accountService.HasBookmarkPermission(user)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "bookmark-permission")); @@ -771,7 +773,7 @@ public class ReaderController : BaseApiController /// /// /// chapter id for next manga - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId", "volumeId", "currentChapterId"})] + [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId", "volumeId", "currentChapterId"])] [HttpGet("next-chapter")] public async Task> GetNextChapter(int seriesId, int volumeId, int currentChapterId) { @@ -789,7 +791,7 @@ public class ReaderController : BaseApiController /// /// /// chapter id for next manga - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId", "volumeId", "currentChapterId"})] + [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = ["seriesId", "volumeId", "currentChapterId"])] [HttpGet("prev-chapter")] public async Task> GetPreviousChapter(int seriesId, int volumeId, int currentChapterId) { @@ -803,7 +805,7 @@ public class ReaderController : BaseApiController /// /// [HttpGet("time-left")] - [ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] { "seriesId"})] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["seriesId"])] public async Task> GetEstimateToCompletion(int seriesId) { var userId = User.GetUserId(); @@ -837,16 +839,26 @@ public class ReaderController : BaseApiController return Ok(_unitOfWork.UserTableOfContentRepository.GetPersonalToC(User.GetUserId(), chapterId)); } + /// + /// Deletes the user's personal table of content for the given chapter + /// + /// + /// + /// + /// [HttpDelete("ptoc")] public async Task DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title) { var userId = User.GetUserId(); if (string.IsNullOrWhiteSpace(title)) return BadRequest(await _localizationService.Translate(userId, "name-required")); if (pageNum < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number")); + var toc = await _unitOfWork.UserTableOfContentRepository.Get(userId, chapterId, pageNum, title); if (toc == null) return Ok(); + _unitOfWork.UserTableOfContentRepository.Remove(toc); await _unitOfWork.CommitAsync(); + return Ok(); } @@ -882,4 +894,17 @@ 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) + { + var userId = User.IsInRole(PolicyConstants.AdminRole) ? 0 : User.GetUserId(); + return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, userId)); + + } } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 11a50d614..6c9be6c75 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -6,10 +6,10 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.ReadingLists; +using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.Services; -using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,13 +24,15 @@ public class ReadingListController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly IReaderService _readerService; public ReadingListController(IUnitOfWork unitOfWork, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, IReaderService readerService) { _unitOfWork = unitOfWork; _readingListService = readingListService; _localizationService = localizationService; + _readerService = readerService; } /// @@ -39,9 +41,15 @@ public class ReadingListController : BaseApiController /// /// [HttpGet] - public async Task>> GetList(int readingListId) + public async Task> GetList(int readingListId) { - return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId())); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, User.GetUserId()); + if (readingList == null) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-restricted")); + } + + return Ok(readingList); } /// @@ -63,7 +71,7 @@ public class ReadingListController : BaseApiController } /// - /// Returns all Reading Lists the user has access to that have a series within it. + /// Returns all Reading Lists the user has access to that the given series within it. /// /// /// @@ -74,6 +82,18 @@ public class ReadingListController : BaseApiController seriesId, true)); } + /// + /// Returns all Reading Lists the user has access to that has the given chapter within it. + /// + /// + /// + [HttpGet("lists-for-chapter")] + public async Task>> GetListsForChapter(int chapterId) + { + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForChapterAndUserAsync(User.GetUserId(), + chapterId, true)); + } + /// /// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress /// @@ -96,6 +116,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-position")] public async Task UpdateListItemPosition(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Make sure UI buffers events var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) @@ -110,13 +131,14 @@ public class ReadingListController : BaseApiController } /// - /// Deletes a list item from the list. Will reorder all item positions afterwards + /// Deletes a list item from the list. Item orders will update as a result. /// /// /// [HttpPost("delete-item")] public async Task DeleteListItem(UpdateReadingListPosition dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -139,6 +161,8 @@ public class ReadingListController : BaseApiController [HttpPost("remove-read")] public async Task DeleteReadFromList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -150,7 +174,7 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "reading-list-updated")); } - return BadRequest("Couldn't delete item(s)"); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-item-delete")); } /// @@ -161,6 +185,7 @@ public class ReadingListController : BaseApiController [HttpDelete] public async Task DeleteList([FromQuery] int readingListId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(readingListId, User.GetUsername()); if (user == null) { @@ -181,6 +206,7 @@ public class ReadingListController : BaseApiController [HttpPost("create")] public async Task> CreateList(CreateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingLists); if (user == null) return Unauthorized(); @@ -204,6 +230,7 @@ public class ReadingListController : BaseApiController [HttpPost("update")] public async Task UpdateList(UpdateReadingListDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); @@ -233,6 +260,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-series")] public async Task UpdateListBySeries(UpdateReadingListBySeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -242,7 +270,7 @@ public class ReadingListController : BaseApiController var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); var chapterIdsForSeries = - await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new [] {dto.SeriesId}); + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync([dto.SeriesId]); // If there are adds, tell tracking this has been modified if (await _readingListService.AddChaptersToReadingList(dto.SeriesId, chapterIdsForSeries, readingList)) @@ -275,6 +303,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple")] public async Task UpdateListByMultiple(UpdateReadingListByMultipleDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -319,6 +348,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-multiple-series")] public async Task UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -357,6 +387,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-volume")] public async Task UpdateListByVolume(UpdateReadingListByVolumeDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -393,6 +424,7 @@ public class ReadingListController : BaseApiController [HttpPost("update-by-chapter")] public async Task UpdateListByChapter(UpdateReadingListByChapterDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); var user = await _readingListService.UserHasReadingListAccess(dto.ReadingListId, User.GetUsername()); if (user == null) { @@ -423,26 +455,38 @@ public class ReadingListController : BaseApiController return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do")); } + /// - /// Returns a list of characters associated with the reading list + /// Returns a list of a given role associated with the reading list + /// + /// + /// PersonRole + /// + [HttpGet("people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId", "role"])] + public ActionResult> GetPeopleByRoleForList(int readingListId, PersonRole role) + { + return Ok(_unitOfWork.ReadingListRepository.GetReadingListPeopleAsync(readingListId, role)); + } + + /// + /// Returns all people in given roles for a reading list /// /// /// - [HttpGet("characters")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute)] - public ActionResult> GetCharactersForList(int readingListId) + [HttpGet("all-people")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.TenMinute, VaryByQueryKeys = ["readingListId"])] + public async Task>> GetAllPeopleForList(int readingListId) { - return Ok(_unitOfWork.ReadingListRepository.GetReadingListCharactersAsync(readingListId)); + return Ok(await _unitOfWork.ReadingListRepository.GetReadingListAllPeopleAsync(readingListId)); } - - /// /// Returns the next chapter within the reading list /// /// /// - /// Chapter Id for next item, -1 if nothing exists + /// Chapter ID for next item, -1 if nothing exists [HttpGet("next-chapter")] public async Task> GetNextChapter(int currentChapterId, int readingListId) { @@ -491,4 +535,83 @@ public class ReadingListController : BaseApiController if (string.IsNullOrEmpty(name)) return true; return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name)); } + + + + /// + /// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role + /// + /// + /// + [HttpPost("promote-multiple")] + public async Task PromoteMultipleReadingLists(PromoteReadingListsDto dto) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + // This needs to take into account owner as I can select other users cards + var userId = User.GetUserId(); + if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole)) + { + return BadRequest(await _localizationService.Translate(userId, "permission-denied")); + } + + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds); + + foreach (var readingList in readingLists) + { + if (readingList.AppUserId != userId) continue; + readingList.Promoted = dto.Promoted; + _unitOfWork.ReadingListRepository.Update(readingList); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + + /// + /// Delete multiple reading lists in one go + /// + /// + /// + [HttpPost("delete-multiple")] + public async Task DeleteMultipleReadingLists(DeleteReadingListsDto dto) + { + // This needs to take into account owner as I can select other users cards + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists); + if (user == null) return Unauthorized(); + + user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList(); + _unitOfWork.UserRepository.Update(user); + + + if (!_unitOfWork.HasChanges()) return Ok(); + await _unitOfWork.CommitAsync(); + + return Ok(); + } + + /// + /// Returns random information about a Reading List + /// + /// + /// + [HttpGet("info")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = ["readingListId"])] + public async Task> GetReadingListInfo(int readingListId) + { + var result = await _unitOfWork.ReadingListRepository.GetReadingListInfoAsync(readingListId); + + if (result == null) return Ok(null); + + var timeEstimate = _readerService.GetTimeEstimate(result.WordCount, result.Pages, result.IsAllEpub); + + result.MinHoursToRead = timeEstimate.MinHours; + result.AvgHoursToRead = timeEstimate.AvgHours; + result.MaxHoursToRead = timeEstimate.MaxHours; + + return Ok(result); + } } diff --git a/API/Controllers/ScrobblingController.cs b/API/Controllers/ScrobblingController.cs index 9707bbf61..3904cb8e0 100644 --- a/API/Controllers/ScrobblingController.cs +++ b/API/Controllers/ScrobblingController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs.Account; +using API.DTOs.KavitaPlus.Account; using API.DTOs.Scrobbling; using API.Entities.Scrobble; using API.Extensions; @@ -52,13 +53,30 @@ public class ScrobblingController : BaseApiController return Ok(user.AniListAccessToken); } + /// + /// Get the current user's MAL token and 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 /// /// - /// + /// True if the token was new or not [HttpPost("update-anilist-token")] - public async Task UpdateAniListToken(AniListUpdateDto dto) + public async Task> UpdateAniListToken(AniListUpdateDto dto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); @@ -68,10 +86,38 @@ public class ScrobblingController : BaseApiController _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - if (isNewToken) - { - BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(user.Id)); - } + return Ok(isNewToken); + } + + /// + /// Update the current user's MAL token (Client ID) and Username + /// + /// + /// True if the token was new or not + [HttpPost("update-mal-token")] + public async Task> UpdateMalToken(MalUserInfoDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (user == null) return Unauthorized(); + + var isNewToken = string.IsNullOrEmpty(user.MalAccessToken); + user.MalAccessToken = dto.AccessToken; + user.MalUserName = dto.Username; + + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + return Ok(isNewToken); + } + + /// + /// When a user request to generate scrobble events from history. Should only be ran once per user. + /// + /// + [HttpPost("generate-scrobble-events")] + public ActionResult GenerateScrobbleEvents() + { + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistory(User.GetUserId())); return Ok(); } @@ -224,4 +270,15 @@ public class ScrobblingController : BaseApiController await _unitOfWork.CommitAsync(); return Ok(); } + + /// + /// Has the logged in user ran scrobble generation + /// + /// + [HttpGet("has-ran-scrobble-gen")] + public async Task> HasRanScrobbleGen() + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId()); + return Ok(user is {HasRunScrobbleEventGeneration: true}); + } } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 4ce7d282d..5aa54d1db 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -50,20 +50,26 @@ public class SearchController : BaseApiController return Ok(await _unitOfWork.SeriesRepository.GetSeriesForChapter(chapterId, User.GetUserId())); } + /// + /// Searches against different entities in the system against a query string + /// + /// + /// Include Chapter and Filenames in the entities. This can slow down the search on larger systems + /// [HttpGet("search")] - public async Task> Search(string queryString) + public async Task> Search(string queryString, [FromQuery] bool includeChapterAndFiles = true) { queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString); 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); var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, - libraries, queryString); + libraries, queryString, includeChapterAndFiles); return Ok(series); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index f65ac0b38..94f9c084f 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -9,6 +9,7 @@ using API.DTOs.Dashboard; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; +using API.DTOs.Metadata.Matching; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities; @@ -18,11 +19,13 @@ using API.Helpers; using API.Services; using API.Services.Plus; using EasyCaching.Core; +using Hangfire; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers; @@ -38,14 +41,17 @@ public class SeriesController : BaseApiController private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; private readonly IExternalMetadataService _externalMetadataService; + private readonly IHostEnvironment _environment; private readonly IEasyCachingProvider _externalSeriesCacheProvider; + private readonly IEasyCachingProvider _matchSeriesCacheProvider; private const string CacheKey = "externalSeriesData_"; + private const string MatchSeriesCacheKey = "matchSeries_"; public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService, ILicenseService licenseService, IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, - IExternalMetadataService externalMetadataService) + IExternalMetadataService externalMetadataService, IHostEnvironment environment) { _logger = logger; _taskScheduler = taskScheduler; @@ -54,8 +60,10 @@ public class SeriesController : BaseApiController _licenseService = licenseService; _localizationService = localizationService; _externalMetadataService = externalMetadataService; + _environment = environment; _externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries); + _matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries); } /// @@ -91,7 +99,7 @@ public class SeriesController : BaseApiController /// /// [HttpPost("v2")] - public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) + public async Task>> GetSeriesForLibraryV2([FromQuery] UserParams userParams, [FromBody] FilterV2Dto filterDto) { var userId = User.GetUserId(); var series = @@ -134,7 +142,7 @@ public class SeriesController : BaseApiController var username = User.GetUsername(); _logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username); - return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId})); + return Ok(await _seriesService.DeleteMultipleSeries([seriesId])); } [Authorize(Policy = "RequireAdminRole")] @@ -176,6 +184,7 @@ public class SeriesController : BaseApiController return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter)); } + [Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")] [HttpGet("chapter-metadata")] public async Task> GetChapterMetadata(int chapterId) { @@ -228,22 +237,26 @@ public class SeriesController : BaseApiController { // Trigger a refresh when we are moving from a locked image to a non-locked needsRefreshMetadata = true; - series.CoverImage = string.Empty; - series.CoverImageLocked = updateSeries.CoverImageLocked; + series.CoverImage = null; + series.CoverImageLocked = false; + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + series.ResetColorScape(); + } _unitOfWork.SeriesRepository.Update(series); - if (await _unitOfWork.CommitAsync()) + if (!await _unitOfWork.CommitAsync()) { - if (needsRefreshMetadata) - { - _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); - } - return Ok(); + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-update")); } - return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-series-update")); + if (needsRefreshMetadata) + { + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id); + } + + return Ok(); } /// @@ -315,11 +328,12 @@ public class SeriesController : BaseApiController /// /// [HttpPost("all-v2")] - public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, + [FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None) { var userId = User.GetUserId(); var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto); + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); @@ -339,7 +353,7 @@ public class SeriesController : BaseApiController /// /// [HttpPost("all")] - [Obsolete("User all-v2")] + [Obsolete("Use all-v2")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var userId = User.GetUserId(); @@ -398,7 +412,7 @@ public class SeriesController : BaseApiController [HttpPost("refresh-metadata")] public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) { - _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); + _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate, refreshSeriesDto.ForceColorscape); return Ok(); } @@ -495,7 +509,7 @@ public class SeriesController : BaseApiController /// /// /// This is cached for an hour - [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})] + [ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = ["ageRating"])] [HttpGet("age-rating")] public async Task> GetAgeRating(int ageRating) { @@ -611,4 +625,52 @@ public class SeriesController : BaseApiController return Ok(await _seriesService.GetEstimatedChapterCreationDate(seriesId, userId)); } + /// + /// Sends a request to Kavita+ API for all potential matches, sorted by relevance + /// + /// + /// + [HttpPost("match")] + public async Task>> MatchSeries(MatchSeriesDto dto) + { + var cacheKey = $"{MatchSeriesCacheKey}-{dto.SeriesId}-{dto.Query}"; + var results = await _matchSeriesCacheProvider.GetAsync>(cacheKey); + if (results.HasValue && !_environment.IsDevelopment()) + { + return Ok(results.Value); + } + + var ret = await _externalMetadataService.MatchSeries(dto); + await _matchSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(1)); + + return Ok(ret); + } + + /// + /// This will perform the fix match + /// + /// + /// + /// + [HttpPost("update-match")] + public ActionResult UpdateSeriesMatch([FromQuery] int seriesId, [FromQuery] int? aniListId, [FromQuery] long? malId, [FromQuery] int? cbrId) + { + BackgroundJob.Enqueue(() => _externalMetadataService.FixSeriesMatch(seriesId, aniListId, malId, cbrId)); + + return Ok(); + } + + /// + /// When true, will not perform a match and will prevent Kavita from attempting to match/scrobble against this series + /// + /// + /// + /// + [HttpPost("dont-match")] + public async Task UpdateDontMatch([FromQuery] int seriesId, [FromQuery] bool dontMatch) + { + await _externalMetadataService.UpdateSeriesDontMatch(seriesId, dontMatch); + return Ok(); + } + } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index d4e1ed59b..79f6391e8 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -88,6 +88,19 @@ public class ServerController : BaseApiController return Ok(); } + /// + /// Performs the nightly maintenance work on the Server. Can be heavy. + /// + /// + [HttpPost("cleanup")] + public ActionResult Cleanup() + { + _logger.LogInformation("{UserName} is clearing running general cleanup from admin dashboard", User.GetUsername()); + RecurringJob.TriggerJob(TaskScheduler.CleanupTaskId); + + return Ok(); + } + /// /// Performs an ad-hoc backup of the Database /// @@ -116,15 +129,6 @@ public class ServerController : BaseApiController return Ok(); } - /// - /// Returns non-sensitive information about the current system - /// - /// - [HttpGet("server-info")] - public async Task> GetVersion() - { - return Ok(await _statsService.GetServerInfo()); - } /// /// Returns non-sensitive information about the current system @@ -132,7 +136,7 @@ public class ServerController : BaseApiController /// This is just for the UI and is extremely lightweight /// [HttpGet("server-info-slim")] - public async Task> GetSlimVersion() + public async Task> GetSlimVersion() { return Ok(await _statsService.GetServerInfoSlim()); } @@ -199,21 +203,27 @@ public class ServerController : BaseApiController /// /// Returns how many versions out of date this install is /// + /// Only count Stable releases [HttpGet("check-out-of-date")] - public async Task> CheckHowOutOfDate() + public async Task> CheckHowOutOfDate(bool stableOnly = true) { - return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind()); + return Ok(await _versionUpdaterService.GetNumberOfReleasesBehind(stableOnly)); } /// /// Pull the Changelog for Kavita from Github and display /// + /// How many releases from the latest to return /// + [AllowAnonymous] [HttpGet("changelog")] - public async Task>> GetChangelog() + public async Task>> GetChangelog(int count = 0) { - return Ok(await _versionUpdaterService.GetAllReleases()); + // Strange bug where [Authorize] doesn't work + if (User.GetUserId() == 0) return Unauthorized(); + + return Ok(await _versionUpdaterService.GetAllReleases(count)); } /// @@ -221,18 +231,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)); } /// @@ -273,4 +283,16 @@ public class ServerController : BaseApiController return Ok(); } + /// + /// Runs the Sync Themes task + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("sync-themes")] + public async Task SyncThemes() + { + await _taskScheduler.SyncThemes(); + return Ok(); + } + } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index e0339309b..0610c8705 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -5,6 +5,7 @@ using System.Net; using System.Threading.Tasks; using API.Data; using API.DTOs.Email; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -23,6 +24,7 @@ using Kavita.Common.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Swashbuckle.AspNetCore.Annotations; namespace API.Controllers; @@ -32,27 +34,26 @@ public class SettingsController : BaseApiController { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; - private readonly ITaskScheduler _taskScheduler; - private readonly IDirectoryService _directoryService; private readonly IMapper _mapper; private readonly IEmailService _emailService; - private readonly ILibraryWatcher _libraryWatcher; private readonly ILocalizationService _localizationService; + private readonly ISettingsService _settingsService; - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, - IDirectoryService directoryService, IMapper mapper, IEmailService emailService, ILibraryWatcher libraryWatcher, - ILocalizationService localizationService) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, IMapper mapper, + IEmailService emailService, ILocalizationService localizationService, ISettingsService settingsService) { _logger = logger; _unitOfWork = unitOfWork; - _taskScheduler = taskScheduler; - _directoryService = directoryService; _mapper = mapper; _emailService = emailService; - _libraryWatcher = libraryWatcher; _localizationService = localizationService; + _settingsService = settingsService; } + /// + /// Returns the base url for this instance (if set) + /// + /// [HttpGet("base-url")] public async Task> GetBaseUrl() { @@ -137,324 +138,31 @@ public class SettingsController : BaseApiController } - + /// + /// Update Server settings + /// + /// + /// [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { _logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername()); - // We do not allow CacheDirectory changes, so we will ignore. - var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); - var updateBookmarks = false; - var originalBookmarkDirectory = _directoryService.BookmarkDirectory; - - var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; - if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && - !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) - { - bookmarkDirectory = - _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); - } - - if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) - { - bookmarkDirectory = _directoryService.BookmarkDirectory; - } - - foreach (var setting in currentSettings) - { - UpdateSchedulingSettings(setting, updateSettingsDto); - - if (setting.Key == ServerSettingKey.OnDeckProgressDays && - updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.OnDeckUpdateDays && - updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CoverImageSize && - updateSettingsDto.CoverImageSize + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.CoverImageSize + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) - { - if (OsInfo.IsDocker) continue; - setting.Value = updateSettingsDto.Port + string.Empty; - // Port is managed in appSetting.json - Configuration.Port = updateSettingsDto.Port; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.CacheSize && - updateSettingsDto.CacheSize + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.CacheSize + string.Empty; - // CacheSize is managed in appSetting.json - Configuration.CacheSize = updateSettingsDto.CacheSize; - _unitOfWork.SettingsRepository.Update(setting); - } - - UpdateEmailSettings(setting, updateSettingsDto); - - - - if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) - { - if (OsInfo.IsDocker) continue; - // Validate IP addresses - foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', - StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (!IPAddress.TryParse(ipAddress.Trim(), out _)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "ip-address-invalid", - ipAddress)); - } - } - - setting.Value = updateSettingsDto.IpAddresses; - // IpAddresses is managed in appSetting.json - Configuration.IpAddresses = updateSettingsDto.IpAddresses; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) - { - var path = !updateSettingsDto.BaseUrl.StartsWith('/') - ? $"/{updateSettingsDto.BaseUrl}" - : updateSettingsDto.BaseUrl; - path = !path.EndsWith('/') - ? $"{path}/" - : path; - setting.Value = path; - Configuration.BaseUrl = updateSettingsDto.BaseUrl; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.LoggingLevel && - updateSettingsDto.LoggingLevel + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.LoggingLevel + string.Empty; - LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableOpds && - updateSettingsDto.EnableOpds + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableOpds + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EncodeMediaAs && - updateSettingsDto.EncodeMediaAs + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EncodeMediaAs + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) - { - setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); - setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) - { - // Validate new directory can be used - if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) - { - return BadRequest( - await _localizationService.Translate(User.GetUserId(), "bookmark-dir-permissions")); - } - - originalBookmarkDirectory = setting.Value; - // Normalize the path deliminators. Just to look nice in DB, no functionality - setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); - _unitOfWork.SettingsRepository.Update(setting); - updateBookmarks = true; - - } - - if (setting.Key == ServerSettingKey.AllowStatCollection && - updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - if (!updateSettingsDto.AllowStatCollection) - { - _taskScheduler.CancelStatsTasks(); - } - else - { - await _taskScheduler.ScheduleStatsTasks(); - } - } - - if (setting.Key == ServerSettingKey.TotalBackups && - updateSettingsDto.TotalBackups + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-backups")); - } - - setting.Value = updateSettingsDto.TotalBackups + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TotalLogs && - updateSettingsDto.TotalLogs + string.Empty != setting.Value) - { - if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "total-logs")); - } - - setting.Value = updateSettingsDto.TotalLogs + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EnableFolderWatching && - updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - if (!_unitOfWork.HasChanges()) return Ok(updateSettingsDto); - try { - await _unitOfWork.CommitAsync(); - - if (updateBookmarks) - { - _directoryService.ExistOrCreate(bookmarkDirectory); - _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); - _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); - } - - if (updateSettingsDto.EnableFolderWatching) - { - await _libraryWatcher.StartWatching(); - } - else - { - _libraryWatcher.StopWatching(); - } + var d = await _settingsService.UpdateSettings(updateSettingsDto); + return Ok(d); + } + catch (KavitaException ex) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message)); } catch (Exception ex) { _logger.LogError(ex, "There was an exception when updating server settings"); - await _unitOfWork.RollbackAsync(); return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); } - - - _logger.LogInformation("Server Settings updated"); - await _taskScheduler.ScheduleTasks(); - return Ok(updateSettingsDto); - } - - private void UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) - { - setting.Value = updateSettingsDto.TaskBackup; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) - { - setting.Value = updateSettingsDto.TaskScan; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) - { - setting.Value = updateSettingsDto.TaskCleanup; - _unitOfWork.SettingsRepository.Update(setting); - } - } - - private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) - { - if (setting.Key == ServerSettingKey.EmailHost && - updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailPort && - updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthPassword && - updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailAuthUserName && - updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderAddress && - updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSenderDisplayName && - updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailSizeLimit && - updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailEnableSsl && - updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } - - if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && - updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) - { - setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; - _unitOfWork.SettingsRepository.Update(setting); - } } /// @@ -510,6 +218,39 @@ 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)); } + + /// + /// Get the metadata settings for Kavita+ users. + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("metadata-settings")] + public async Task> GetMetadataSettings() + { + return Ok(await _unitOfWork.SettingsRepository.GetMetadataSettingDto()); + + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("metadata-settings")] + public async Task> UpdateMetadataSettings(MetadataSettingsDto dto) + { + try + { + return Ok(await _settingsService.UpdateMetadataSettings(dto)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue when updating metadata settings"); + return BadRequest(ex.Message); + } + } } diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 9654abef6..383905edd 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -7,10 +10,15 @@ using API.DTOs.Statistics; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; +using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; +using CsvHelper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using MimeTypes; namespace API.Controllers; @@ -22,14 +30,19 @@ public class StatsController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly UserManager _userManager; private readonly ILocalizationService _localizationService; + private readonly ILicenseService _licenseService; + private readonly IDirectoryService _directoryService; public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, - UserManager userManager, ILocalizationService localizationService) + UserManager userManager, ILocalizationService localizationService, + ILicenseService licenseService, IDirectoryService directoryService) { _statService = statService; _unitOfWork = unitOfWork; _userManager = userManager; _localizationService = localizationService; + _licenseService = licenseService; + _directoryService = directoryService; } [HttpGet("user/{userId}/read")] @@ -108,6 +121,34 @@ public class StatsController : BaseApiController return Ok(await _statService.GetFileBreakdown()); } + /// + /// Generates a csv of all file paths for a given extension + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("server/file-extension")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task DownloadFilesByExtension(string fileExtension) + { + if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions)) + { + return BadRequest("Invalid file format"); + } + var tempFile = Path.Join(_directoryService.TempDirectory, + $"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv"); + + if (!_directoryService.FileSystem.File.Exists(tempFile)) + { + var results = await _statService.GetFilesByExtension(fileExtension); + await using var writer = new StreamWriter(tempFile); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(results); + } + + return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)), + System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true); + } + /// /// Returns reading history events for a give or all users, broken up by day, and format @@ -181,6 +222,4 @@ public class StatsController : BaseApiController return Ok(_statService.GetWordsReadCountByYear(userId)); } - - } diff --git a/API/Controllers/StreamController.cs b/API/Controllers/StreamController.cs index a694d5b34..049885e78 100644 --- a/API/Controllers/StreamController.cs +++ b/API/Controllers/StreamController.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Dashboard; using API.DTOs.SideNav; @@ -19,11 +20,13 @@ public class StreamController : BaseApiController { private readonly IStreamService _streamService; private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; - public StreamController(IStreamService streamService, IUnitOfWork unitOfWork) + public StreamController(IStreamService streamService, IUnitOfWork unitOfWork, ILocalizationService localizationService) { _streamService = streamService; _unitOfWork = unitOfWork; + _localizationService = localizationService; } /// @@ -74,6 +77,7 @@ public class StreamController : BaseApiController [HttpPost("update-external-source")] public async Task> UpdateExternalSource(ExternalSourceDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); // Check if a host and api key exists for the current user return Ok(await _streamService.UpdateExternalSource(User.GetUserId(), dto)); } @@ -86,7 +90,8 @@ public class StreamController : BaseApiController [HttpGet("external-source-exists")] public async Task> ExternalSourceExists(string host, string name, string apiKey) { - return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), host, name, apiKey)); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + return Ok(await _unitOfWork.AppUserExternalSourceRepository.ExternalSourceExists(User.GetUserId(), name, host, apiKey)); } /// @@ -97,6 +102,7 @@ public class StreamController : BaseApiController [HttpDelete("delete-external-source")] public async Task ExternalSourceExists(int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.DeleteExternalSource(User.GetUserId(), externalSourceId); return Ok(); } @@ -110,6 +116,7 @@ public class StreamController : BaseApiController [HttpPost("add-dashboard-stream")] public async Task> AddDashboard([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateDashboardStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -121,6 +128,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-stream")] public async Task UpdateDashboardStream(DashboardStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStream(User.GetUserId(), dto); return Ok(); } @@ -133,6 +141,7 @@ public class StreamController : BaseApiController [HttpPost("update-dashboard-position")] public async Task UpdateDashboardStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateDashboardStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -146,6 +155,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream")] public async Task> AddSideNav([FromQuery] int smartFilterId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromSmartFilter(User.GetUserId(), smartFilterId)); } @@ -157,6 +167,7 @@ public class StreamController : BaseApiController [HttpPost("add-sidenav-stream-from-external-source")] public async Task> AddSideNavFromExternalSource([FromQuery] int externalSourceId) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); return Ok(await _streamService.CreateSideNavStreamFromExternalSource(User.GetUserId(), externalSourceId)); } @@ -168,6 +179,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-stream")] public async Task UpdateSideNavStream(SideNavStreamDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStream(User.GetUserId(), dto); return Ok(); } @@ -180,6 +192,7 @@ public class StreamController : BaseApiController [HttpPost("update-sidenav-position")] public async Task UpdateSideNavStreamPosition(UpdateStreamPositionDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto); return Ok(); } @@ -187,7 +200,34 @@ public class StreamController : BaseApiController [HttpPost("bulk-sidenav-stream-visibility")] public async Task BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto) { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto); return Ok(); } + + /// + /// Removes a Smart Filter from a user's SideNav Streams + /// + /// + /// + [HttpDelete("smart-filter-side-nav-stream")] + public async Task DeleteSmartFilterSideNavStream([FromQuery] int sideNavStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteSideNavSmartFilterStream(User.GetUserId(), sideNavStreamId); + return Ok(); + } + + /// + /// Removes a Smart Filter from a user's Dashboard Streams + /// + /// + /// + [HttpDelete("smart-filter-dashboard-stream")] + public async Task DeleteSmartFilterDashboardStream([FromQuery] int dashboardStreamId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _streamService.DeleteDashboardSmartFilterStream(User.GetUserId(), dashboardStreamId); + return Ok(); + } } diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index 7fa722624..9e4cee20c 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -1,13 +1,21 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Theme; +using API.Entities; using API.Extensions; using API.Services; using API.Services.Tasks; +using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; namespace API.Controllers; @@ -17,16 +25,19 @@ public class ThemeController : BaseApiController { private readonly IUnitOfWork _unitOfWork; private readonly IThemeService _themeService; - private readonly ITaskScheduler _taskScheduler; private readonly ILocalizationService _localizationService; + private readonly IDirectoryService _directoryService; + private readonly IMapper _mapper; - public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler, - ILocalizationService localizationService) + + public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, + ILocalizationService localizationService, IDirectoryService directoryService, IMapper mapper) { _unitOfWork = unitOfWork; _themeService = themeService; - _taskScheduler = taskScheduler; _localizationService = localizationService; + _directoryService = directoryService; + _mapper = mapper; } [ResponseCache(CacheProfileName = "10Minute")] @@ -37,13 +48,6 @@ public class ThemeController : BaseApiController return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos()); } - [Authorize("RequireAdminRole")] - [HttpPost("scan")] - public ActionResult Scan() - { - _taskScheduler.ScanSiteThemes(); - return Ok(); - } [Authorize("RequireAdminRole")] [HttpPost("update-default")] @@ -78,4 +82,70 @@ public class ThemeController : BaseApiController return BadRequest(await _localizationService.Get("en", ex.Message)); } } + + /// + /// Browse themes that can be used on this server + /// + /// + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)] + [HttpGet("browse")] + public async Task>> BrowseThemes() + { + var themes = await _themeService.GetDownloadableThemes(); + return Ok(themes.Where(t => !t.AlreadyDownloaded)); + } + + /// + /// Attempts to delete a theme. If already in use by users, will not allow + /// + /// + /// + [HttpDelete] + public async Task>> DeleteTheme(int themeId) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + await _themeService.DeleteTheme(themeId); + + return Ok(); + } + + /// + /// Downloads a SiteTheme from upstream + /// + /// + /// + [HttpPost("download-theme")] + public async Task> DownloadTheme(DownloadableSiteThemeDto dto) + { + return Ok(_mapper.Map(await _themeService.DownloadRepoTheme(dto))); + } + + /// + /// Uploads a new theme file + /// + /// + /// + [HttpPost("upload-theme")] + public async Task> DownloadTheme(IFormFile formFile) + { + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + + if (!formFile.FileName.EndsWith(".css")) return BadRequest("Invalid file"); + if (formFile.FileName.Contains("..")) return BadRequest("Invalid file"); + var tempFile = await UploadToTemp(formFile); + + // Set summary as "Uploaded by User.GetUsername() on DATE" + var theme = await _themeService.CreateThemeFromFile(tempFile, User.GetUsername()); + return Ok(_mapper.Map(theme)); + } + + private async Task UploadToTemp(IFormFile file) + { + var outputFile = Path.Join(_directoryService.TempDirectory, file.FileName); + await using var stream = System.IO.File.Create(outputFile); + await file.CopyToAsync(stream); + stream.Close(); + return outputFile; + } + } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 81b3ea6fe..4b935a1bf 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -1,10 +1,14 @@ using System; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs.Uploads; +using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks.Metadata; using API.SignalR; using Flurl.Http; using Microsoft.AspNetCore.Authorization; @@ -28,11 +32,12 @@ public class UploadController : BaseApiController private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; private readonly ILocalizationService _localizationService; + private readonly ICoverDbService _coverDbService; /// public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService, - ILocalizationService localizationService) + ILocalizationService localizationService, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _imageService = imageService; @@ -42,6 +47,7 @@ public class UploadController : BaseApiController _eventHub = eventHub; _readingListService = readingListService; _localizationService = localizationService; + _coverDbService = coverDbService; } /// @@ -90,26 +96,33 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - 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)}"); - if (!string.IsNullOrEmpty(filePath)) + if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist")); + + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - series.CoverImage = filePath; - series.CoverImageLocked = true; - _unitOfWork.SeriesRepository.Update(series); + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; } + series.CoverImage = filePath; + series.CoverImageLocked = lockState; + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + if (_unitOfWork.HasChanges()) { + // Refresh covers + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + } + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); await _unitOfWork.CommitAsync(); @@ -138,24 +151,24 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - 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)}"); - if (!string.IsNullOrEmpty(filePath)) + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - tag.CoverImage = filePath; - tag.CoverImageLocked = true; - _unitOfWork.CollectionTagRepository.Update(tag); + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; } + tag.CoverImage = filePath; + tag.CoverImageLocked = lockState; + _imageService.UpdateColorScape(tag); + _unitOfWork.CollectionTagRepository.Update(tag); + if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); @@ -184,29 +197,31 @@ public class UploadController : BaseApiController [HttpPost("reading-list")] public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) { - // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // Check if Url is non-empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - - if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null) + if (await _readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null) return Unauthorized(await _localizationService.Translate(User.GetUserId(), "access-denied")); try { var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); - if (!string.IsNullOrEmpty(filePath)) + + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - readingList.CoverImage = filePath; - readingList.CoverImageLocked = true; - _unitOfWork.ReadingListRepository.Update(readingList); + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; } + + readingList.CoverImage = filePath; + readingList.CoverImageLocked = lockState; + _imageService.UpdateColorScape(readingList); + _unitOfWork.ReadingListRepository.Update(readingList); + if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); @@ -225,17 +240,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); } /// @@ -250,33 +262,42 @@ public class UploadController : BaseApiController { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) - { - return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); - } - try { var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); - var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); - if (!string.IsNullOrEmpty(filePath)) + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) { - chapter.CoverImage = filePath; - chapter.CoverImageLocked = true; - _unitOfWork.ChapterRepository.Update(chapter); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); - if (volume != null) - { - volume.CoverImage = chapter.CoverImage; - _unitOfWork.VolumeRepository.Update(volume); - } + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); + lockState = uploadFileDto.LockCover; + } + + chapter.CoverImage = filePath; + chapter.CoverImageLocked = lockState; + _unitOfWork.ChapterRepository.Update(chapter); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); + if (volume != null) + { + volume.CoverImage = chapter.CoverImage; + volume.CoverImageLocked = lockState; + _unitOfWork.VolumeRepository.Update(volume); } if (_unitOfWork.HasChanges()) { await _unitOfWork.CommitAsync(); + + // Refresh covers + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId))!; + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + } + + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, @@ -294,6 +315,67 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-chapter-save")); } + /// + /// Replaces volume cover image and locks it with a base64 encoded image. + /// + /// This will not update the underlying chapter + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] + [HttpPost("volume")] + public async Task UploadVolumeCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + try + { + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(uploadFileDto.Id, VolumeIncludes.Chapters); + if (volume == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + + var filePath = string.Empty; + var lockState = false; + if (!string.IsNullOrEmpty(uploadFileDto.Url)) + { + filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetVolumeFormat(uploadFileDto.Id)}"); + lockState = uploadFileDto.LockCover; + } + + volume.CoverImage = filePath; + volume.CoverImageLocked = lockState; + _imageService.UpdateColorScape(volume); + _unitOfWork.VolumeRepository.Update(volume); + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + + // Refresh covers + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; + _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + } + + + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(uploadFileDto.Id, MessageFactoryEntityTypes.Volume), false); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Chapter), false); + return Ok(); + } + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Volume {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-volume-save")); + } + + /// /// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null. /// @@ -312,6 +394,7 @@ public class UploadController : BaseApiController if (string.IsNullOrEmpty(uploadFileDto.Url)) { library.CoverImage = null; + library.ResetColorScape(); _unitOfWork.LibraryRepository.Update(library); if (_unitOfWork.HasChanges()) { @@ -326,12 +409,12 @@ public class UploadController : BaseApiController try { var filePath = await CreateThumbnail(uploadFileDto, - $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", - ImageService.LibraryThumbnailWidth); + $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { library.CoverImage = filePath; + _imageService.UpdateColorScape(library); _unitOfWork.LibraryRepository.Update(library); } @@ -360,6 +443,7 @@ public class UploadController : BaseApiController /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("reset-chapter-lock")] + [Obsolete("Use LockCover in UploadFileDto")] public async Task ResetChapterLock(UploadFileDto uploadFileDto) { try @@ -367,12 +451,15 @@ public class UploadController : BaseApiController var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); var originalFile = chapter.CoverImage; + chapter.CoverImage = string.Empty; chapter.CoverImageLocked = false; _unitOfWork.ChapterRepository.Update(chapter); + var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; if (_unitOfWork.HasChanges()) @@ -393,4 +480,32 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } + /// + /// Replaces person tag cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] + [HttpPost("person")] + public async Task UploadPersonCoverImageFromUrl(UploadFileDto uploadFileDto) + { + try + { + var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); + if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + + await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true); + return Ok(); + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Person {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-person-save")); + } + + } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index fdb6baa5d..944ea987b 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,11 +1,14 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.KavitaPlus.Account; using API.Extensions; using API.Services; +using API.Services.Plus; using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Authorization; @@ -22,14 +25,16 @@ public class UsersController : BaseApiController private readonly IMapper _mapper; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILicenseService _licenseService; public UsersController(IUnitOfWork unitOfWork, IMapper mapper, IEventHub eventHub, - ILocalizationService localizationService) + ILocalizationService localizationService, ILicenseService licenseService) { _unitOfWork = unitOfWork; _mapper = mapper; _eventHub = eventHub; _localizationService = localizationService; + _licenseService = licenseService; } [Authorize(Policy = "RequireAdminRole")] @@ -82,12 +87,20 @@ public class UsersController : BaseApiController return Ok(libs.Any(x => x.Id == libraryId)); } + /// + /// Update the user preferences + /// + /// If the user has ReadOnly role, they will not be able to perform this action + /// + /// [HttpPost("update-preferences")] public async Task> UpdatePreferences(UserPreferencesDto preferencesDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.UserPreferences); if (user == null) return Unauthorized(); + if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied")); + var existingPreferences = user!.UserPreferences; existingPreferences.ReadingDirection = preferencesDto.ReadingDirection; @@ -112,17 +125,37 @@ 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; - if (_localizationService.GetLocales().Contains(preferencesDto.Locale)) + + existingPreferences.PdfTheme = preferencesDto.PdfTheme; + existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode; + existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode; + + if (await _licenseService.HasActiveLicense()) + { + existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled; + existingPreferences.WantToReadSync = preferencesDto.WantToReadSync; + } + + + + if (preferencesDto.Theme != null && existingPreferences.Theme.Id != preferencesDto.Theme?.Id) + { + var theme = await _unitOfWork.SiteThemeRepository.GetTheme(preferencesDto.Theme!.Id); + existingPreferences.Theme = theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + } + + + if (_localizationService.GetLocales().Select(l => l.FileName).Contains(preferencesDto.Locale)) { existingPreferences.Locale = preferencesDto.Locale; } + _unitOfWork.UserRepository.Update(existingPreferences); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-user-pref")); @@ -153,4 +186,18 @@ public class UsersController : BaseApiController { return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName)); } + + /// + /// Returns all users with tokens registered and their token information. Does not send the tokens. + /// + /// Kavita+ only + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("tokens")] + public async Task>> GetUserTokens() + { + if (!await _licenseService.HasActiveLicense()) return BadRequest(_localizationService.Translate(User.GetUserId(), "kavitaplus-restricted")); + + return Ok((await _unitOfWork.UserRepository.GetUserTokenInfo())); + } } diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs new file mode 100644 index 000000000..db1381d9d --- /dev/null +++ b/API/Controllers/VolumeController.cs @@ -0,0 +1,84 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services; +using API.SignalR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; +#nullable enable + +public class VolumeController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IEventHub _eventHub; + + public VolumeController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _eventHub = eventHub; + } + + /// + /// Returns the appropriate Volume + /// + /// + /// + [HttpGet] + public async Task> GetVolume(int volumeId) + { + return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId())); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete] + public async Task> DeleteVolume(int volumeId) + { + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, + VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags); + if (volume == null) + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + + _unitOfWork.VolumeRepository.Remove(volume); + + if (await _unitOfWork.CommitAsync()) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false); + return Ok(true); + } + + return Ok(false); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("multiple")] + public async Task> DeleteMultipleVolumes(int[] volumesIds) + { + var volumes = await _unitOfWork.VolumeRepository.GetVolumesById(volumesIds); + if (volumes.Count != volumesIds.Length) + { + return BadRequest(_localizationService.Translate(User.GetUserId(), "volume-doesnt-exist")); + } + + _unitOfWork.VolumeRepository.Remove(volumes); + + if (!await _unitOfWork.CommitAsync()) + { + return Ok(false); + } + + foreach (var volume in volumes) + { + await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false); + } + + return Ok(true); + } +} 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/Account/ConfirmEmailDto.cs b/API/DTOs/Account/ConfirmEmailDto.cs index fb9a7c470..2f5849e74 100644 --- a/API/DTOs/Account/ConfirmEmailDto.cs +++ b/API/DTOs/Account/ConfirmEmailDto.cs @@ -9,7 +9,7 @@ public class ConfirmEmailDto [Required] public string Token { get; set; } = default!; [Required] - [StringLength(32, MinimumLength = 6)] + [StringLength(256, MinimumLength = 6)] public string Password { get; set; } = default!; [Required] public string Username { get; set; } = default!; diff --git a/API/DTOs/Account/ConfirmPasswordResetDto.cs b/API/DTOs/Account/ConfirmPasswordResetDto.cs index 862a18986..16dd86f9a 100644 --- a/API/DTOs/Account/ConfirmPasswordResetDto.cs +++ b/API/DTOs/Account/ConfirmPasswordResetDto.cs @@ -9,6 +9,6 @@ public class ConfirmPasswordResetDto [Required] public string Token { get; set; } = default!; [Required] - [StringLength(32, MinimumLength = 6)] + [StringLength(256, MinimumLength = 6)] public string Password { get; set; } = default!; } diff --git a/API/DTOs/Account/ResetPasswordDto.cs b/API/DTOs/Account/ResetPasswordDto.cs index fc7147f62..51a195131 100644 --- a/API/DTOs/Account/ResetPasswordDto.cs +++ b/API/DTOs/Account/ResetPasswordDto.cs @@ -13,7 +13,7 @@ public class ResetPasswordDto /// The new password /// [Required] - [StringLength(32, MinimumLength = 6)] + [StringLength(256, MinimumLength = 6)] public string Password { get; init; } = default!; /// /// The old, existing password. If an admin is performing the change, this is not required. Otherwise, it is. diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index bda664bdb..c40124b7b 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; +#nullable enable public record UpdateUserDto { @@ -18,4 +19,8 @@ public record UpdateUserDto /// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// public AgeRestrictionDto AgeRestriction { get; init; } = default!; + /// + /// Email of the user + /// + public string? Email { get; set; } = default!; } diff --git a/API/DTOs/BulkActionDto.cs b/API/DTOs/BulkActionDto.cs new file mode 100644 index 000000000..d3ce75293 --- /dev/null +++ b/API/DTOs/BulkActionDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace API.DTOs; + +public class BulkActionDto +{ + public List Ids { get; set; } + /** + * If this is a Scan action, will ignore optimizations + */ + public bool? Force { get; set; } +} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 26a8b8459..70c77e92d 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,26 +1,39 @@ using System; using System.Collections.Generic; +using API.DTOs.Metadata; using API.Entities.Enums; using API.Entities.Interfaces; namespace API.DTOs; +#nullable enable /// /// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying /// file (abstracted from type). /// -public class ChapterDto : IHasReadTimeEstimate +public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage { public int Id { get; init; } /// - /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name. /// + /// This can be something like 19.HU or Alpha as some comics are like this public string Range { get; init; } = default!; /// /// Smallest number of the Range. /// + [Obsolete("Use MinNumber and MaxNumber instead")] public string Number { get; init; } = default!; /// + /// This may be 0 under the circumstance that the Issue is "Alpha" or other non-standard numbers. + /// + public float MinNumber { get; init; } + public float MaxNumber { get; init; } + /// + /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. + /// + public float SortOrder { get; set; } + /// /// Total number of pages in all MangaFiles /// public int Pages { get; init; } @@ -99,7 +112,7 @@ public class ChapterDto : IHasReadTimeEstimate /// public int MaxHoursToRead { get; set; } /// - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } /// /// Comma-separated link of urls to external services that have some relation to the Chapter /// @@ -109,4 +122,79 @@ public class ChapterDto : IHasReadTimeEstimate /// /// This is guaranteed to be Valid public string ISBN { get; set; } + + #region Metadata + + public ICollection Writers { get; set; } = new List(); + public ICollection CoverArtists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Imprints { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); + public ICollection Teams { get; set; } = new List(); + public ICollection Locations { get; set; } = new List(); + + public ICollection Genres { get; set; } = new List(); + + /// + /// Collection of all Tags from underlying chapters for a Series + /// + public ICollection Tags { get; set; } = new List(); + public PublicationStatus PublicationStatus { get; set; } + /// + /// Language for the Chapter/Issue + /// + public string? Language { get; set; } + /// + /// Number in the TotalCount of issues + /// + public int Count { get; set; } + /// + /// Total number of issues for the series + /// + public int TotalCount { get; set; } + + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + /// + /// Locked by user so metadata updates from scan loop will not override PublicationStatus + /// + public bool PublicationStatusLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } + public bool CoverArtistLocked { get; set; } + public bool ReleaseYearLocked { get; set; } + + #endregion + + public string CoverImage { get; set; } + public string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } } diff --git a/API/DTOs/Collection/AppUserCollectionDto.cs b/API/DTOs/Collection/AppUserCollectionDto.cs new file mode 100644 index 000000000..ecfb5c062 --- /dev/null +++ b/API/DTOs/Collection/AppUserCollectionDto.cs @@ -0,0 +1,61 @@ +using System; +using API.Entities.Enums; +using API.Entities.Interfaces; +using API.Services.Plus; + +namespace API.DTOs.Collection; +#nullable enable + +public class AppUserCollectionDto : IHasCoverImage +{ + 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 string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + public bool CoverImageLocked { get; set; } + + /// + /// Number of Series in the Collection + /// + public int ItemCount { 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; } + /// + /// Total number of items as of the last sync. Not applicable for Kavita managed collections. + /// + public int TotalSourceCount { get; set; } + /// + /// A
separated string of all missing series + ///
+ public string? MissingSeriesFromSource { get; set; } + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } +} diff --git a/API/DTOs/Collection/DeleteCollectionsDto.cs b/API/DTOs/Collection/DeleteCollectionsDto.cs new file mode 100644 index 000000000..c0b94e9a1 --- /dev/null +++ b/API/DTOs/Collection/DeleteCollectionsDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Collection; + +public class DeleteCollectionsDto +{ + [Required] + 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..d9d902e88 --- /dev/null +++ b/API/DTOs/Collection/MalStackDto.cs @@ -0,0 +1,20 @@ +namespace API.DTOs.Collection; +#nullable enable + +/// +/// 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/CollectionTags/CollectionTagDto.cs b/API/DTOs/CollectionTags/CollectionTagDto.cs index 2a1279a35..ec9939ebd 100644 --- a/API/DTOs/CollectionTags/CollectionTagDto.cs +++ b/API/DTOs/CollectionTags/CollectionTagDto.cs @@ -1,5 +1,8 @@ -namespace API.DTOs.CollectionTags; +using System; +namespace API.DTOs.CollectionTags; + +[Obsolete("Use AppUserCollectionDto")] public class CollectionTagDto { public int Id { get; set; } diff --git a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs index 9d6f2a035..19e9a11e2 100644 --- a/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs +++ b/API/DTOs/CollectionTags/UpdateSeriesForTagDto.cs @@ -1,9 +1,11 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using API.DTOs.Collection; namespace API.DTOs.CollectionTags; public class UpdateSeriesForTagDto { - public CollectionTagDto Tag { get; init; } = default!; + public AppUserCollectionDto Tag { get; init; } = default!; public IEnumerable SeriesIdsToRemove { get; init; } = default!; } diff --git a/API/DTOs/ColorScape.cs b/API/DTOs/ColorScape.cs new file mode 100644 index 000000000..d95346af7 --- /dev/null +++ b/API/DTOs/ColorScape.cs @@ -0,0 +1,11 @@ +namespace API.DTOs; +#nullable enable + +/// +/// A primary and secondary color +/// +public class ColorScape +{ + public required string? Primary { get; set; } + public required string? Secondary { get; set; } +} diff --git a/API/DTOs/CopySettingsFromLibraryDto.cs b/API/DTOs/CopySettingsFromLibraryDto.cs new file mode 100644 index 000000000..ee75f7422 --- /dev/null +++ b/API/DTOs/CopySettingsFromLibraryDto.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace API.DTOs; + +public class CopySettingsFromLibraryDto +{ + public int SourceLibraryId { get; set; } + public List TargetLibraryIds { get; set; } + /// + /// Include copying over the type + /// + public bool IncludeType { get; set; } + +} diff --git a/API/DTOs/CoverDb/CoverDbAuthor.cs b/API/DTOs/CoverDb/CoverDbAuthor.cs new file mode 100644 index 000000000..2f023398a --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbAuthor.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; + +public class CoverDbAuthor +{ + [YamlMember(Alias = "name", ApplyNamingConventions = false)] + public string Name { get; set; } + [YamlMember(Alias = "aliases", ApplyNamingConventions = false)] + public List Aliases { get; set; } = new List(); + [YamlMember(Alias = "ids", ApplyNamingConventions = false)] + public CoverDbPersonIds Ids { get; set; } + [YamlMember(Alias = "image_path", ApplyNamingConventions = false)] + public string ImagePath { get; set; } +} diff --git a/API/DTOs/CoverDb/CoverDbPeople.cs b/API/DTOs/CoverDb/CoverDbPeople.cs new file mode 100644 index 000000000..c0f5e327e --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbPeople.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; + +public class CoverDbPeople +{ + [YamlMember(Alias = "people", ApplyNamingConventions = false)] + public List People { get; set; } = new List(); +} diff --git a/API/DTOs/CoverDb/CoverDbPersonIds.cs b/API/DTOs/CoverDb/CoverDbPersonIds.cs new file mode 100644 index 000000000..9c59415e6 --- /dev/null +++ b/API/DTOs/CoverDb/CoverDbPersonIds.cs @@ -0,0 +1,20 @@ +using YamlDotNet.Serialization; + +namespace API.DTOs.CoverDb; +#nullable enable + +public class CoverDbPersonIds +{ + [YamlMember(Alias = "hardcover_id", ApplyNamingConventions = false)] + public string? HardcoverId { get; set; } = null; + [YamlMember(Alias = "amazon_id", ApplyNamingConventions = false)] + public string? AmazonId { get; set; } = null; + [YamlMember(Alias = "metron_id", ApplyNamingConventions = false)] + public string? MetronId { get; set; } = null; + [YamlMember(Alias = "comicvine_id", ApplyNamingConventions = false)] + public string? ComicVineId { get; set; } = null; + [YamlMember(Alias = "anilist_id", ApplyNamingConventions = false)] + public string? AnilistId { get; set; } = null; + [YamlMember(Alias = "mal_id", ApplyNamingConventions = false)] + public string? MALId { get; set; } = null; +} diff --git a/API/DTOs/DeleteChaptersDto.cs b/API/DTOs/DeleteChaptersDto.cs new file mode 100644 index 000000000..cbd21df36 --- /dev/null +++ b/API/DTOs/DeleteChaptersDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace API.DTOs; + +public class DeleteChaptersDto +{ + public IList ChapterIds { get; set; } = default!; +} diff --git a/API/DTOs/Email/EmailHistoryDto.cs b/API/DTOs/Email/EmailHistoryDto.cs new file mode 100644 index 000000000..ca3549550 --- /dev/null +++ b/API/DTOs/Email/EmailHistoryDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace API.DTOs.Email; + +public class EmailHistoryDto +{ + public long Id { get; set; } + public bool Sent { get; set; } + public DateTime SendDate { get; set; } = DateTime.UtcNow; + public string EmailTemplate { get; set; } + public string ErrorMessage { get; set; } + public string ToUserName { get; set; } + +} diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs index b072819f4..7082ded69 100644 --- a/API/DTOs/Filtering/SortField.cs +++ b/API/DTOs/Filtering/SortField.cs @@ -33,5 +33,9 @@ public enum SortField /// /// Kavita+ Only - External Average Rating /// - AverageRating = 8 + AverageRating = 8, + /// + /// Randomise the order + /// + Random = 9 } diff --git a/API/DTOs/Filtering/v2/FilterComparision.cs b/API/DTOs/Filtering/v2/FilterComparision.cs index 109667dad..59bb86a8a 100644 --- a/API/DTOs/Filtering/v2/FilterComparision.cs +++ b/API/DTOs/Filtering/v2/FilterComparision.cs @@ -53,4 +53,8 @@ public enum FilterComparison /// Is Date not between now and X seconds ago ///
IsNotInLast = 15, + /// + /// There are no records + /// + IsEmpty = 16 } diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs index 1efb385fa..5323f2b48 100644 --- a/API/DTOs/Filtering/v2/FilterField.cs +++ b/API/DTOs/Filtering/v2/FilterField.cs @@ -48,6 +48,13 @@ public enum FilterField /// /// Average rating from Kavita+ - Not usable for non-licensed users /// - AverageRating = 28 + AverageRating = 28, + Imprint = 29, + Team = 30, + Location = 31, + /// + /// Last time User Read + /// + ReadLast = 32, } diff --git a/API/DTOs/KavitaLocale.cs b/API/DTOs/KavitaLocale.cs new file mode 100644 index 000000000..decfb7395 --- /dev/null +++ b/API/DTOs/KavitaLocale.cs @@ -0,0 +1,10 @@ +namespace API.DTOs; + +public class KavitaLocale +{ + public string FileName { get; set; } // Key + public string RenderName { get; set; } + public float TranslationCompletion { get; set; } + public bool IsRtL { get; set; } + public string Hash { get; set; } // ETAG hash so I can run my own localization busting implementation +} diff --git a/API/DTOs/Account/AniListUpdateDto.cs b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs similarity index 63% rename from API/DTOs/Account/AniListUpdateDto.cs rename to API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs index d51a1dc0d..c6d2e07cc 100644 --- a/API/DTOs/Account/AniListUpdateDto.cs +++ b/API/DTOs/KavitaPlus/Account/AniListUpdateDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.Account; +namespace API.DTOs.KavitaPlus.Account; public class AniListUpdateDto { diff --git a/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs new file mode 100644 index 000000000..220bd9e7e --- /dev/null +++ b/API/DTOs/KavitaPlus/Account/UserTokenInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace API.DTOs.KavitaPlus.Account; + +/// +/// Represents information around a user's tokens and their status +/// +public class UserTokenInfo +{ + public int UserId { get; set; } + public string Username { get; set; } + public bool IsAniListTokenSet { get; set; } + public bool IsAniListTokenValid { get; set; } + public DateTime AniListValidUntilUtc { get; set; } + public bool IsMalTokenSet { get; set; } +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs new file mode 100644 index 000000000..547bb63a8 --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -0,0 +1,17 @@ +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; +#nullable enable + +/// +/// Used for matching and fetching metadata on a series +/// +internal class ExternalMetadataIdsDto +{ + public long? MalId { get; set; } + public int? AniListId { get; set; } + + public string? SeriesName { get; set; } + public string? LocalizedSeriesName { get; set; } + public PlusMediaFormat? PlusMediaFormat { get; set; } = DTOs.Scrobbling.PlusMediaFormat.Unknown; +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs new file mode 100644 index 000000000..f63fe5a9e --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using API.DTOs.Scrobbling; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; +#nullable enable + +internal class MatchSeriesRequestDto +{ + public string SeriesName { get; set; } + public ICollection AlternativeNames { get; set; } + public int Year { get; set; } = 0; + public string Query { get; set; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public string? HardcoverId { get; set; } + public PlusMediaFormat Format { get; set; } +} diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs new file mode 100644 index 000000000..26411bce7 --- /dev/null +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using API.DTOs.Recommendation; +using API.DTOs.Scrobbling; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.ExternalMetadata; + +internal class SeriesDetailPlusApiDto +{ + public IEnumerable Recommendations { get; set; } + public IEnumerable Reviews { get; set; } + public IEnumerable Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } + public int? AniListId { get; set; } + public long? MalId { get; set; } + public int? CbrId { get; set; } +} diff --git a/API/DTOs/License/EncryptLicenseDto.cs b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs similarity index 79% rename from API/DTOs/License/EncryptLicenseDto.cs rename to API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs index 97015c470..eedbed2ef 100644 --- a/API/DTOs/License/EncryptLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/EncryptLicenseDto.cs @@ -1,4 +1,5 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; +#nullable enable public class EncryptLicenseDto { diff --git a/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs new file mode 100644 index 000000000..398556aac --- /dev/null +++ b/API/DTOs/KavitaPlus/License/LicenseInfoDto.cs @@ -0,0 +1,35 @@ +using System; + +namespace API.DTOs.KavitaPlus.License; + +public class LicenseInfoDto +{ + /// + /// If cancelled, will represent cancellation date. If not, will represent repayment date + /// + public DateTime ExpirationDate { get; set; } + /// + /// If cancelled or not + /// + public bool IsActive { get; set; } + /// + /// If will be or is cancelled + /// + public bool IsCancelled { get; set; } + /// + /// Is the installed version valid for Kavita+ (aka within 3 releases) + /// + public bool IsValidVersion { get; set; } + /// + /// The email on file + /// + public string RegisteredEmail { get; set; } + /// + /// Number of months user has been subscribed + /// + public int TotalMonthsSubbed { get; set; } + /// + /// A license is stored within Kavita + /// + public bool HasLicense { get; set; } +} diff --git a/API/DTOs/Account/LicenseValidDto.cs b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs similarity index 76% rename from API/DTOs/Account/LicenseValidDto.cs rename to API/DTOs/KavitaPlus/License/LicenseValidDto.cs index f49420779..56ee6cf73 100644 --- a/API/DTOs/Account/LicenseValidDto.cs +++ b/API/DTOs/KavitaPlus/License/LicenseValidDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.Account; +namespace API.DTOs.KavitaPlus.License; public class LicenseValidDto { diff --git a/API/DTOs/License/ResetLicenseDto.cs b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs similarity index 81% rename from API/DTOs/License/ResetLicenseDto.cs rename to API/DTOs/KavitaPlus/License/ResetLicenseDto.cs index f62d78870..60496ee0e 100644 --- a/API/DTOs/License/ResetLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/ResetLicenseDto.cs @@ -1,4 +1,4 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; public class ResetLicenseDto { diff --git a/API/DTOs/License/UpdateLicenseDto.cs b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs similarity index 86% rename from API/DTOs/License/UpdateLicenseDto.cs rename to API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs index b2803952c..4621810f0 100644 --- a/API/DTOs/License/UpdateLicenseDto.cs +++ b/API/DTOs/KavitaPlus/License/UpdateLicenseDto.cs @@ -1,4 +1,5 @@ -namespace API.DTOs.License; +namespace API.DTOs.KavitaPlus.License; +#nullable enable public class UpdateLicenseDto { diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs new file mode 100644 index 000000000..60bed32b0 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchFilterDto.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.KavitaPlus.Manage; + +/// +/// Represents an option in the UI layer for Filtering +/// +public enum MatchStateOption +{ + All = 0, + Matched = 1, + NotMatched = 2, + Error = 3, + DontMatch = 4 +} + +public class ManageMatchFilterDto +{ + public MatchStateOption MatchStateOption { get; set; } = MatchStateOption.All; + public string SearchTerm { get; set; } = string.Empty; +} diff --git a/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs new file mode 100644 index 000000000..14617e7f0 --- /dev/null +++ b/API/DTOs/KavitaPlus/Manage/ManageMatchSeriesDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace API.DTOs.KavitaPlus.Manage; + +public class ManageMatchSeriesDto +{ + public SeriesDto Series { get; set; } + public bool IsMatched { get; set; } + public DateTime ValidUntilUtc { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs new file mode 100644 index 000000000..6b711513c --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using API.DTOs.SeriesDetail; + +namespace API.DTOs.KavitaPlus.Metadata; + +/// +/// Information about an individual issue/chapter/book from Kavita+ +/// +public class ExternalChapterDto +{ + public string Title { get; set; } + + public string IssueNumber { get; set; } + + public decimal? CriticRating { get; set; } + + public decimal? UserRating { get; set; } + + public string? Summary { get; set; } + + public IList? Writers { get; set; } + + public IList? Artists { get; set; } + + public DateTime? ReleaseDate { get; set; } + + public string? Publisher { get; set; } + + public string? CoverImageUrl { get; set; } + + public string? IssueUrl { get; set; } + + public IList CriticReviews { get; set; } + public IList UserReviews { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs new file mode 100644 index 000000000..2ea746214 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Scrobbling; +using API.Services.Plus; + +namespace API.DTOs.Recommendation; +#nullable enable + +/// +/// This is AniListSeries +/// +public class ExternalSeriesDetailDto +{ + public string Name { get; set; } + public int? AniListId { get; set; } + public long? MALId { get; set; } + public int? CbrId { get; set; } + public IList Synonyms { get; set; } = []; + public PlusMediaFormat PlusMediaFormat { get; set; } + public string? SiteUrl { get; set; } + public string? CoverUrl { get; set; } + public IList Genres { get; set; } + public IList Staff { get; set; } + public IList Tags { get; set; } + public string? Summary { get; set; } + public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + public int AverageScore { get; set; } + public int Chapters { get; set; } + public int Volumes { get; set; } + public IList? Relations { get; set; } = []; + public IList? Characters { get; set; } = []; + + #region Comic Only + public string? Publisher { get; set; } + /// + /// Only from CBR for . Full metadata about issues + /// + public IList? ChapterDtos { get; set; } + #endregion + + +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs new file mode 100644 index 000000000..796cfeb1a --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataFieldMappingDto.cs @@ -0,0 +1,22 @@ +using API.Entities.Enums; + +namespace API.DTOs.KavitaPlus.Metadata; + +public class MetadataFieldMappingDto +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs new file mode 100644 index 000000000..1dd26a7bc --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/MetadataSettingsDto.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using API.Entities; +using API.Entities.Enums; +using API.Entities.MetadataMatching; +using NotImplementedException = System.NotImplementedException; + +namespace API.DTOs.KavitaPlus.Metadata; + + +public class MetadataSettingsDto +{ + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } + + #region Chapter Metadata + /// + /// Allow Summary to be set within Chapter/Issue + /// + public bool EnableChapterSummary { get; set; } + /// + /// Allow Release Date to be set within Chapter/Issue + /// + public bool EnableChapterReleaseDate { get; set; } + /// + /// Allow Title to be set within Chapter/Issue + /// + public bool EnableChapterTitle { get; set; } + /// + /// Allow Publisher to be set within Chapter/Issue + /// + public bool EnableChapterPublisher { get; set; } + /// + /// Allow setting the cover image for the Chapter/Issue + /// + public bool EnableChapterCoverImage { get; set; } + #endregion + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + /// + /// A list of overrides that will enable writing to locked fields + /// + public List Overrides { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } + + + /// + /// Override list contains this field + /// + /// + /// + public bool HasOverride(MetadataSettingField field) + { + return Overrides.Contains(field); + } + + /// + /// If this Person role is allowed to be written + /// + /// + /// + public bool IsPersonAllowed(PersonRole character) + { + return PersonRoles.Contains(character); + } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs new file mode 100644 index 000000000..bb5a3f20a --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesCharacter.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable + +public enum CharacterRole +{ + Main = 0, + Supporting = 1, + Background = 2 +} + + +public class SeriesCharacter +{ + public string Name { get; set; } + public required string Description { get; set; } + public required string Url { get; set; } + public string? ImageUrl { get; set; } + public CharacterRole Role { get; set; } +} diff --git a/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs new file mode 100644 index 000000000..bd42e73a1 --- /dev/null +++ b/API/DTOs/KavitaPlus/Metadata/SeriesRelationship.cs @@ -0,0 +1,24 @@ +using API.DTOs.Scrobbling; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Services.Plus; + +namespace API.DTOs.KavitaPlus.Metadata; + +public class ALMediaTitle +{ + public string? EnglishTitle { get; set; } + public string RomajiTitle { get; set; } + public string NativeTitle { get; set; } + public string PreferredTitle { get; set; } +} + +public class SeriesRelationship +{ + public int AniListId { get; set; } + public int? MalId { get; set; } + public ALMediaTitle SeriesName { get; set; } + public RelationKind Relation { get; set; } + public ScrobbleProvider Provider { get; set; } + public PlusMediaFormat PlusMediaFormat { get; set; } = PlusMediaFormat.Manga; +} diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index c8c85063e..18dea9434 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -61,4 +61,10 @@ public class LibraryDto /// A set of globs that will exclude matching content from being scanned ///
public ICollection ExcludePatterns { get; set; } + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; } diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 9be3c117f..9f2f19a42 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -2,14 +2,28 @@ using API.Entities.Enums; namespace API.DTOs; +#nullable enable public class MangaFileDto { public int Id { get; init; } + /// + /// Absolute path to the archive file (normalized) + /// public string FilePath { get; init; } = default!; + /// + /// Number of pages for the given file + /// public int Pages { get; init; } + /// + /// How many bytes make up this file + /// public long Bytes { get; init; } public MangaFormat Format { get; init; } public DateTime Created { get; init; } + /// + /// File extension + /// + public string? Extension { get; set; } } diff --git a/API/DTOs/MediaErrors/MediaErrorDto.cs b/API/DTOs/MediaErrors/MediaErrorDto.cs index d890108d2..bfaf57124 100644 --- a/API/DTOs/MediaErrors/MediaErrorDto.cs +++ b/API/DTOs/MediaErrors/MediaErrorDto.cs @@ -20,4 +20,6 @@ public class MediaErrorDto /// Exception message ///
public string Details { get; set; } + + public DateTime CreatedUtc { get; set; } } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index 903c327dc..bbd93d618 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities.Enums; namespace API.DTOs.Metadata; @@ -7,6 +8,7 @@ namespace API.DTOs.Metadata; /// /// Exclusively metadata about a given chapter /// +[Obsolete("Will not be maintained as of v0.8.1")] public class ChapterMetadataDto { public int Id { get; set; } @@ -18,10 +20,13 @@ public class ChapterMetadataDto public ICollection Characters { get; set; } = new List(); public ICollection Pencillers { get; set; } = new List(); public ICollection Inkers { get; set; } = new List(); + public ICollection Imprints { get; set; } = new List(); public ICollection Colorists { get; set; } = new List(); public ICollection Letterers { get; set; } = new List(); public ICollection Editors { get; set; } = new List(); public ICollection Translators { get; set; } = new List(); + public ICollection Teams { get; set; } = new List(); + public ICollection Locations { get; set; } = new List(); public ICollection Genres { get; set; } = new List(); diff --git a/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs new file mode 100644 index 000000000..aefd697ba --- /dev/null +++ b/API/DTOs/Metadata/Matching/ExternalSeriesMatchDto.cs @@ -0,0 +1,9 @@ +using API.DTOs.Recommendation; + +namespace API.DTOs.Metadata.Matching; + +public class ExternalSeriesMatchDto +{ + public ExternalSeriesDetailDto Series { get; set; } + public float MatchRating { get; set; } +} diff --git a/API/DTOs/Metadata/Matching/MatchSeriesDto.cs b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs new file mode 100644 index 000000000..1f401e787 --- /dev/null +++ b/API/DTOs/Metadata/Matching/MatchSeriesDto.cs @@ -0,0 +1,20 @@ +namespace API.DTOs.Metadata.Matching; + +/// +/// Used for matching a series with Kavita+ for metadata and scrobbling +/// +public class MatchSeriesDto +{ + /// + /// When set, Kavita will stop attempting to match this series and will not perform any scrobbling + /// + public bool DontMatch { get; set; } + /// + /// Series Id to pull internal metadata from to improve matching + /// + public int SeriesId { get; set; } + /// + /// Free form text to query for. Can be a url and ids will be parsed from it + /// + public string Query { get; set; } +} diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Person/BrowsePersonDto.cs new file mode 100644 index 000000000..8d6999973 --- /dev/null +++ b/API/DTOs/Person/BrowsePersonDto.cs @@ -0,0 +1,16 @@ +namespace API.DTOs; + +/// +/// Used to browse writers and click in to see their series +/// +public class BrowsePersonDto : PersonDto +{ + /// + /// Number of Series this Person is the Writer for + /// + public int SeriesCount { get; set; } + /// + /// Number or Issues this Person is the Writer for + /// + public int IssueCount { get; set; } +} diff --git a/API/DTOs/Person/PersonDto.cs b/API/DTOs/Person/PersonDto.cs new file mode 100644 index 000000000..511317f2a --- /dev/null +++ b/API/DTOs/Person/PersonDto.cs @@ -0,0 +1,40 @@ +using System.Runtime.Serialization; + +namespace API.DTOs; +#nullable enable + +public class PersonDto +{ + public int Id { get; set; } + public required string Name { get; set; } + + public bool CoverImageLocked { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } + + public string? CoverImage { get; set; } + + public string? Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs new file mode 100644 index 000000000..d21fb7350 --- /dev/null +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs; +#nullable enable + +public class UpdatePersonDto +{ + [Required] + public int Id { get; init; } + [Required] + public bool CoverImageLocked { get; set; } + [Required] + public string Name {get; set;} + public string? Description { get; set; } + + public int? AniListId { get; set; } + public long? MalId { get; set; } + public string? HardcoverId { get; set; } + public string? Asin { get; set; } +} diff --git a/API/DTOs/PersonDto.cs b/API/DTOs/PersonDto.cs deleted file mode 100644 index 85cc72bb0..000000000 --- a/API/DTOs/PersonDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -using API.Entities.Enums; - -namespace API.DTOs; - -public class PersonDto -{ - public int Id { get; set; } - public required string Name { get; set; } - public PersonRole Role { get; set; } -} 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/Reader/CreatePersonalToCDto.cs b/API/DTOs/Reader/CreatePersonalToCDto.cs index 25526b490..3b80ece4a 100644 --- a/API/DTOs/Reader/CreatePersonalToCDto.cs +++ b/API/DTOs/Reader/CreatePersonalToCDto.cs @@ -1,4 +1,5 @@ namespace API.DTOs.Reader; +#nullable enable public class CreatePersonalToCDto { diff --git a/API/DTOs/Reader/HourEstimateRangeDto.cs b/API/DTOs/Reader/HourEstimateRangeDto.cs index 4343e2e93..8c8bd11a9 100644 --- a/API/DTOs/Reader/HourEstimateRangeDto.cs +++ b/API/DTOs/Reader/HourEstimateRangeDto.cs @@ -16,5 +16,5 @@ public record HourEstimateRangeDto /// /// Estimated average hours to read the selection /// - public int AvgHours { get; init; } = 1; + public float AvgHours { get; init; } = 1f; } diff --git a/API/DTOs/ReadingLists/CBL/CblBook.cs b/API/DTOs/ReadingLists/CBL/CblBook.cs index 0bf16a1a4..08930e208 100644 --- a/API/DTOs/ReadingLists/CBL/CblBook.cs +++ b/API/DTOs/ReadingLists/CBL/CblBook.cs @@ -1,4 +1,5 @@ using System.Xml.Serialization; +using API.Data.Metadata; namespace API.DTOs.ReadingLists.CBL; @@ -21,6 +22,12 @@ public class CblBook [XmlAttribute("Year")] public string Year { get; set; } /// + /// Main Series, Annual, Limited Series + /// + /// This maps to Format tag + [XmlAttribute("Format")] + public string Format { get; set; } + /// /// The underlying filetype /// /// This is not part of the standard and explicitly for Kavita to support non cbz/cbr files diff --git a/API/DTOs/ReadingLists/DeleteReadingListsDto.cs b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs new file mode 100644 index 000000000..8417f8132 --- /dev/null +++ b/API/DTOs/ReadingLists/DeleteReadingListsDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.ReadingLists; + +public class DeleteReadingListsDto +{ + [Required] + public IList ReadingListIds { get; set; } +} diff --git a/API/DTOs/ReadingLists/PromoteReadingListsDto.cs b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs new file mode 100644 index 000000000..f64bbb5ca --- /dev/null +++ b/API/DTOs/ReadingLists/PromoteReadingListsDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists; + +public class PromoteReadingListsDto +{ + public IList ReadingListIds { get; init; } + public bool Promoted { get; init; } +} diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs new file mode 100644 index 000000000..4532df7d5 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace API.DTOs.ReadingLists; + +public class ReadingListCast +{ + public ICollection Writers { get; set; } = []; + public ICollection CoverArtists { get; set; } = []; + public ICollection Publishers { get; set; } = []; + public ICollection Characters { get; set; } = []; + public ICollection Pencillers { get; set; } = []; + public ICollection Inkers { get; set; } = []; + public ICollection Imprints { get; set; } = []; + public ICollection Colorists { get; set; } = []; + public ICollection Letterers { get; set; } = []; + public ICollection Editors { get; set; } = []; + public ICollection Translators { get; set; } = []; + public ICollection Teams { get; set; } = []; + public ICollection Locations { get; set; } = []; +} diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index f8791b0d6..6508e7bd4 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -1,8 +1,11 @@ using System; +using API.Entities.Enums; +using API.Entities.Interfaces; namespace API.DTOs.ReadingLists; +#nullable enable -public class ReadingListDto +public class ReadingListDto : IHasCoverImage { public int Id { get; init; } public string Title { get; set; } = default!; @@ -15,7 +18,16 @@ 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; + + public string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + + /// + /// Number of Items in the Reading List + /// + public int ItemCount { get; set; } + /// /// Minimum Year the Reading List starts /// @@ -32,5 +44,15 @@ public class ReadingListDto /// Maximum Month the Reading List starts ///
public int EndingMonth { get; set; } + /// + /// The highest age rating from all Series within the reading list + /// + public required AgeRating AgeRating { get; set; } = AgeRating.Unknown; + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } } diff --git a/API/DTOs/ReadingLists/ReadingListInfoDto.cs b/API/DTOs/ReadingLists/ReadingListInfoDto.cs new file mode 100644 index 000000000..bd95b9226 --- /dev/null +++ b/API/DTOs/ReadingLists/ReadingListInfoDto.cs @@ -0,0 +1,26 @@ +using API.DTOs.Reader; +using API.Entities.Interfaces; + +namespace API.DTOs.ReadingLists; + +public class ReadingListInfoDto : IHasReadTimeEstimate +{ + /// + /// Total Pages across all Reading List Items + /// + public int Pages { get; set; } + /// + /// Total Word count across all Reading List Items + /// + public long WordCount { get; set; } + /// + /// Are ALL Reading List Items epub + /// + public bool IsAllEpub { get; set; } + /// + public int MinHoursToRead { get; set; } + /// + public int MaxHoursToRead { get; set; } + /// + public float AvgHoursToRead { get; set; } +} diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 6d35a2961..4fca5360c 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -25,7 +25,7 @@ public class ReadingListItemDto /// /// Release Date from Chapter /// - public DateTime ReleaseDate { get; set; } + public DateTime? ReleaseDate { get; set; } /// /// Used internally only /// @@ -33,10 +33,16 @@ public class ReadingListItemDto /// /// The last time a reading list item (underlying chapter) was read by current authenticated user /// - public DateTime LastReadingProgressUtc { get; set; } + public DateTime? LastReadingProgressUtc { get; set; } /// /// File size of underlying item /// /// This is only used for CDisplayEx public long FileSize { get; set; } + /// + /// The chapter summary + /// + public string? Summary { get; set; } + + public bool IsSpecial { get; set; } } diff --git a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs b/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs deleted file mode 100644 index 9aa852fd7..000000000 --- a/API/DTOs/Recommendation/ExternalSeriesDetailDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using API.DTOs.Scrobbling; -using API.Services.Plus; - -namespace API.DTOs.Recommendation; -#nullable enable - -public class ExternalSeriesDetailDto -{ - public string Name { get; set; } - public int? AniListId { get; set; } - public long? MALId { get; set; } - public IList Synonyms { get; set; } - public MediaFormat PlusMediaFormat { get; set; } - public string? SiteUrl { get; set; } - public string? CoverUrl { get; set; } - public IList Genres { get; set; } - public IList Staff { get; set; } - public IList Tags { get; set; } - public string? Summary { get; set; } - public int? VolumeCount { get; set; } - public int? ChapterCount { get; set; } - public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; -} diff --git a/API/DTOs/Recommendation/ExternalSeriesDto.cs b/API/DTOs/Recommendation/ExternalSeriesDto.cs index 55d2d320c..d393443af 100644 --- a/API/DTOs/Recommendation/ExternalSeriesDto.cs +++ b/API/DTOs/Recommendation/ExternalSeriesDto.cs @@ -12,4 +12,6 @@ public class ExternalSeriesDto public int? AniListId { get; set; } public long? MalId { get; set; } public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.AniList; + + } diff --git a/API/DTOs/Recommendation/SeriesStaffDto.cs b/API/DTOs/Recommendation/SeriesStaffDto.cs index 0c1e9759d..e4c6f6423 100644 --- a/API/DTOs/Recommendation/SeriesStaffDto.cs +++ b/API/DTOs/Recommendation/SeriesStaffDto.cs @@ -4,6 +4,8 @@ public class SeriesStaffDto { public required string Name { get; set; } + public string? FirstName { get; set; } + public string? LastName { get; set; } public required string Url { get; set; } public required string Role { get; set; } public string? ImageUrl { get; set; } diff --git a/API/DTOs/RefreshSeriesDto.cs b/API/DTOs/RefreshSeriesDto.cs index 64a684394..0e94fc44b 100644 --- a/API/DTOs/RefreshSeriesDto.cs +++ b/API/DTOs/RefreshSeriesDto.cs @@ -18,4 +18,9 @@ public class RefreshSeriesDto ///
/// This is expensive if true. Defaults to true. public bool ForceUpdate { get; init; } = true; + /// + /// Should the task force re-calculation of colorscape. + /// + /// This is expensive if true. Defaults to true. + public bool ForceColorscape { get; init; } = false; } diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index b6132046f..2d4d3b77f 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; namespace API.DTOs; +#nullable enable public class RegisterDto { @@ -9,8 +10,8 @@ public class RegisterDto /// /// An email to register with. Optional. Provides Forgot Password functionality /// - public string Email { get; init; } = default!; + public string? Email { get; set; } = default!; [Required] - [StringLength(32, MinimumLength = 6)] + [StringLength(256, MinimumLength = 6)] public string Password { get; set; } = default!; } 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/Scrobbling/MediaRecommendationDto.cs b/API/DTOs/Scrobbling/MediaRecommendationDto.cs index c83694b2b..3f565296b 100644 --- a/API/DTOs/Scrobbling/MediaRecommendationDto.cs +++ b/API/DTOs/Scrobbling/MediaRecommendationDto.cs @@ -2,6 +2,7 @@ using API.Services.Plus; namespace API.DTOs.Scrobbling; +#nullable enable public record MediaRecommendationDto { diff --git a/API/DTOs/Scrobbling/PlusSeriesDto.cs b/API/DTOs/Scrobbling/PlusSeriesDto.cs index 552a86575..dca9aca92 100644 --- a/API/DTOs/Scrobbling/PlusSeriesDto.cs +++ b/API/DTOs/Scrobbling/PlusSeriesDto.cs @@ -1,14 +1,22 @@ namespace API.DTOs.Scrobbling; +#nullable enable -public record PlusSeriesDto +/// +/// Represents information about a potential Series for Kavita+ +/// +public record PlusSeriesRequestDto { public int? AniListId { get; set; } public long? MalId { get; set; } public string? GoogleBooksId { get; set; } public string? MangaDexId { get; set; } + /// + /// ComicBookRoundup Id + /// + public int? CbrId { get; set; } public string SeriesName { get; set; } public string? AltSeriesName { get; set; } - public MediaFormat MediaFormat { get; set; } + public PlusMediaFormat MediaFormat { get; set; } /// /// Optional but can help with matching /// diff --git a/API/DTOs/Scrobbling/ScrobbleDto.cs b/API/DTOs/Scrobbling/ScrobbleDto.cs index ca2c2e528..e8420e785 100644 --- a/API/DTOs/Scrobbling/ScrobbleDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleDto.cs @@ -22,7 +22,7 @@ public enum ScrobbleEventType /// /// Represents PlusMediaFormat /// -public enum MediaFormat +public enum PlusMediaFormat { [Description("Manga")] Manga = 1, @@ -44,7 +44,7 @@ public class ScrobbleDto public string AniListToken { get; set; } public string SeriesName { get; set; } public string LocalizedSeriesName { get; set; } - public MediaFormat Format { get; set; } + public PlusMediaFormat Format { get; set; } public int? Year { get; set; } /// /// Optional AniListId if present on Kavita's WebLinks diff --git a/API/DTOs/Scrobbling/ScrobbleEventDto.cs b/API/DTOs/Scrobbling/ScrobbleEventDto.cs index 298e32180..b62c87866 100644 --- a/API/DTOs/Scrobbling/ScrobbleEventDto.cs +++ b/API/DTOs/Scrobbling/ScrobbleEventDto.cs @@ -1,6 +1,7 @@ using System; namespace API.DTOs.Scrobbling; +#nullable enable public class ScrobbleEventDto { 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/SeriesDetail/RelatedSeriesDto.cs b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs index 72271ff73..29b9eb263 100644 --- a/API/DTOs/SeriesDetail/RelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/RelatedSeriesDto.cs @@ -22,4 +22,5 @@ public class RelatedSeriesDto public IEnumerable Doujinshis { get; set; } = default!; public IEnumerable Parent { get; set; } = default!; public IEnumerable Editions { get; set; } = default!; + public IEnumerable Annuals { get; set; } = default!; } diff --git a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs index 59ce47bf6..76e77ae2c 100644 --- a/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs +++ b/API/DTOs/SeriesDetail/SeriesDetailPlusDto.cs @@ -2,6 +2,7 @@ using API.DTOs.Recommendation; namespace API.DTOs.SeriesDetail; +#nullable enable /// /// All the data from Kavita+ for Series Detail @@ -12,4 +13,5 @@ public class SeriesDetailPlusDto public RecommendationDto? Recommendations { get; set; } public IEnumerable Reviews { get; set; } public IEnumerable? Ratings { get; set; } + public ExternalSeriesDetailDto? Series { get; set; } } diff --git a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs index 8a81f766e..f19ad9ca8 100644 --- a/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs +++ b/API/DTOs/SeriesDetail/UpdateRelatedSeriesDto.cs @@ -17,4 +17,5 @@ public class UpdateRelatedSeriesDto public IList AlternativeVersions { get; set; } = default!; public IList Doujinshis { get; set; } = default!; public IList Editions { get; set; } = default!; + public IList Annuals { get; set; } = default!; } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index a8ec37d9c..6aa1ecefd 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -5,7 +5,7 @@ using API.Entities.Interfaces; namespace API.DTOs; #nullable enable -public class SeriesDto : IHasReadTimeEstimate +public class SeriesDto : IHasReadTimeEstimate, IHasCoverImage { public int Id { get; init; } public string? Name { get; init; } @@ -53,13 +53,38 @@ public class SeriesDto : IHasReadTimeEstimate /// public int MaxHoursToRead { get; set; } /// - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } /// /// The highest level folder for this Series /// public string FolderPath { get; set; } = default!; /// + /// Lowest path (that is under library root) that contains all files for the series. + /// + /// must be used before setting + public string? LowestFolderPath { get; set; } + /// /// The last time the folder for this series was scanned /// public DateTime LastFolderScanned { get; set; } + #region KavitaPlus + /// + /// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling. + /// + public bool DontMatch { get; set; } + /// + /// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it + /// + public bool IsBlacklisted { get; set; } + #endregion + + public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } } diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index e2a4c7aa2..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 /// @@ -30,10 +24,14 @@ public class SeriesMetadataDto public ICollection Characters { get; set; } = new List(); public ICollection Pencillers { get; set; } = new List(); public ICollection Inkers { get; set; } = new List(); + public ICollection Imprints { get; set; } = new List(); public ICollection Colorists { get; set; } = new List(); public ICollection Letterers { get; set; } = new List(); public ICollection Editors { get; set; } = new List(); public ICollection Translators { get; set; } = new List(); + public ICollection Teams { get; set; } = new List(); + public ICollection Locations { get; set; } = new List(); + /// /// Highest Age Rating from all Chapters /// @@ -80,10 +78,13 @@ public class SeriesMetadataDto public bool ColoristLocked { get; set; } public bool EditorLocked { get; set; } public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } public bool LettererLocked { get; set; } public bool PencillerLocked { get; set; } public bool PublisherLocked { get; set; } public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } public bool CoverArtistLocked { get; set; } public bool ReleaseYearLocked { get; set; } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 077ffbaac..78db88d7d 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,7 +1,10 @@ -using API.Entities.Enums; +using System; +using System.Text.Json.Serialization; +using API.Entities.Enums; using API.Services; namespace API.DTOs.Settings; +#nullable enable public class ServerSettingDto { @@ -43,6 +46,7 @@ public class ServerSettingDto /// /// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs. /// + public string InstallId { get; set; } = default!; /// /// The format that should be used when saving media for Kavita @@ -88,6 +92,14 @@ public class ServerSettingDto /// SMTP Configuration /// public SmtpConfigDto SmtpConfig { get; set; } + /// + /// The Date Kavita was first installed + /// + public DateTime? FirstInstallDate { get; set; } + /// + /// The Version of Kavita on the first run + /// + public string? FirstInstallVersion { get; set; } /// /// Are at least some basics filled in @@ -96,7 +108,7 @@ public class ServerSettingDto public bool IsEmailSetup() { return !string.IsNullOrEmpty(SmtpConfig.Host) - && !string.IsNullOrEmpty(SmtpConfig.UserName) + && !string.IsNullOrEmpty(SmtpConfig.SenderAddress) && !string.IsNullOrEmpty(HostName); } @@ -107,6 +119,6 @@ public class ServerSettingDto public bool IsEmailSetupForSendToDevice() { return !string.IsNullOrEmpty(SmtpConfig.Host) - && !string.IsNullOrEmpty(SmtpConfig.UserName); + && !string.IsNullOrEmpty(SmtpConfig.SenderAddress); } } diff --git a/API/DTOs/SideNav/SideNavStreamDto.cs b/API/DTOs/SideNav/SideNavStreamDto.cs index 1f3453611..fdef82a08 100644 --- a/API/DTOs/SideNav/SideNavStreamDto.cs +++ b/API/DTOs/SideNav/SideNavStreamDto.cs @@ -2,6 +2,7 @@ using API.Entities.Enums; namespace API.DTOs.SideNav; +#nullable enable public class SideNavStreamDto { diff --git a/API/DTOs/StandaloneChapterDto.cs b/API/DTOs/StandaloneChapterDto.cs new file mode 100644 index 000000000..2f4cd2ee1 --- /dev/null +++ b/API/DTOs/StandaloneChapterDto.cs @@ -0,0 +1,15 @@ +using API.Entities.Enums; + +namespace API.DTOs; +#nullable enable + +/// +/// Used on Person Profile page +/// +public class StandaloneChapterDto : ChapterDto +{ + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + public string VolumeTitle { get; set; } +} diff --git a/API/DTOs/Statistics/ReadHistoryEvent.cs b/API/DTOs/Statistics/ReadHistoryEvent.cs index 9e32aa792..496148789 100644 --- a/API/DTOs/Statistics/ReadHistoryEvent.cs +++ b/API/DTOs/Statistics/ReadHistoryEvent.cs @@ -1,6 +1,7 @@ using System; namespace API.DTOs.Statistics; +#nullable enable /// /// Represents a single User's reading event @@ -13,6 +14,7 @@ public class ReadHistoryEvent public int SeriesId { get; set; } public required string SeriesName { get; set; } = default!; public DateTime ReadDate { get; set; } + public DateTime ReadDateUtc { get; set; } public int ChapterId { get; set; } - public required string ChapterNumber { get; set; } = default!; + public required float ChapterNumber { get; set; } = default!; } diff --git a/API/DTOs/Statistics/TopReadsDto.cs b/API/DTOs/Statistics/TopReadsDto.cs index d8e3b1eb0..806360533 100644 --- a/API/DTOs/Statistics/TopReadsDto.cs +++ b/API/DTOs/Statistics/TopReadsDto.cs @@ -8,11 +8,11 @@ public class TopReadDto /// /// Amount of time read on Comic libraries /// - public long ComicsTime { get; set; } + public float ComicsTime { get; set; } /// /// Amount of time read on /// - public long BooksTime { get; set; } - public long MangaTime { get; set; } + public float BooksTime { get; set; } + public float MangaTime { get; set; } } diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs index 5e3f5aa5d..5da4b491e 100644 --- a/API/DTOs/Statistics/UserReadStatistics.cs +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; namespace API.DTOs.Statistics; +#nullable enable public class UserReadStatistics { diff --git a/API/DTOs/Stats/FileExtensionExportDto.cs b/API/DTOs/Stats/FileExtensionExportDto.cs new file mode 100644 index 000000000..6ed554d75 --- /dev/null +++ b/API/DTOs/Stats/FileExtensionExportDto.cs @@ -0,0 +1,15 @@ +using CsvHelper.Configuration.Attributes; + +namespace API.DTOs.Stats; + +/// +/// Excel export for File Extension Report +/// +public class FileExtensionExportDto +{ + [Name("Path")] + public string FilePath { get; set; } + + [Name("Extension")] + public string Extension { get; set; } +} diff --git a/API/DTOs/Stats/FileFormatDto.cs b/API/DTOs/Stats/FileFormatDto.cs deleted file mode 100644 index 6319bd2a9..000000000 --- a/API/DTOs/Stats/FileFormatDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using API.Entities.Enums; - -namespace API.DTOs.Stats; - -public class FileFormatDto -{ - /// - /// The extension with the ., in lowercase - /// - public required string Extension { get; set; } - /// - /// Format of extension - /// - public required MangaFormat Format { get; set; } -} diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs deleted file mode 100644 index 41c4c8264..000000000 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System; -using System.Collections.Generic; -using API.Entities.Enums; - -namespace API.DTOs.Stats; -#nullable enable - -/// -/// Represents information about a Kavita Installation -/// -public class ServerInfoDto -{ - /// - /// Unique Id that represents a unique install - /// - public required string InstallId { get; set; } - public required string Os { get; set; } - /// - /// If the Kavita install is using Docker - /// - public bool IsDocker { get; set; } - /// - /// Version of .NET instance is running - /// - public required string DotnetVersion { get; set; } - /// - /// Version of Kavita - /// - public required string KavitaVersion { get; set; } - /// - /// Number of Cores on the instance - /// - public int NumOfCores { get; set; } - /// - /// The number of libraries on the instance - /// - public int NumberOfLibraries { get; set; } - /// - /// Does any user have bookmarks - /// - public bool HasBookmarks { get; set; } - /// - /// The site theme the install is using - /// - /// Introduced in v0.5.2 - public string? ActiveSiteTheme { get; set; } - /// - /// The reading mode the main user has as a preference - /// - /// Introduced in v0.5.2 - public ReaderMode MangaReaderMode { get; set; } - - /// - /// Number of users on the install - /// - /// Introduced in v0.5.2 - public int NumberOfUsers { get; set; } - - /// - /// Number of collections on the install - /// - /// Introduced in v0.5.2 - public int NumberOfCollections { get; set; } - /// - /// Number of reading lists on the install (Sum of all users) - /// - /// Introduced in v0.5.2 - public int NumberOfReadingLists { get; set; } - /// - /// Is OPDS enabled - /// - /// Introduced in v0.5.2 - public bool OPDSEnabled { get; set; } - /// - /// Total number of files in the instance - /// - /// Introduced in v0.5.2 - public int TotalFiles { get; set; } - /// - /// Total number of Genres in the instance - /// - /// Introduced in v0.5.4 - public int TotalGenres { get; set; } - /// - /// Total number of People in the instance - /// - /// Introduced in v0.5.4 - public int TotalPeople { get; set; } - /// - /// Number of users on this instance using Card Layout - /// - /// Introduced in v0.5.4 - public int UsersOnCardLayout { get; set; } - /// - /// Number of users on this instance using List Layout - /// - /// Introduced in v0.5.4 - public int UsersOnListLayout { get; set; } - /// - /// Max number of Series for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxSeriesInALibrary { get; set; } - /// - /// Max number of Volumes for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxVolumesInASeries { get; set; } - /// - /// Max number of Chapters for any library on the instance - /// - /// Introduced in v0.5.4 - public int MaxChaptersInASeries { get; set; } - /// - /// Does this instance have relationships setup between series - /// - /// Introduced in v0.5.4 - public bool UsingSeriesRelationships { get; set; } - /// - /// A list of background colors set on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderBackgroundColors { get; set; } - /// - /// A list of Page Split defaults being used on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderPageSplittingModes { get; set; } - /// - /// A list of Layout Mode defaults being used on the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable MangaReaderLayoutModes { get; set; } - /// - /// A list of file formats existing in the instance - /// - /// Introduced in v0.6.0 - public required IEnumerable FileFormats { get; set; } - /// - /// If there is at least one user that is using an age restricted profile on the instance - /// - /// Introduced in v0.6.0 - public bool UsingRestrictedProfiles { get; set; } - /// - /// Number of users using the Emulate Comic Book setting - /// - /// Introduced in v0.7.0 - public int UsersWithEmulateComicBook { get; set; } - /// - /// Percent (0.0-1.0) of libraries with folder watching enabled - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesWithFolderWatchingEnabled { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Search - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInSearch { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Recommended - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInRecommended { get; set; } - /// - /// Percent (0.0-1.0) of libraries included in Dashboard - /// - /// Introduced in v0.7.0 - public float PercentOfLibrariesIncludedInDashboard { get; set; } - /// - /// Total reading hours of all users - /// - /// Introduced in v0.7.0 - public long TotalReadingHours { get; set; } - /// - /// The encoding the server is using to save media - /// - /// Added in v0.7.3 - public EncodeFormat EncodeMediaAs { get; set; } - /// - /// The last user reading progress on the server (in UTC) - /// - /// Added in v0.7.4 - public DateTime LastReadTime { get; set; } -} diff --git a/API/DTOs/Stats/ServerInfoSlimDto.cs b/API/DTOs/Stats/ServerInfoSlimDto.cs index e8db6a2b0..0b47fa2f3 100644 --- a/API/DTOs/Stats/ServerInfoSlimDto.cs +++ b/API/DTOs/Stats/ServerInfoSlimDto.cs @@ -1,4 +1,7 @@ -namespace API.DTOs.Stats; +using System; + +namespace API.DTOs.Stats; +#nullable enable /// /// This is just for the Server tab on UI @@ -17,5 +20,13 @@ public class ServerInfoSlimDto /// Version of Kavita /// public required string KavitaVersion { get; set; } + /// + /// The Date Kavita was first installed + /// + public DateTime? FirstInstallDate { get; set; } + /// + /// The Version of Kavita on the first run + /// + public string? FirstInstallVersion { get; set; } } diff --git a/API/DTOs/Stats/V3/LibraryStatV3.cs b/API/DTOs/Stats/V3/LibraryStatV3.cs new file mode 100644 index 000000000..51af34b58 --- /dev/null +++ b/API/DTOs/Stats/V3/LibraryStatV3.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +public class LibraryStatV3 +{ + public bool IncludeInDashboard { get; set; } + public bool IncludeInSearch { get; set; } + public bool UsingFolderWatching { get; set; } + /// + /// Are any exclude patterns setup + /// + public bool UsingExcludePatterns { get; set; } + /// + /// Will this library create collections from ComicInfo + /// + public bool CreateCollectionsFromMetadata { get; set; } + /// + /// Will this library create reading lists from ComicInfo + /// + public bool CreateReadingListsFromMetadata { get; set; } + /// + /// Type of the Library + /// + public LibraryType LibraryType { get; set; } + public ICollection FileTypes { get; set; } + /// + /// Last time library was fully scanned + /// + public DateTime LastScanned { get; set; } + /// + /// Number of folders the library has + /// + public int NumberOfFolders { get; set; } + + +} diff --git a/API/DTOs/Stats/V3/RelationshipStatV3.cs b/API/DTOs/Stats/V3/RelationshipStatV3.cs new file mode 100644 index 000000000..e8e1e7440 --- /dev/null +++ b/API/DTOs/Stats/V3/RelationshipStatV3.cs @@ -0,0 +1,12 @@ +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +/// +/// KavitaStats - Information about Series Relationships +/// +public class RelationshipStatV3 +{ + public int Count { get; set; } + public RelationKind Relationship { get; set; } +} diff --git a/API/DTOs/Stats/V3/ServerInfoV3Dto.cs b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs new file mode 100644 index 000000000..0bf95403f --- /dev/null +++ b/API/DTOs/Stats/V3/ServerInfoV3Dto.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.DTOs.Stats.V3; + +/// +/// Represents information about a Kavita Installation for Kavita Stats v3 API +/// +public class ServerInfoV3Dto +{ + /// + /// Unique Id that represents a unique install + /// + public required string InstallId { get; set; } + public required string Os { get; set; } + /// + /// If the Kavita install is using Docker + /// + public bool IsDocker { get; set; } + /// + /// Version of .NET instance is running + /// + public required string DotnetVersion { get; set; } + /// + /// Version of Kavita + /// + public required string KavitaVersion { get; set; } + /// + /// Version of Kavita on Installation + /// + public required string InitialKavitaVersion { get; set; } + /// + /// Date of first Installation + /// + public DateTime InitialInstallDate { get; set; } + /// + /// Number of Cores on the instance + /// + public int NumOfCores { get; set; } + /// + /// OS locale on the instance + /// + public string OsLocale { get; set; } + /// + /// Milliseconds to open a random archive (zip/cbz) for reading + /// + public long TimeToOpeCbzMs { get; set; } + /// + /// Number of pages for said archive (zip/cbz) + /// + public long TimeToOpenCbzPages { get; set; } + /// + /// Milliseconds to get a response from KavitaStats API + /// + /// This pings a health check and does not capture any IP Information + public long TimeToPingKavitaStatsApi { get; set; } + /// + /// If using the downloading metadata feature + /// + /// Kavita+ Only + public bool MatchedMetadataEnabled { get; set; } + + + + #region Media + /// + /// Number of collections on the install + /// + public int NumberOfCollections { get; set; } + /// + /// Number of reading lists on the install (Sum of all users) + /// + public int NumberOfReadingLists { get; set; } + /// + /// Total number of files in the instance + /// + public int TotalFiles { get; set; } + /// + /// Total number of Genres in the instance + /// + public int TotalGenres { get; set; } + /// + /// Total number of Series in the instance + /// + public int TotalSeries { get; set; } + /// + /// Total number of Libraries in the instance + /// + public int TotalLibraries { get; set; } + /// + /// Total number of People in the instance + /// + public int TotalPeople { get; set; } + /// + /// Max number of Series for any library on the instance + /// + public int MaxSeriesInALibrary { get; set; } + /// + /// Max number of Volumes for any library on the instance + /// + public int MaxVolumesInASeries { get; set; } + /// + /// Max number of Chapters for any library on the instance + /// + public int MaxChaptersInASeries { get; set; } + /// + /// Everything about the Libraries on the instance + /// + public IList Libraries { get; set; } + /// + /// Everything around Series Relationships between series + /// + public IList Relationships { get; set; } + #endregion + + #region Server + /// + /// Is OPDS enabled + /// + public bool OpdsEnabled { get; set; } + /// + /// The encoding the server is using to save media + /// + public EncodeFormat EncodeMediaAs { get; set; } + /// + /// The last user reading progress on the server (in UTC) + /// + public DateTime LastReadTime { get; set; } + /// + /// Is this server using Kavita+ + /// + public bool ActiveKavitaPlusSubscription { get; set; } + #endregion + + #region Users + /// + /// If there is at least one user that is using an age restricted profile on the instance + /// + /// Introduced in v0.6.0 + public bool UsingRestrictedProfiles { get; set; } + + public IList Users { get; set; } + + #endregion +} diff --git a/API/DTOs/Stats/V3/UserStatV3.cs b/API/DTOs/Stats/V3/UserStatV3.cs new file mode 100644 index 000000000..7f4e080ba --- /dev/null +++ b/API/DTOs/Stats/V3/UserStatV3.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using API.Data.Misc; +using API.Entities.Enums.Device; + +namespace API.DTOs.Stats.V3; + +public class UserStatV3 +{ + public AgeRestriction AgeRestriction { get; set; } + /// + /// The last reading progress on the server (in UTC) + /// + public DateTime LastReadTime { get; set; } + /// + /// The last login on the server (in UTC) + /// + public DateTime LastLogin { get; set; } + /// + /// Has the user gone through email confirmation + /// + public bool IsEmailConfirmed { get; set; } + /// + /// Is the Email a valid address + /// + public bool HasValidEmail { get; set; } + /// + /// Float between 0-1 to showcase how much of the libraries a user has access to + /// + public float PercentageOfLibrariesHasAccess { get; set; } + /// + /// Number of reading lists this user created + /// + public int ReadingListsCreatedCount { get; set; } + /// + /// Number of collections this user created + /// + public int CollectionsCreatedCount { get; set; } + /// + /// Number of series in want to read for this user + /// + public int WantToReadSeriesCount { get; set; } + /// + /// Active locale for the user + /// + public string Locale { get; set; } + /// + /// Active Theme (name) + /// + public string ActiveTheme { get; set; } + /// + /// Number of series with Bookmarks created + /// + public int SeriesBookmarksCreatedCount { get; set; } + /// + /// Kavita+ only - Has an AniList Token set + /// + public bool HasAniListToken { get; set; } + /// + /// Kavita+ only - Has a MAL Token set + /// + public bool HasMALToken { get; set; } + /// + /// Number of Smart Filters a user has created + /// + public int SmartFilterCreatedCount { get; set; } + /// + /// Is the user sharing reviews + /// + public bool IsSharingReviews { get; set; } + /// + /// The number of devices setup and their platforms + /// + public ICollection DevicePlatforms { get; set; } + /// + /// Roles for this user + /// + public ICollection Roles { get; set; } + + +} diff --git a/API/DTOs/TachiyomiChapterDto.cs b/API/DTOs/TachiyomiChapterDto.cs new file mode 100644 index 000000000..ecdd5115c --- /dev/null +++ b/API/DTOs/TachiyomiChapterDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs; +#nullable enable + +/// +/// This is explicitly for Tachiyomi. Number field was removed in v0.8.0, but Tachiyomi needs it for the hacks. +/// +public class TachiyomiChapterDto : ChapterDto +{ + /// + /// Smallest number of the Range. + /// + public string Number { get; init; } = default!; +} diff --git a/API/DTOs/Theme/ColorScapeDto.cs b/API/DTOs/Theme/ColorScapeDto.cs new file mode 100644 index 000000000..066e87d84 --- /dev/null +++ b/API/DTOs/Theme/ColorScapeDto.cs @@ -0,0 +1,19 @@ +namespace API.DTOs.Theme; +#nullable enable + +/// +/// A set of colors for the color scape system in the UI +/// +public class ColorScapeDto +{ + public string? Primary { get; set; } + public string? Secondary { get; set; } + + public ColorScapeDto(string? primary, string? secondary) + { + Primary = primary; + Secondary = secondary; + } + + public static readonly ColorScapeDto Empty = new ColorScapeDto(null, null); +} diff --git a/API/DTOs/Theme/DownloadableSiteThemeDto.cs b/API/DTOs/Theme/DownloadableSiteThemeDto.cs new file mode 100644 index 000000000..dbcedfe61 --- /dev/null +++ b/API/DTOs/Theme/DownloadableSiteThemeDto.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace API.DTOs.Theme; + + +public class DownloadableSiteThemeDto +{ + /// + /// Theme Name + /// + public string Name { get; set; } + /// + /// Url to download css file + /// + public string CssUrl { get; set; } + public string CssFile { get; set; } + /// + /// Url to preview image + /// + public IList PreviewUrls { get; set; } + /// + /// If Already downloaded + /// + public bool AlreadyDownloaded { get; set; } + /// + /// Sha of the file + /// + public string Sha { get; set; } + /// + /// Path of the Folder the files reside in + /// + public string Path { get; set; } + /// + /// Author of the theme + /// + /// Derived from Readme + public string Author { get; set; } + /// + /// Last version tested against + /// + /// Derived from Readme + public string LastCompatibleVersion { get; set; } + /// + /// If version compatible with version + /// + public bool IsCompatible { get; set; } + /// + /// Small blurb about the Theme + /// + public string Description { get; set; } +} diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index 18a281b56..eb2a14904 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using API.Entities.Enums.Theme; using API.Services; @@ -30,5 +31,21 @@ public class SiteThemeDto /// Where did the theme come from /// public ThemeProvider Provider { get; set; } + + public IList PreviewUrls { get; set; } + /// + /// Information about the theme + /// + public string Description { get; set; } + /// + /// Author of the Theme (only applies to non-system provided themes) + /// + public string Author { get; set; } + /// + /// Last compatible version. System provided will always be most current + /// + public string CompatibleVersion { get; set; } + + public string Selector => "bg-" + Name.ToLower(); } diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 63e3e8088..2f9550746 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,4 +1,7 @@ -namespace API.DTOs.Update; +using System.Collections.Generic; +using System.Runtime.InteropServices.JavaScript; + +namespace API.DTOs.Update; /// /// Update Notification denoting a new release available for user to update to @@ -13,7 +16,7 @@ public class UpdateNotificationDto /// Semver of the release version /// 0.4.3 /// - public required string UpdateVersion { get; init; } + public required string UpdateVersion { get; set; } /// /// Release body in HTML /// @@ -21,11 +24,11 @@ public class UpdateNotificationDto /// /// Title of the release /// - public required string UpdateTitle { get; init; } + public required string UpdateTitle { get; set; } /// /// Github Url /// - public required string UpdateUrl { get; init; } + public required string UpdateUrl { get; set; } /// /// If this install is within Docker /// @@ -37,7 +40,7 @@ public class UpdateNotificationDto /// /// Date of the publish /// - public required string PublishDate { get; init; } + public required string PublishDate { get; set; } /// /// Is the server on a nightly within this release /// @@ -50,4 +53,18 @@ public class UpdateNotificationDto /// Is the server on this version /// public bool IsReleaseEqual { get; set; } + + public IList Added { get; set; } + public IList Removed { get; set; } + public IList Changed { get; set; } + public IList Fixed { get; set; } + public IList Theme { get; set; } + public IList Developer { get; set; } + public IList Api { get; set; } + public IList FeatureRequests { get; set; } + public IList KnownIssues { get; set; } + /// + /// The part above the changelog part + /// + public string BlogPart { get; set; } } diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs new file mode 100644 index 000000000..2ca0a12a9 --- /dev/null +++ b/API/DTOs/UpdateChapterDto.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Metadata; +using API.Entities.Enums; + +namespace API.DTOs; + +public class UpdateChapterDto +{ + public int Id { get; init; } + public string Summary { get; set; } = string.Empty; + + /// + /// Genres for the Chapter + /// + public ICollection Genres { get; set; } = new List(); + /// + /// Collection of all Tags from underlying chapters for a Chapter + /// + public ICollection Tags { get; set; } = new List(); + + public ICollection Writers { get; set; } = new List(); + public ICollection CoverArtists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Imprints { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public ICollection Translators { get; set; } = new List(); + public ICollection Teams { get; set; } = new List(); + public ICollection Locations { get; set; } = new List(); + + /// + /// Highest Age Rating from all Chapters + /// + public AgeRating AgeRating { get; set; } = AgeRating.Unknown; + /// + /// Language of the content (BCP-47 code) + /// + public string Language { get; set; } = string.Empty; + + + /// + /// Locked by user so metadata updates from scan loop will not override AgeRating + /// + public bool AgeRatingLocked { get; set; } + public bool TitleNameLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } + public bool CoverArtistLocked { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + public bool ISBNLocked { get; set; } + public bool ReleaseDateLocked { get; set; } + + /// + /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. + /// + public float SortOrder { get; set; } + /// + /// Can the sort order be updated on scan or is it locked from UI + /// + public bool SortOrderLocked { get; set; } + + /// + /// Comma-separated link of urls to external services that have some relation to the Chapter + /// + public string WebLinks { get; set; } = string.Empty; + public string ISBN { get; set; } = string.Empty; + /// + /// Date which chapter was released + /// + public DateTime ReleaseDate { get; set; } + /// + /// Chapter title + /// + /// This should not be confused with Title which is used for special filenames. + public string TitleName { get; set; } = string.Empty; +} diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index b8e8e4953..de02f304d 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -19,8 +19,6 @@ public class UpdateLibraryDto [Required] public bool IncludeInDashboard { get; init; } [Required] - public bool IncludeInRecommended { get; init; } - [Required] public bool IncludeInSearch { get; init; } [Required] public bool ManageCollections { get; init; } @@ -28,6 +26,8 @@ public class UpdateLibraryDto public bool ManageReadingLists { get; init; } [Required] public bool AllowScrobbling { get; init; } + [Required] + public bool AllowMetadataMatching { get; init; } /// /// What types of files to allow the scanner to pickup /// @@ -36,5 +36,6 @@ public class UpdateLibraryDto /// /// A set of Glob patterns that the scanner will exclude processing /// + [Required] public ICollection ExcludePatterns { get; init; } } diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs index 52826f9d1..ab4ffcb22 100644 --- a/API/DTOs/UpdateSeriesDto.cs +++ b/API/DTOs/UpdateSeriesDto.cs @@ -1,4 +1,5 @@ namespace API.DTOs; +#nullable enable public class UpdateSeriesDto { diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs index 43318fe0f..75150b3fa 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!; + public SeriesMetadataDto SeriesMetadata { get; set; } = null!; } diff --git a/API/DTOs/Uploads/UploadFileDto.cs b/API/DTOs/Uploads/UploadFileDto.cs index 236a554b8..72fe7da9b 100644 --- a/API/DTOs/Uploads/UploadFileDto.cs +++ b/API/DTOs/Uploads/UploadFileDto.cs @@ -10,4 +10,9 @@ public class UploadFileDto /// Base Url encoding of the file to upload from (can be null) /// public required string Url { get; set; } + + /// + /// Lock the cover or not + /// + public bool LockCover { get; set; } = true; } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 7d54102da..e89e17df9 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,4 +1,5 @@  +using System; using API.DTOs.Account; namespace API.DTOs; diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 41160e362..14987ae77 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,9 +1,11 @@ using System.ComponentModel.DataAnnotations; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; namespace API.DTOs; +#nullable enable public class UserPreferencesDto { @@ -61,6 +63,13 @@ public class UserPreferencesDto /// [Required] public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + [Required] + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + + /// /// Book Reader Option: Override extra Margin /// @@ -104,7 +113,7 @@ public class UserPreferencesDto ///
/// Should default to Dark [Required] - public SiteTheme? Theme { get; set; } + public SiteThemeDto? Theme { get; set; } [Required] public string BookReaderThemeName { get; set; } = null!; [Required] @@ -152,4 +161,34 @@ 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; + + /// + /// Kavita+: Should this account have Scrobbling enabled for AniList + /// + public bool AniListScrobblingEnabled { get; set; } + /// + /// Kavita+: Should this account have Want to Read Sync enabled + /// + public bool WantToReadSync { get; set; } } diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 4820f4d95..8ef22a93b 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; using API.Entities; using API.Entities.Interfaces; +using API.Extensions; +using API.Services.Tasks.Scanner.Parser; namespace API.DTOs; -public class VolumeDto : IHasReadTimeEstimate +public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -19,7 +21,7 @@ public class VolumeDto : IHasReadTimeEstimate /// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14 ///
[Obsolete("Use MinNumber")] - public float Number { get; set; } + public int Number { get; set; } public int Pages { get; set; } public int PagesRead { get; set; } public DateTime LastModifiedUtc { get; set; } @@ -41,5 +43,35 @@ public class VolumeDto : IHasReadTimeEstimate /// public int MaxHoursToRead { get; set; } /// - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } + public long WordCount { get; set; } + + /// + /// Is this a loose leaf volume + /// + /// + public bool IsLooseLeaf() + { + return MinNumber.Is(Parser.LooseLeafVolumeNumber); + } + + /// + /// Does this volume hold only specials + /// + /// + public bool IsSpecial() + { + return MinNumber.Is(Parser.SpecialVolumeNumber); + } + + public string CoverImage { get; set; } + private bool CoverImageLocked { get; set; } + public string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 6d37d95bc..4533a5dbf 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -1,17 +1,24 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Metadata; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.UserPreferences; +using API.Entities.History; using API.Entities.Interfaces; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Entities.Scrobble; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; namespace API.Data; @@ -36,6 +43,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,7 +72,12 @@ 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!; + public DbSet ChapterPeople { get; set; } = null!; + public DbSet SeriesMetadataPeople { get; set; } = null!; + public DbSet EmailHistory { get; set; } = null!; + public DbSet MetadataSettings { get; set; } = null!; + public DbSet MetadataFieldMapping { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { @@ -114,10 +127,22 @@ public sealed class DataContext : IdentityDbContext b.Locale) .IsRequired(true) .HasDefaultValue("en"); + builder.Entity() + .Property(b => b.AniListScrobblingEnabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.WantToReadSync) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); builder.Entity() .Property(b => b.AllowScrobbling) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowMetadataMatching) + .HasDefaultValue(true); builder.Entity() .Property(b => b.WebLinks) @@ -149,6 +174,85 @@ public sealed class DataContext : IdentityDbContext s.ExternalSeriesMetadata) .HasForeignKey(em => em.SeriesId) .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.AgeRating) + .HasDefaultValue(AgeRating.Unknown); + + // Configure the many-to-many relationship for Movie and Person + builder.Entity() + .HasKey(cp => new { cp.ChapterId, cp.PersonId, cp.Role }); + + builder.Entity() + .HasOne(cp => cp.Chapter) + .WithMany(c => c.People) + .HasForeignKey(cp => cp.ChapterId); + + builder.Entity() + .HasOne(cp => cp.Person) + .WithMany(p => p.ChapterPeople) + .HasForeignKey(cp => cp.PersonId) + .OnDelete(DeleteBehavior.Cascade); + + + builder.Entity() + .HasKey(smp => new { smp.SeriesMetadataId, smp.PersonId, smp.Role }); + + builder.Entity() + .HasOne(smp => smp.SeriesMetadata) + .WithMany(sm => sm.People) + .HasForeignKey(smp => smp.SeriesMetadataId); + + builder.Entity() + .HasOne(smp => smp.Person) + .WithMany(p => p.SeriesMetadataPeople) + .HasForeignKey(smp => smp.PersonId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.OrderWeight) + .HasDefaultValue(0); + + builder.Entity() + .Property(x => x.AgeRatingMappings) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new Dictionary() + ); + + // Ensure blacklist is stored as a JSON array + builder.Entity() + .Property(x => x.Blacklist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Whitelist) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + builder.Entity() + .Property(x => x.Overrides) + .HasConversion( + v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List() + ); + + // Configure one-to-many relationship + builder.Entity() + .HasMany(x => x.FieldMappings) + .WithOne(x => x.MetadataSettings) + .HasForeignKey(x => x.MetadataSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.Enabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableCoverImage) + .HasDefaultValue(true); } #nullable enable @@ -156,10 +260,15 @@ public sealed class DataContext : IdentityDbContext logger) { - if (await dataContext.Library.AnyAsync(l => l.LibraryFileTypes.Count == 0)) + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLibrariesToHaveAllFileTypes")) { - logger.LogCritical("Running MigrateLibrariesToHaveAllFileTypes migration - Completed. This is not an error"); return; } diff --git a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs similarity index 85% rename from API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs rename to API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs index 89b2d9cfc..d36859e69 100644 --- a/API/Data/ManualMigrations/MigrateSmartFilterEncoding.cs +++ b/API/Data/ManualMigrations/v0.7.11/MigrateSmartFilterEncoding.cs @@ -3,7 +3,11 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Filtering.v2; +using API.Entities; +using API.Entities.History; using API.Helpers; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Data.ManualMigrations; @@ -21,10 +25,14 @@ public static class MigrateSmartFilterEncoding public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger) { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateSmartFilterEncoding")) + { + return; + } + logger.LogCritical("Running MigrateSmartFilterEncoding migration - Please be patient, this may take some time. This is not an error"); - - var smartFilters = dataContext.AppUserSmartFilter.ToList(); + var smartFilters = await dataContext.AppUserSmartFilter.ToListAsync(); foreach (var filter in smartFilters) { if (!ShouldMigrateFilter(filter.Filter)) continue; @@ -38,6 +46,14 @@ public static class MigrateSmartFilterEncoding await unitOfWork.CommitAsync(); } + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateSmartFilterEncoding", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + logger.LogCritical("Running MigrateSmartFilterEncoding migration - Completed. This is not an error"); } diff --git a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs similarity index 98% rename from API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs index 9eff55bc1..89485fd71 100644 --- a/API/Data/ManualMigrations/MigrateClearNightlyExternalSeriesRecords.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateClearNightlyExternalSeriesRecords.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Kavita.Common.EnvironmentInfo; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; diff --git a/API/Data/ManualMigrations/MigrateEmailTemplates.cs b/API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs similarity index 96% rename from API/Data/ManualMigrations/MigrateEmailTemplates.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs index ca0dc125b..0e406c386 100644 --- a/API/Data/ManualMigrations/MigrateEmailTemplates.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateEmailTemplates.cs @@ -21,10 +21,11 @@ public static class MigrateEmailTemplates var files = directoryService.GetFiles(directoryService.CustomizedTemplateDirectory); if (files.Any()) { - logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error"); return; } + logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error"); + // Write files to directory await DownloadAndWriteToFile(EmailChange, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailChange.html"), logger); await DownloadAndWriteToFile(EmailConfirm, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailConfirm.html"), logger); @@ -33,8 +34,7 @@ public static class MigrateEmailTemplates await DownloadAndWriteToFile(EmailTest, Path.Join(directoryService.CustomizedTemplateDirectory, "EmailTest.html"), logger); - - logger.LogCritical("Running MigrateEmailTemplates migration - Please be patient, this may take some time. This is not an error"); + logger.LogCritical("Running MigrateEmailTemplates migration - Completed. This is not an error"); } private static async Task DownloadAndWriteToFile(string url, string filePath, ILogger logger) diff --git a/API/Data/ManualMigrations/MigrateManualHistory.cs b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs similarity index 95% rename from API/Data/ManualMigrations/MigrateManualHistory.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs index be41f0992..eaf63c41c 100644 --- a/API/Data/ManualMigrations/MigrateManualHistory.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateManualHistory.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using API.Entities; +using API.Entities.History; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -16,8 +17,6 @@ public static class MigrateManualHistory { if (await dataContext.ManualMigrationHistory.AnyAsync()) { - logger.LogCritical( - "Running MigrateManualHistory migration - Completed. This is not an error"); return; } diff --git a/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs new file mode 100644 index 000000000..38b7cfbba --- /dev/null +++ b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeLookupName.cs @@ -0,0 +1,42 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +public static class MigrateVolumeLookupName +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateVolumeLookupName")) + { + return; + } + + logger.LogCritical( + "Running MigrateVolumeLookupName migration - Please be patient, this may take some time. This is not an error"); + + // Update all volumes to have LookupName as after this migration, name isn't used for lookup + var volumes = dataContext.Volume.ToList(); + foreach (var volume in volumes) + { + volume.LookupName = volume.Name; + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateVolumeLookupName", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateVolumeLookupName migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/MigrateVolumeNumber.cs b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs similarity index 84% rename from API/Data/ManualMigrations/MigrateVolumeNumber.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs index cae2e7f3c..712d826fa 100644 --- a/API/Data/ManualMigrations/MigrateVolumeNumber.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateVolumeNumber.cs @@ -13,8 +13,13 @@ namespace API.Data.ManualMigrations; ///
public static class MigrateVolumeNumber { - public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger) + public static async Task Migrate(DataContext dataContext, ILogger logger) { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateVolumeNumber")) + { + return; + } + if (await dataContext.Volume.AnyAsync(v => v.MaxNumber > 0)) { logger.LogCritical( diff --git a/API/Data/ManualMigrations/MigrateWantToReadExport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs similarity index 94% rename from API/Data/ManualMigrations/MigrateWantToReadExport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs index cff05b9a8..95a86c370 100644 --- a/API/Data/ManualMigrations/MigrateWantToReadExport.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadExport.cs @@ -20,6 +20,12 @@ public static class MigrateWantToReadExport { try { + + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadExport")) + { + return; + } + var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv"); if (File.Exists(importFile)) { diff --git a/API/Data/ManualMigrations/MigrateWantToReadImport.cs b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs similarity index 86% rename from API/Data/ManualMigrations/MigrateWantToReadImport.cs rename to API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs index 01982e58f..31df056d9 100644 --- a/API/Data/ManualMigrations/MigrateWantToReadImport.cs +++ b/API/Data/ManualMigrations/v0.7.14/MigrateWantToReadImport.cs @@ -6,6 +6,7 @@ using API.Data.Repositories; using API.Entities; using API.Services; using CsvHelper; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Data.ManualMigrations; @@ -15,8 +16,14 @@ namespace API.Data.ManualMigrations; ///
public static class MigrateWantToReadImport { - public static async Task Migrate(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) + public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, IDirectoryService directoryService, ILogger logger) { + + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadImport")) + { + return; + } + var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv"); var outputFile = Path.Join(directoryService.ConfigDirectory, "imported-want-to-read-migration.csv"); diff --git a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs b/API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs similarity index 93% rename from API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs rename to API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs index 290bd0dc9..5070a43d0 100644 --- a/API/Data/ManualMigrations/MigrateUserLibrarySideNavStream.cs +++ b/API/Data/ManualMigrations/v0.7.9/MigrateUserLibrarySideNavStream.cs @@ -14,6 +14,10 @@ public static class MigrateUserLibrarySideNavStream { public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger logger) { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateUserLibrarySideNavStream")) + { + return; + } var usersWithLibraryStreams = await dataContext.AppUser .Include(u => u.SideNavStreams) diff --git a/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs new file mode 100644 index 000000000..fac184dc9 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/ManualMigrateLooseLeafChapters.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +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/v0.8.0/ManualMigrateMixedSpecials.cs b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs new file mode 100644 index 000000000..cda83f05b --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/ManualMigrateMixedSpecials.cs @@ -0,0 +1,207 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +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; + +public class UserProgressCsvRecord +{ + public bool IsSpecial { get; set; } + public int AppUserId { get; set; } + public int PagesRead { get; set; } + public string Range { get; set; } + public string Number { get; set; } + public float MinNumber { get; set; } + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public int ProgressId { get; set; } +} + +/// +/// v0.8.0 migration to move Specials into their own volume and retain user progress. +/// +public static class MigrateMixedSpecials +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateMixedSpecials")) + { + return; + } + + logger.LogCritical( + "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 + { + 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 || d.Number == "0") + .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 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) + { + // 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.SpecialVolume) + .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 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 + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "ManualMigrateMixedSpecials", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + 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/v0.8.0/MigrateChapterFields.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs new file mode 100644 index 000000000..7d1f2dd12 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterFields.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + + + +/// +/// Introduced in v0.8.0, this migrates the existing Chapter and Volume 0 -> Parser defined, MangaFile.FileName +/// +public static class MigrateChapterFields +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateChapterFields")) + { + return; + } + + logger.LogCritical( + "Running MigrateChapterFields migration - Please be patient, this may take some time. This is not an error"); + + // Update all volumes only have specials in them (rare) + var volumesWithJustSpecials = dataContext.Volume + .Include(v => v.Chapters) + .Where(v => v.Name == "0" && v.Chapters.All(c => c.IsSpecial)) + .ToList(); + logger.LogCritical( + "Running MigrateChapterFields migration - Updating {Count} volumes that only have specials in them", volumesWithJustSpecials.Count); + foreach (var volume in volumesWithJustSpecials) + { + volume.Name = $"{Parser.SpecialVolumeNumber}"; + volume.MinNumber = Parser.SpecialVolumeNumber; + volume.MaxNumber = Parser.SpecialVolumeNumber; + } + + // Update all volumes that only have loose leafs in them + var looseLeafVolumes = dataContext.Volume + .Include(v => v.Chapters) + .Where(v => v.Name == "0" && v.Chapters.All(c => !c.IsSpecial)) + .ToList(); + logger.LogCritical( + "Running MigrateChapterFields migration - Updating {Count} volumes that only have loose leaf chapters in them", looseLeafVolumes.Count); + foreach (var volume in looseLeafVolumes) + { + volume.Name = $"{Parser.DefaultChapterNumber}"; + volume.MinNumber = Parser.DefaultChapterNumber; + volume.MaxNumber = Parser.DefaultChapterNumber; + } + + // Update all MangaFile + logger.LogCritical( + "Running MigrateChapterFields migration - Updating all MangaFiles"); + foreach (var mangaFile in dataContext.MangaFile) + { + mangaFile.FileName = Parser.RemoveExtensionIfSupported(mangaFile.FilePath); + } + + var looseLeafChapters = await dataContext.Chapter.Where(c => c.Number == "0").ToListAsync(); + logger.LogCritical( + "Running MigrateChapterFields migration - Updating {Count} loose leaf chapters", looseLeafChapters.Count); + foreach (var chapter in looseLeafChapters) + { + chapter.Number = Parser.DefaultChapter; + chapter.MinNumber = Parser.DefaultChapterNumber; + chapter.MaxNumber = Parser.DefaultChapterNumber; + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateChapterFields", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + + + logger.LogCritical( + "Running MigrateChapterFields migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs new file mode 100644 index 000000000..e31fa4b92 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterNumber.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// Introduced in v0.8.0, this migrates the existing Chapter Range -> Chapter Min/Max Number +/// +public static class MigrateChapterNumber +{ + public static async Task Migrate(DataContext dataContext, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateChapterNumber")) + { + return; + } + + logger.LogCritical( + "Running MigrateChapterNumber migration - Please be patient, this may take some time. This is not an error"); + + // Get all volumes + foreach (var chapter in dataContext.Chapter) + { + if (chapter.IsSpecial) + { + chapter.MinNumber = Parser.DefaultChapterNumber; + chapter.MaxNumber = Parser.DefaultChapterNumber; + continue; + } + chapter.MinNumber = Parser.MinNumberFromRange(chapter.Range); + chapter.MaxNumber = Parser.MaxNumberFromRange(chapter.Range); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateChapterNumber", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateChapterNumber migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs new file mode 100644 index 000000000..70a4b30f6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateChapterRange.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Extensions; +using API.Helpers.Builders; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 changed the range to that it doesn't have filename by default +/// +public static class MigrateChapterRange +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateChapterRange")) + { + return; + } + + logger.LogCritical( + "Running MigrateChapterRange migration - Please be patient, this may take some time. This is not an error"); + + var chapters = await dataContext.Chapter.ToListAsync(); + foreach (var chapter in chapters) + { + if (Parser.MinNumberFromRange(chapter.Range).Is(0.0f)) + { + chapter.Range = chapter.GetNumberTitle(); + } + } + + + // Save changes after processing all series + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateChapterRange", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateChapterRange migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs new file mode 100644 index 000000000..e29e706d0 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateCollectionTagToUserCollections.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +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") || + !await dataContext.AppUser.AnyAsync()) + { + 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/v0.8.0/MigrateDuplicateDarkTheme.cs b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs new file mode 100644 index 000000000..e414cd8cc --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateDuplicateDarkTheme.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +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 MigrateDuplicateDarkTheme +{ + public static async Task Migrate(DataContext dataContext, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateDuplicateDarkTheme")) + { + return; + } + + logger.LogCritical( + "Running MigrateDuplicateDarkTheme migration - Please be patient, this may take some time. This is not an error"); + + var darkThemes = await dataContext.SiteTheme.Where(t => t.Name == "Dark").ToListAsync(); + + if (darkThemes.Count > 1) + { + var correctDarkTheme = darkThemes.First(d => !string.IsNullOrEmpty(d.Description)); + + // Get users + var users = await dataContext.AppUser + .Include(u => u.UserPreferences) + .ThenInclude(p => p.Theme) + .Where(u => u.UserPreferences.Theme.Name == "Dark") + .ToListAsync(); + + // Find any users that have a duplicate Dark theme as default and switch to the correct one + foreach (var user in users) + { + if (string.IsNullOrEmpty(user.UserPreferences.Theme.Description)) + { + user.UserPreferences.Theme = correctDarkTheme; + } + } + await dataContext.SaveChangesAsync(); + + // Now remove the bad themes + dataContext.SiteTheme.RemoveRange(darkThemes.Where(d => string.IsNullOrEmpty(d.Description))); + + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateDuplicateDarkTheme", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateDuplicateDarkTheme migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs new file mode 100644 index 000000000..1dbc7f325 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateMangaFilePath.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +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/v0.8.0/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs new file mode 100644 index 000000000..631daeea8 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.0/MigrateProgressExport.cs @@ -0,0 +1,124 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +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/v0.8.1/MigrateLowestSeriesFolderPath.cs b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs new file mode 100644 index 000000000..2a68ca3d6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.1/MigrateLowestSeriesFolderPath.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.0 released with a bug around LowestSeriesPath. This resets it for all users. +/// +public static class MigrateLowestSeriesFolderPath +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLowestSeriesFolderPath")) + { + return; + } + + logger.LogCritical( + "Running MigrateLowestSeriesFolderPath migration - Please be patient, this may take some time. This is not an error"); + + var series = await dataContext.Series.Where(s => !string.IsNullOrEmpty(s.LowestFolderPath)).ToListAsync(); + foreach (var s in series) + { + s.LowestFolderPath = string.Empty; + unitOfWork.SeriesRepository.Update(s); + } + + // Save changes after processing all series + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateLowestSeriesFolderPath", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateLowestSeriesFolderPath migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs new file mode 100644 index 000000000..21abfdf10 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.2/ManualMigrateSwitchToWal.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.2 switches Default Kavita installs to WAL +/// +public static class ManualMigrateSwitchToWal +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateSwitchToWal")) + { + return; + } + + logger.LogCritical("Running ManualMigrateSwitchToWal migration - Please be patient, this may take some time. This is not an error"); + try + { + var connection = context.Database.GetDbConnection(); + await connection.OpenAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA journal_mode=WAL;"; + await command.ExecuteNonQueryAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error setting WAL"); + /* Swallow */ + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateSwitchToWal", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateSwitchToWal migration - Completed. This is not an error"); + } + +} diff --git a/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs new file mode 100644 index 000000000..e137afe7b --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.2/ManualMigrateThemeDescription.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.2 introduced Theme repo viewer, this adds Description to existing SiteTheme defaults +/// +public static class ManualMigrateThemeDescription +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateThemeDescription")) + { + return; + } + + logger.LogCritical("Running ManualMigrateThemeDescription migration - Please be patient, this may take some time. This is not an error"); + + var theme = await context.SiteTheme.FirstOrDefaultAsync(t => t.Name == "Dark"); + if (theme != null) + { + theme.Description = Seed.DefaultThemes.First().Description; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + + + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateThemeDescription", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateThemeDescription migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs new file mode 100644 index 000000000..851f4ac42 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.2/MigrateInitialInstallData.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using API.Services; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.2 I started collecting information on when the user first installed Kavita as a nice to have info for the user +/// +public static class MigrateInitialInstallData +{ + public static async Task Migrate(DataContext dataContext, ILogger logger, IDirectoryService directoryService) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateInitialInstallData")) + { + return; + } + + logger.LogCritical( + "Running MigrateInitialInstallData migration - Please be patient, this may take some time. This is not an error"); + + var settings = await dataContext.ServerSetting.ToListAsync(); + + // Get the Install Date as Date the DB was written + var dbFile = Path.Join(directoryService.ConfigDirectory, "kavita.db"); + if (!string.IsNullOrEmpty(dbFile) && directoryService.FileSystem.File.Exists(dbFile)) + { + var fi = directoryService.FileSystem.FileInfo.New(dbFile); + var setting = settings.First(s => s.Key == ServerSettingKey.FirstInstallDate); + setting.Value = fi.CreationTimeUtc.ToString(CultureInfo.InvariantCulture); + await dataContext.SaveChangesAsync(); + } + + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateInitialInstallData", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical( + "Running MigrateInitialInstallData migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs new file mode 100644 index 000000000..8e0db3c10 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.2/MigrateSeriesLowestFolderPath.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; +#nullable enable + +/// +/// Some linux-based users are having non-rooted LowestFolderPaths. This will attempt to fix it or null them. +/// Fixed in v0.8.2 +/// +public static class MigrateSeriesLowestFolderPath +{ + public static async Task Migrate(DataContext dataContext, ILogger logger, IDirectoryService directoryService) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateSeriesLowestFolderPath")) + { + return; + } + + logger.LogCritical("Running MigrateSeriesLowestFolderPath migration - Please be patient, this may take some time. This is not an error"); + + var seriesWithFolderPath = + await dataContext.Series.Where(s => !string.IsNullOrEmpty(s.LowestFolderPath)) + .Include(s => s.Library) + .ThenInclude(l => l.Folders) + .ToListAsync(); + + foreach (var series in seriesWithFolderPath) + { + var isValidPath = series.Library.Folders + .Any(folder => Parser.NormalizePath(series.LowestFolderPath!).StartsWith(Parser.NormalizePath(folder.Path), StringComparison.OrdinalIgnoreCase)); + + if (isValidPath) continue; + series.LowestFolderPath = null; + dataContext.Entry(series).State = EntityState.Modified; + } + + await dataContext.SaveChangesAsync(); + + + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateSeriesLowestFolderPath", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + + logger.LogCritical("Running MigrateSeriesLowestFolderPath migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs new file mode 100644 index 000000000..fc8b2e586 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateEncodeSettings.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using Flurl.Util; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// At some point, encoding settings wrote bad data to the backend, maybe in v0.8.0. This just fixes any bad data. +/// +public static class ManualMigrateEncodeSettings +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateEncodeSettings")) + { + return; + } + + logger.LogCritical("Running ManualMigrateEncodeSettings migration - Please be patient, this may take some time. This is not an error"); + + + var encodeAs = await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.EncodeMediaAs); + var coverSize = await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CoverImageSize); + + var encodeMap = new Dictionary + { + { EncodeFormat.WEBP.ToString(), ((int)EncodeFormat.WEBP).ToString() }, + { EncodeFormat.PNG.ToString(), ((int)EncodeFormat.PNG).ToString() }, + { EncodeFormat.AVIF.ToString(), ((int)EncodeFormat.AVIF).ToString() } + }; + + if (encodeMap.TryGetValue(encodeAs.Value, out var encodedValue)) + { + encodeAs.Value = encodedValue; + context.ServerSetting.Update(encodeAs); + } + + if (coverSize.Value == "0") + { + coverSize.Value = ((int)CoverImageSize.Default).ToString(); + context.ServerSetting.Update(coverSize); + } + + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateEncodeSettings", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateEncodeSettings migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs new file mode 100644 index 000000000..01d9ad45d --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateRemovePeople.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// Due to a bug in the initial merge of People/Scanner rework, people got messed up bad. This migration will clear out the table only for nightly users: 0.8.3.15/0.8.3.16 +/// +public static class ManualMigrateRemovePeople +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateRemovePeople")) + { + return; + } + + var version = BuildInfo.Version.ToString(); + if (version != "0.8.3.15" && version != "0.8.3.16") + { + return; + } + + logger.LogCritical("Running ManualMigrateRemovePeople migration - Please be patient, this may take some time. This is not an error"); + + context.Person.RemoveRange(context.Person); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateRemovePeople", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateRemovePeople migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs new file mode 100644 index 000000000..4f0ed3f96 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.4/ManualMigrateUnscrobbleBookLibraries.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.Enums; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// When I removed Scrobble support for Book libraries, I forgot to turn the setting off for said libraries. +/// +public static class ManualMigrateUnscrobbleBookLibraries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateUnscrobbleBookLibraries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateUnscrobbleBookLibraries migration - Please be patient, this may take some time. This is not an error"); + + var libs = await context.Library.Where(l => l.Type == LibraryType.Book).ToListAsync(); + foreach (var lib in libs) + { + lib.AllowScrobbling = false; + context.Entry(lib).State = EntityState.Modified; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateUnscrobbleBookLibraries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateUnscrobbleBookLibraries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs new file mode 100644 index 000000000..00233852a --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.4/MigrateLowestSeriesFolderPath2.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.3 still had a bug around LowestSeriesPath. This resets it for all users. +/// +public static class MigrateLowestSeriesFolderPath2 +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLowestSeriesFolderPath2")) + { + return; + } + + logger.LogCritical( + "Running MigrateLowestSeriesFolderPath2 migration - Please be patient, this may take some time. This is not an error"); + + var series = await dataContext.Series.Where(s => !string.IsNullOrEmpty(s.LowestFolderPath)).ToListAsync(); + foreach (var s in series) + { + s.LowestFolderPath = string.Empty; + unitOfWork.SeriesRepository.Update(s); + } + + // Save changes after processing all series + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateLowestSeriesFolderPath2", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateLowestSeriesFolderPath2 migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs new file mode 100644 index 000000000..60fd170fd --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateBlacklistTableToSeries.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ BlacklistedSeries table to Series entity to streamline implementation and generate a "Needs Manual Match" entry for the Series +/// +public static class ManualMigrateBlacklistTableToSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateBlacklistTableToSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateBlacklistTableToSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.SeriesBlacklist + .Include(s => s.Series.ExternalSeriesMetadata) + .Select(s => s.Series) + .ToListAsync(); + + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = true; + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() { SeriesId = series.Id }; + + if (series.ExternalSeriesMetadata.AniListId > 0) + { + series.IsBlacklisted = false; + logger.LogInformation("{SeriesName} was in Blacklist table, but has valid AniList Id, not blacklisting", series.Name); + } + + context.Series.Entry(series).State = EntityState.Modified; + } + // Remove everything in SeriesBlacklist (it will be removed in another migration) + context.SeriesBlacklist.RemoveRange(context.SeriesBlacklist); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateBlacklistTableToSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateBlacklistTableToSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs new file mode 100644 index 000000000..14bc57cb1 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateInvalidBlacklistSeries.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Entities.Metadata; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - Migrating Kavita+ Series that are Blacklisted but have valid ExternalSeries row +/// +public static class ManualMigrateInvalidBlacklistSeries +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateInvalidBlacklistSeries")) + { + return; + } + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var blacklistedSeries = await context.Series + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.IsBlacklisted && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue) + .ToListAsync(); + + foreach (var series in blacklistedSeries) + { + series.IsBlacklisted = false; + context.Series.Entry(series).State = EntityState.Modified; + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateInvalidBlacklistSeries", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateInvalidBlacklistSeries migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs new file mode 100644 index 000000000..30e4a6d8e --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateNeedsManualMatch.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Manage; +using API.Entities.History; +using API.Entities.Metadata; +using API.Extensions.QueryExtensions; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - After user testing, the needs manual match has some edge cases from migrations and for best user experience, +/// should be reset to allow the upgraded system to process better. +/// +public static class ManualMigrateNeedsManualMatch +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateNeedsManualMatch")) + { + return; + } + + logger.LogCritical("Running ManualMigrateNeedsManualMatch migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var series = await context.Series + .FilterMatchState(MatchStateOption.Error) + .ToListAsync(); + + foreach (var seriesEntry in series) + { + seriesEntry.IsBlacklisted = false; + context.Series.Update(seriesEntry); + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateNeedsManualMatch", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateNeedsManualMatch migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs b/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs new file mode 100644 index 000000000..b0d483de6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/ManualMigrateScrobbleErrors.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.5 - There seems to be some scrobble events that are pre-scrobble error table that can be processed over and over. +/// This will take the given year and minus 1 from it and clear everything from that and anything that is errored. +/// +public static class ManualMigrateScrobbleErrors +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleErrors")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleErrors migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var events = await context.ScrobbleEvent + .Where(se => se.LastModifiedUtc <= DateTime.UtcNow.AddYears(-1) || se.IsErrored) + .ToListAsync(); + + context.ScrobbleEvent.RemoveRange(events); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + logger.LogInformation("Removed {Count} old scrobble events", events.Count); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleErrors", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleErrors migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs b/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs new file mode 100644 index 000000000..67488d337 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.5/MigrateProgressExport.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Entities.History; +using API.Services; +using CsvHelper; +using CsvHelper.Configuration.Attributes; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + + +/// +/// v0.8.5 - Progress is extracted and saved in a csv since PDF parser has massive changes +/// +public static class MigrateProgressExportForV085 +{ + public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger logger) + { + try + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateProgressExportForV085")) + { + return; + } + + logger.LogCritical( + "Running MigrateProgressExportForV085 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-v0.8.5.csv")); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(data); + + logger.LogCritical( + "Running MigrateProgressExportForV085 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 = "MigrateProgressExportForV085", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await dataContext.SaveChangesAsync(); + } +} diff --git a/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs new file mode 100644 index 000000000..eb51d0fe6 --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleEventGen.cs @@ -0,0 +1,55 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.6 - Manually check when a user triggers scrobble event generation +/// +public static class ManualMigrateScrobbleEventGen +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleEventGen")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleEventGen migration - Please be patient, this may take some time. This is not an error"); + + var users = await context.Users + .Where(u => u.AniListAccessToken != null) + .ToListAsync(); + + foreach (var user in users) + { + if (await context.ScrobbleEvent.AnyAsync(se => se.AppUserId == user.Id)) + { + user.HasRunScrobbleEventGeneration = true; + user.ScrobbleEventGenerationRan = DateTime.UtcNow; + context.AppUser.Update(user); + } + } + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleEventGen", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleEventGen migration - Completed. This is not an error"); + } +} diff --git a/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs new file mode 100644 index 000000000..4749ff2ec --- /dev/null +++ b/API/Data/ManualMigrations/v0.8.6/ManualMigrateScrobbleSpecials.cs @@ -0,0 +1,49 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities.History; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.6 - Change to not scrobble specials as they will never process, this migration removes all existing scrobble events +/// +public static class ManualMigrateScrobbleSpecials +{ + public static async Task Migrate(DataContext context, ILogger logger) + { + if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateScrobbleSpecials")) + { + return; + } + + logger.LogCritical("Running ManualMigrateScrobbleSpecials migration - Please be patient, this may take some time. This is not an error"); + + // Get all series in the Blacklist table and set their IsBlacklist = true + var events = await context.ScrobbleEvent + .Where(se => se.VolumeNumber == Parser.SpecialVolumeNumber) + .ToListAsync(); + + context.ScrobbleEvent.RemoveRange(events); + + if (context.ChangeTracker.HasChanges()) + { + await context.SaveChangesAsync(); + logger.LogInformation("Removed {Count} scrobble events that were specials", events.Count); + } + + await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory() + { + Name = "ManualMigrateScrobbleSpecials", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + + logger.LogCritical("Running ManualMigrateScrobbleSpecials migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 81e10e771..8a9ef1900 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -127,7 +127,11 @@ public class ComicInfo public string CoverArtist { get; set; } = string.Empty; public string Editor { get; set; } = string.Empty; public string Publisher { get; set; } = string.Empty; + public string Imprint { get; set; } = string.Empty; public string Characters { get; set; } = string.Empty; + public string Teams { get; set; } = string.Empty; + public string Locations { get; set; } = string.Empty; + public static AgeRating ConvertAgeRatingToEnum(string value) { @@ -151,9 +155,12 @@ public class ComicInfo info.Letterer = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Letterer); info.Penciller = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Penciller); info.Publisher = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Publisher); + info.Imprint = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Imprint); info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters); info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator); info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist); + info.Teams = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Teams); + info.Locations = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Locations); // We need to convert GTIN to ISBN if (!string.IsNullOrEmpty(info.GTIN)) @@ -174,7 +181,12 @@ public class ComicInfo if (!string.IsNullOrEmpty(info.Number)) { - info.Number = info.Number.Replace(",", "."); // Corrective measure for non English OSes + info.Number = info.Number.Trim().Replace(",", "."); // Corrective measure for non English OSes + } + + if (!string.IsNullOrEmpty(info.Volume)) + { + info.Volume = info.Volume.Trim(); } } diff --git a/API/Data/Migrations/20240214232436_ChapterNumber.Designer.cs b/API/Data/Migrations/20240214232436_ChapterNumber.Designer.cs new file mode 100644 index 000000000..d770ccbbd --- /dev/null +++ b/API/Data/Migrations/20240214232436_ChapterNumber.Designer.cs @@ -0,0 +1,2877 @@ +// +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("20240214232436_ChapterNumber")] + partial class ChapterNumber + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("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("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("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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("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("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("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/20240214232436_ChapterNumber.cs b/API/Data/Migrations/20240214232436_ChapterNumber.cs new file mode 100644 index 000000000..c1e277d58 --- /dev/null +++ b/API/Data/Migrations/20240214232436_ChapterNumber.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterNumber : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaxNumber", + table: "Chapter", + type: "REAL", + nullable: false, + defaultValue: 0f); + + migrationBuilder.AddColumn( + name: "MinNumber", + table: "Chapter", + type: "REAL", + nullable: false, + defaultValue: 0f); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaxNumber", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "MinNumber", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20240216000223_MangaFileNameTemp.Designer.cs b/API/Data/Migrations/20240216000223_MangaFileNameTemp.Designer.cs new file mode 100644 index 000000000..7709d9afa --- /dev/null +++ b/API/Data/Migrations/20240216000223_MangaFileNameTemp.Designer.cs @@ -0,0 +1,2880 @@ +// +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("20240216000223_MangaFileNameTemp")] + partial class MangaFileNameTemp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("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("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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("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("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("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/20240216000223_MangaFileNameTemp.cs b/API/Data/Migrations/20240216000223_MangaFileNameTemp.cs new file mode 100644 index 000000000..8a14c912c --- /dev/null +++ b/API/Data/Migrations/20240216000223_MangaFileNameTemp.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class MangaFileNameTemp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FileName", + table: "MangaFile", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FileName", + table: "MangaFile"); + } + } +} diff --git a/API/Data/Migrations/20240222125420_ChapterIssueSort.Designer.cs b/API/Data/Migrations/20240222125420_ChapterIssueSort.Designer.cs new file mode 100644 index 000000000..68c1a12e5 --- /dev/null +++ b/API/Data/Migrations/20240222125420_ChapterIssueSort.Designer.cs @@ -0,0 +1,2883 @@ +// +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("20240222125420_ChapterIssueSort")] + partial class ChapterIssueSort + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("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("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("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/20240222125420_ChapterIssueSort.cs b/API/Data/Migrations/20240222125420_ChapterIssueSort.cs new file mode 100644 index 000000000..0689a8e88 --- /dev/null +++ b/API/Data/Migrations/20240222125420_ChapterIssueSort.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterIssueSort : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SortOrder", + table: "Chapter", + type: "REAL", + nullable: false, + defaultValue: 0f); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SortOrder", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20240225235816_VolumeLookupName.Designer.cs b/API/Data/Migrations/20240225235816_VolumeLookupName.Designer.cs new file mode 100644 index 000000000..c7f646f73 --- /dev/null +++ b/API/Data/Migrations/20240225235816_VolumeLookupName.Designer.cs @@ -0,0 +1,2886 @@ +// +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("20240225235816_VolumeLookupName")] + partial class VolumeLookupName + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("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("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .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("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("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/20240225235816_VolumeLookupName.cs b/API/Data/Migrations/20240225235816_VolumeLookupName.cs new file mode 100644 index 000000000..3d42e9645 --- /dev/null +++ b/API/Data/Migrations/20240225235816_VolumeLookupName.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class VolumeLookupName : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LookupName", + table: "Volume", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LookupName", + table: "Volume"); + } + } +} diff --git a/API/Data/Migrations/20240309140117_SeriesImprints.Designer.cs b/API/Data/Migrations/20240309140117_SeriesImprints.Designer.cs new file mode 100644 index 000000000..d99650e86 --- /dev/null +++ b/API/Data/Migrations/20240309140117_SeriesImprints.Designer.cs @@ -0,0 +1,2889 @@ +// +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("20240309140117_SeriesImprints")] + partial class SeriesImprints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("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("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("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("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/20240309140117_SeriesImprints.cs b/API/Data/Migrations/20240309140117_SeriesImprints.cs new file mode 100644 index 000000000..a48ac7c48 --- /dev/null +++ b/API/Data/Migrations/20240309140117_SeriesImprints.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SeriesImprints : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ImprintLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ImprintLocked", + table: "SeriesMetadata"); + } + } +} diff --git a/API/Data/Migrations/20240313112552_SeriesLowestFolderPath.Designer.cs b/API/Data/Migrations/20240313112552_SeriesLowestFolderPath.Designer.cs new file mode 100644 index 000000000..707d6ea0a --- /dev/null +++ b/API/Data/Migrations/20240313112552_SeriesLowestFolderPath.Designer.cs @@ -0,0 +1,2892 @@ +// +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("20240313112552_SeriesLowestFolderPath")] + partial class SeriesLowestFolderPath + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("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("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("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/20240313112552_SeriesLowestFolderPath.cs b/API/Data/Migrations/20240313112552_SeriesLowestFolderPath.cs new file mode 100644 index 000000000..e138bd8f1 --- /dev/null +++ b/API/Data/Migrations/20240313112552_SeriesLowestFolderPath.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SeriesLowestFolderPath : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LowestFolderPath", + table: "Series", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LowestFolderPath", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/20240314194402_TeamsAndLocations.Designer.cs b/API/Data/Migrations/20240314194402_TeamsAndLocations.Designer.cs new file mode 100644 index 000000000..21616f684 --- /dev/null +++ b/API/Data/Migrations/20240314194402_TeamsAndLocations.Designer.cs @@ -0,0 +1,2898 @@ +// +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("20240314194402_TeamsAndLocations")] + partial class TeamsAndLocations + { + /// + 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("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/20240314194402_TeamsAndLocations.cs b/API/Data/Migrations/20240314194402_TeamsAndLocations.cs new file mode 100644 index 000000000..dca377c99 --- /dev/null +++ b/API/Data/Migrations/20240314194402_TeamsAndLocations.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class TeamsAndLocations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LocationLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TeamLocked", + table: "SeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LocationLocked", + table: "SeriesMetadata"); + + migrationBuilder.DropColumn( + name: "TeamLocked", + table: "SeriesMetadata"); + } + } +} 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/20240418163829_ChapterSortOrderLock.Designer.cs b/API/Data/Migrations/20240418163829_ChapterSortOrderLock.Designer.cs new file mode 100644 index 000000000..3cd3291b2 --- /dev/null +++ b/API/Data/Migrations/20240418163829_ChapterSortOrderLock.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("20240418163829_ChapterSortOrderLock")] + partial class ChapterSortOrderLock + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + 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("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("SortOrderLocked") + .HasColumnType("INTEGER"); + + 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/20240418163829_ChapterSortOrderLock.cs b/API/Data/Migrations/20240418163829_ChapterSortOrderLock.cs new file mode 100644 index 000000000..197085b0c --- /dev/null +++ b/API/Data/Migrations/20240418163829_ChapterSortOrderLock.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterSortOrderLock : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PdfLayoutMode", + table: "AppUserPreferences"); + + migrationBuilder.AddColumn( + name: "SortOrderLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SortOrderLocked", + table: "Chapter"); + + migrationBuilder.AddColumn( + name: "PdfLayoutMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + } +} diff --git a/API/Data/Migrations/20240503120147_SmartCollectionFields.Designer.cs b/API/Data/Migrations/20240503120147_SmartCollectionFields.Designer.cs new file mode 100644 index 000000000..1dff0c0e5 --- /dev/null +++ b/API/Data/Migrations/20240503120147_SmartCollectionFields.Designer.cs @@ -0,0 +1,3025 @@ +// +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("20240503120147_SmartCollectionFields")] + partial class SmartCollectionFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + 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("MissingSeriesFromSource") + .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.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("SortOrderLocked") + .HasColumnType("INTEGER"); + + 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/20240503120147_SmartCollectionFields.cs b/API/Data/Migrations/20240503120147_SmartCollectionFields.cs new file mode 100644 index 000000000..f0b6ed693 --- /dev/null +++ b/API/Data/Migrations/20240503120147_SmartCollectionFields.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SmartCollectionFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MissingSeriesFromSource", + table: "AppUserCollection", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "TotalSourceCount", + table: "AppUserCollection", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MissingSeriesFromSource", + table: "AppUserCollection"); + + migrationBuilder.DropColumn( + name: "TotalSourceCount", + table: "AppUserCollection"); + } + } +} diff --git a/API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs b/API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs new file mode 100644 index 000000000..c88a1628f --- /dev/null +++ b/API/Data/Migrations/20240510134030_SiteThemeFields.Designer.cs @@ -0,0 +1,3043 @@ +// +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("20240510134030_SiteThemeFields")] + partial class SiteThemeFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.4"); + + 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("MissingSeriesFromSource") + .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.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("SortOrderLocked") + .HasColumnType("INTEGER"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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/20240510134030_SiteThemeFields.cs b/API/Data/Migrations/20240510134030_SiteThemeFields.cs new file mode 100644 index 000000000..36171fa0a --- /dev/null +++ b/API/Data/Migrations/20240510134030_SiteThemeFields.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SiteThemeFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Author", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CompatibleVersion", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "GitHubPath", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreviewUrls", + table: "SiteTheme", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ShaHash", + table: "SiteTheme", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Author", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "CompatibleVersion", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "Description", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "GitHubPath", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "PreviewUrls", + table: "SiteTheme"); + + migrationBuilder.DropColumn( + name: "ShaHash", + table: "SiteTheme"); + } + } +} diff --git a/API/Data/Migrations/20240704144224_PersonFields.Designer.cs b/API/Data/Migrations/20240704144224_PersonFields.Designer.cs new file mode 100644 index 000000000..ddc41d811 --- /dev/null +++ b/API/Data/Migrations/20240704144224_PersonFields.Designer.cs @@ -0,0 +1,3064 @@ +// +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("20240704144224_PersonFields")] + partial class PersonFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.6"); + + 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("MissingSeriesFromSource") + .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.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("SortOrderLocked") + .HasColumnType("INTEGER"); + + 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("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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/20240704144224_PersonFields.cs b/API/Data/Migrations/20240704144224_PersonFields.cs new file mode 100644 index 000000000..2d30696ce --- /dev/null +++ b/API/Data/Migrations/20240704144224_PersonFields.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AniListId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Asin", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImage", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Description", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HardcoverId", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MalId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AniListId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Asin", + table: "Person"); + + migrationBuilder.DropColumn( + name: "CoverImage", + table: "Person"); + + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Description", + table: "Person"); + + migrationBuilder.DropColumn( + name: "HardcoverId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "MalId", + table: "Person"); + } + } +} diff --git a/API/Data/Migrations/20240808100353_CoverPrimaryColors.Designer.cs b/API/Data/Migrations/20240808100353_CoverPrimaryColors.Designer.cs new file mode 100644 index 000000000..d105ece92 --- /dev/null +++ b/API/Data/Migrations/20240808100353_CoverPrimaryColors.Designer.cs @@ -0,0 +1,3079 @@ +// +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("20240808100353_CoverPrimaryColors")] + partial class CoverPrimaryColors + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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/20240808100353_CoverPrimaryColors.cs b/API/Data/Migrations/20240808100353_CoverPrimaryColors.cs new file mode 100644 index 000000000..c69c906b0 --- /dev/null +++ b/API/Data/Migrations/20240808100353_CoverPrimaryColors.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class CoverPrimaryColors : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Volume", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Volume", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Series", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Library", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Library", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Chapter", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "AppUserCollection", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "AppUserCollection", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Series"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Series"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Library"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Library"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "AppUserCollection"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "AppUserCollection"); + } + } +} diff --git a/API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs new file mode 100644 index 000000000..07723e833 --- /dev/null +++ b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs @@ -0,0 +1,3142 @@ +// +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("20240811154857_ChapterMetadataLocks")] + partial class ChapterMetadataLocks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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/20240811154857_ChapterMetadataLocks.cs b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs new file mode 100644 index 000000000..b0b58b3b3 --- /dev/null +++ b/API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs @@ -0,0 +1,249 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ChapterMetadataLocks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRatingLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CharacterLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ColoristLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CoverArtistLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EditorLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "GenresLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ISBNLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ImprintLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InkerLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LanguageLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LettererLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LocationLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PencillerLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PublisherLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ReleaseDateLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SummaryLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TagsLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TeamLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TitleNameLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "TranslatorLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "WriterLocked", + table: "Chapter", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRatingLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "CharacterLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ColoristLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "CoverArtistLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "EditorLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "GenresLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ISBNLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ImprintLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "InkerLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LanguageLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LettererLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "LocationLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "PencillerLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "PublisherLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "ReleaseDateLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "SummaryLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TagsLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TeamLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TitleNameLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "TranslatorLocked", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "WriterLocked", + table: "Chapter"); + } + } +} diff --git a/API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs b/API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs new file mode 100644 index 000000000..1471c1de7 --- /dev/null +++ b/API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs @@ -0,0 +1,3145 @@ +// +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("20240813194728_VolumeCoverLocked")] + partial class VolumeCoverLocked + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.7"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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/20240813194728_VolumeCoverLocked.cs b/API/Data/Migrations/20240813194728_VolumeCoverLocked.cs new file mode 100644 index 000000000..c9127ae6a --- /dev/null +++ b/API/Data/Migrations/20240813194728_VolumeCoverLocked.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class VolumeCoverLocked : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "Volume", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "Volume"); + } + } +} diff --git a/API/Data/Migrations/20240917180034_AvgReadingTimeFloat.Designer.cs b/API/Data/Migrations/20240917180034_AvgReadingTimeFloat.Designer.cs new file mode 100644 index 000000000..f9b858de5 --- /dev/null +++ b/API/Data/Migrations/20240917180034_AvgReadingTimeFloat.Designer.cs @@ -0,0 +1,3145 @@ +// +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("20240917180034_AvgReadingTimeFloat")] + partial class AvgReadingTimeFloat + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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/20240917180034_AvgReadingTimeFloat.cs b/API/Data/Migrations/20240917180034_AvgReadingTimeFloat.cs new file mode 100644 index 000000000..70e9238ec --- /dev/null +++ b/API/Data/Migrations/20240917180034_AvgReadingTimeFloat.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AvgReadingTimeFloat : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "AvgHoursToRead", + table: "Volume", + type: "REAL", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "AvgHoursToRead", + table: "Series", + type: "REAL", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AlterColumn( + name: "AvgHoursToRead", + table: "Chapter", + type: "REAL", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "AvgHoursToRead", + table: "Volume", + type: "INTEGER", + nullable: false, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "AvgHoursToRead", + table: "Series", + type: "INTEGER", + nullable: false, + oldClrType: typeof(float), + oldType: "REAL"); + + migrationBuilder.AlterColumn( + name: "AvgHoursToRead", + table: "Chapter", + type: "INTEGER", + nullable: false, + oldClrType: typeof(float), + oldType: "REAL"); + } + } +} diff --git a/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.Designer.cs b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.Designer.cs new file mode 100644 index 000000000..3865e6007 --- /dev/null +++ b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.Designer.cs @@ -0,0 +1,3170 @@ +// +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("20241011143144_PeopleOverhaulPart1")] + partial class PeopleOverhaulPart1 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20241011143144_PeopleOverhaulPart1.cs b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.cs new file mode 100644 index 000000000..1bf0cf6c4 --- /dev/null +++ b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PeopleOverhaulPart1 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterPerson"); + + migrationBuilder.DropTable( + name: "PersonSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Person"); + + migrationBuilder.CreateTable( + name: "ChapterPeople", + columns: table => new + { + ChapterId = table.Column(type: "INTEGER", nullable: false), + PersonId = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterPeople", x => new { x.ChapterId, x.PersonId, x.Role }); + table.ForeignKey( + name: "FK_ChapterPeople_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterPeople_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SeriesMetadataPeople", + columns: table => new + { + SeriesMetadataId = table.Column(type: "INTEGER", nullable: false), + PersonId = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesMetadataPeople", x => new { x.SeriesMetadataId, x.PersonId, x.Role }); + table.ForeignKey( + name: "FK_SeriesMetadataPeople_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SeriesMetadataPeople_SeriesMetadata_SeriesMetadataId", + column: x => x.SeriesMetadataId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterPeople_PersonId", + table: "ChapterPeople", + column: "PersonId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadataPeople_PersonId", + table: "SeriesMetadataPeople", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterPeople"); + + migrationBuilder.DropTable( + name: "SeriesMetadataPeople"); + + migrationBuilder.AddColumn( + name: "Role", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "ChapterPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterPerson_Chapter_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterPerson_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PersonSeriesMetadata", + columns: table => new + { + PeopleId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonSeriesMetadata", x => new { x.PeopleId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_PersonSeriesMetadata_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PersonSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterPerson_PeopleId", + table: "ChapterPerson", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_PersonSeriesMetadata_SeriesMetadatasId", + table: "PersonSeriesMetadata", + column: "SeriesMetadatasId"); + } + } +} diff --git a/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.Designer.cs b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.Designer.cs new file mode 100644 index 000000000..bbbf0f989 --- /dev/null +++ b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.Designer.cs @@ -0,0 +1,3182 @@ +// +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("20241011152321_PeopleOverhaulPart2")] + partial class PeopleOverhaulPart2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20241011152321_PeopleOverhaulPart2.cs b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.cs new file mode 100644 index 000000000..4fd8e4b8d --- /dev/null +++ b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PeopleOverhaulPart2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImage", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Person", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImage", + table: "Person"); + + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "Person"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Person"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Person"); + } + } +} diff --git a/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.Designer.cs b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.Designer.cs new file mode 100644 index 000000000..6f76df92c --- /dev/null +++ b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.Designer.cs @@ -0,0 +1,3197 @@ +// +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("20241011172428_PeopleOverhaulPart3")] + partial class PeopleOverhaulPart3 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20241011172428_PeopleOverhaulPart3.cs b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.cs new file mode 100644 index 000000000..13aa9e050 --- /dev/null +++ b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PeopleOverhaulPart3 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AniListId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Asin", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HardcoverId", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MalId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AniListId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Asin", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Description", + table: "Person"); + + migrationBuilder.DropColumn( + name: "HardcoverId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "MalId", + table: "Person"); + } + } +} diff --git a/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs new file mode 100644 index 000000000..a5158ebc1 --- /dev/null +++ b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.Designer.cs @@ -0,0 +1,3203 @@ +// +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("20250105180131_SeriesDontMatchAndBlacklist")] + partial class SeriesDontMatchAndBlacklist + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250105180131_SeriesDontMatchAndBlacklist.cs b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs new file mode 100644 index 000000000..ab80f0621 --- /dev/null +++ b/API/Data/Migrations/20250105180131_SeriesDontMatchAndBlacklist.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class SeriesDontMatchAndBlacklist : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DontMatch", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "IsBlacklisted", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DontMatch", + table: "Series"); + + migrationBuilder.DropColumn( + name: "IsBlacklisted", + table: "Series"); + } + } +} diff --git a/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs b/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs new file mode 100644 index 000000000..ff3212562 --- /dev/null +++ b/API/Data/Migrations/20250109173537_EmailHistory.Designer.cs @@ -0,0 +1,3265 @@ +// +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("20250109173537_EmailHistory")] + partial class EmailHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250109173537_EmailHistory.cs b/API/Data/Migrations/20250109173537_EmailHistory.cs new file mode 100644 index 000000000..b31bf20c3 --- /dev/null +++ b/API/Data/Migrations/20250109173537_EmailHistory.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EmailHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "EmailHistory", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Sent = table.Column(type: "INTEGER", nullable: false), + SendDate = table.Column(type: "TEXT", nullable: false), + EmailTemplate = table.Column(type: "TEXT", nullable: true), + Subject = table.Column(type: "TEXT", nullable: true), + Body = table.Column(type: "TEXT", nullable: true), + DeliveryStatus = table.Column(type: "TEXT", nullable: true), + ErrorMessage = table.Column(type: "TEXT", nullable: true), + AppUserId = table.Column(type: "INTEGER", nullable: false), + Created = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_EmailHistory", x => x.Id); + table.ForeignKey( + name: "FK_EmailHistory_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_EmailHistory_AppUserId", + table: "EmailHistory", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_EmailHistory_Sent_AppUserId_EmailTemplate_SendDate", + table: "EmailHistory", + columns: new[] { "Sent", "AppUserId", "EmailTemplate", "SendDate" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "EmailHistory"); + } + } +} diff --git a/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs new file mode 100644 index 000000000..835510a1e --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.Designer.cs @@ -0,0 +1,3382 @@ +// +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("20250202163454_KavitaPlusUserAndMetadataSettings")] + partial class KavitaPlusUserAndMetadataSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("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.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + 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.History.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250202163454_KavitaPlusUserAndMetadataSettings.cs b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs new file mode 100644 index 000000000..b23d7896b --- /dev/null +++ b/API/Data/Migrations/20250202163454_KavitaPlusUserAndMetadataSettings.cs @@ -0,0 +1,112 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusUserAndMetadataSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowMetadataMatching", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "WantToReadSync", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.CreateTable( + name: "MetadataSettings", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + EnableSummary = table.Column(type: "INTEGER", nullable: false), + EnablePublicationStatus = table.Column(type: "INTEGER", nullable: false), + EnableRelationships = table.Column(type: "INTEGER", nullable: false), + EnablePeople = table.Column(type: "INTEGER", nullable: false), + EnableStartDate = table.Column(type: "INTEGER", nullable: false), + EnableLocalizedName = table.Column(type: "INTEGER", nullable: false), + EnableGenres = table.Column(type: "INTEGER", nullable: false), + EnableTags = table.Column(type: "INTEGER", nullable: false), + FirstLastPeopleNaming = table.Column(type: "INTEGER", nullable: false), + AgeRatingMappings = table.Column(type: "TEXT", nullable: true), + Blacklist = table.Column(type: "TEXT", nullable: true), + Whitelist = table.Column(type: "TEXT", nullable: true), + PersonRoles = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MetadataFieldMapping", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SourceType = table.Column(type: "INTEGER", nullable: false), + DestinationType = table.Column(type: "INTEGER", nullable: false), + SourceValue = table.Column(type: "TEXT", nullable: true), + DestinationValue = table.Column(type: "TEXT", nullable: true), + ExcludeFromSource = table.Column(type: "INTEGER", nullable: false), + MetadataSettingsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MetadataFieldMapping", x => x.Id); + table.ForeignKey( + name: "FK_MetadataFieldMapping_MetadataSettings_MetadataSettingsId", + column: x => x.MetadataSettingsId, + principalTable: "MetadataSettings", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MetadataFieldMapping_MetadataSettingsId", + table: "MetadataFieldMapping", + column: "MetadataSettingsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MetadataFieldMapping"); + + migrationBuilder.DropTable( + name: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "AllowMetadataMatching", + table: "Library"); + + migrationBuilder.DropColumn( + name: "AniListScrobblingEnabled", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "WantToReadSync", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs new file mode 100644 index 000000000..9aaa63101 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.Designer.cs @@ -0,0 +1,3398 @@ +// +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("20250208200843_MoreMetadtaSettings")] + partial class MoreMetadtaSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("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.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + 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.History.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + 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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250208200843_MoreMetadtaSettings.cs b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs new file mode 100644 index 000000000..70e42cd11 --- /dev/null +++ b/API/Data/Migrations/20250208200843_MoreMetadtaSettings.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class MoreMetadtaSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "EnableCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "Overrides", + table: "MetadataSettings", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "SeriesMetadataPeople"); + + migrationBuilder.DropColumn( + name: "EnableCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "Overrides", + table: "MetadataSettings"); + } + } +} diff --git a/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs new file mode 100644 index 000000000..be3d5e3f9 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.Designer.cs @@ -0,0 +1,3403 @@ +// +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("20250328125012_AutomaticWebtoonReaderMode")] + partial class AutomaticWebtoonReaderMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("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.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + 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.History.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250328125012_AutomaticWebtoonReaderMode.cs b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs new file mode 100644 index 000000000..38b772811 --- /dev/null +++ b/API/Data/Migrations/20250328125012_AutomaticWebtoonReaderMode.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class AutomaticWebtoonReaderMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AllowAutomaticWebtoonReaderDetection", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs new file mode 100644 index 000000000..53e450b3b --- /dev/null +++ b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.Designer.cs @@ -0,0 +1,3409 @@ +// +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("20250408222330_ScrobbleGenerationDbCapture")] + partial class ScrobbleGenerationDbCapture + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.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("HasRunScrobbleEventGeneration") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("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.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + 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.History.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250408222330_ScrobbleGenerationDbCapture.cs b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs new file mode 100644 index 000000000..7431a7338 --- /dev/null +++ b/API/Data/Migrations/20250408222330_ScrobbleGenerationDbCapture.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class ScrobbleGenerationDbCapture : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HasRunScrobbleEventGeneration", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ScrobbleEventGenerationRan", + table: "AspNetUsers", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "HasRunScrobbleEventGeneration", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "ScrobbleEventGenerationRan", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs b/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs new file mode 100644 index 000000000..fd287c085 --- /dev/null +++ b/API/Data/Migrations/20250415194829_KavitaPlusCBR.Designer.cs @@ -0,0 +1,3433 @@ +// +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("20250415194829_KavitaPlusCBR")] + partial class KavitaPlusCBR + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + 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("HasRunScrobbleEventGeneration") + .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("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + 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("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("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.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .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.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + 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.History.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .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.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("CbrId") + .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.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .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("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + 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("REAL"); + + 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("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("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + 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("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("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.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .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.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + 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("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("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("People"); + + 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.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + 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/20250415194829_KavitaPlusCBR.cs b/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs new file mode 100644 index 000000000..188969476 --- /dev/null +++ b/API/Data/Migrations/20250415194829_KavitaPlusCBR.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class KavitaPlusCBR : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableChapterCoverImage", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterPublisher", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterReleaseDate", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterSummary", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableChapterTitle", + table: "MetadataSettings", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "CbrId", + table: "ExternalSeriesMetadata", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "KavitaPlusConnection", + table: "ChapterPeople", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "OrderWeight", + table: "ChapterPeople", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableChapterCoverImage", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterPublisher", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterReleaseDate", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterSummary", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "EnableChapterTitle", + table: "MetadataSettings"); + + migrationBuilder.DropColumn( + name: "CbrId", + table: "ExternalSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "KavitaPlusConnection", + table: "ChapterPeople"); + + migrationBuilder.DropColumn( + name: "OrderWeight", + table: "ChapterPeople"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index d2f0f6240..ab2115091 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.1"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -85,6 +85,9 @@ namespace API.Data.Migrations b.Property("EmailConfirmed") .HasColumnType("INTEGER"); + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + b.Property("LastActive") .HasColumnType("TEXT"); @@ -97,6 +100,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"); @@ -118,6 +127,9 @@ namespace API.Data.Migrations .IsConcurrencyToken() .HasColumnType("INTEGER"); + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + b.Property("SecurityStamp") .HasColumnType("TEXT"); @@ -183,6 +195,78 @@ 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("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => { b.Property("Id") @@ -275,6 +359,16 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AppUserId") .HasColumnType("INTEGER"); @@ -349,6 +443,15 @@ namespace API.Data.Migrations b.Property("PageSplitOption") .HasColumnType("INTEGER"); + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + b.Property("PromptForDownloadSize") .HasColumnType("INTEGER"); @@ -373,6 +476,11 @@ namespace API.Data.Migrations b.Property("ThemeId") .HasColumnType("INTEGER"); + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.HasKey("Id"); b.HasIndex("AppUserId") @@ -632,6 +740,9 @@ namespace API.Data.Migrations b.Property("AgeRating") .HasColumnType("INTEGER"); + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + b.Property("AlternateCount") .HasColumnType("INTEGER"); @@ -641,12 +752,21 @@ namespace API.Data.Migrations b.Property("AlternateSeries") .HasColumnType("TEXT"); - b.Property("AvgHoursToRead") + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") .HasColumnType("INTEGER"); b.Property("Count") .HasColumnType("INTEGER"); + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + b.Property("CoverImage") .HasColumnType("TEXT"); @@ -659,44 +779,95 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + b.Property("ISBN") .ValueGeneratedOnAdd() .HasColumnType("TEXT") .HasDefaultValue(""); + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + b.Property("IsSpecial") .HasColumnType("INTEGER"); b.Property("Language") .HasColumnType("TEXT"); + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); b.Property("LastModifiedUtc") .HasColumnType("TEXT"); + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + 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("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + b.Property("Range") .HasColumnType("TEXT"); b.Property("ReleaseDate") .HasColumnType("TEXT"); + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("SeriesGroup") .HasColumnType("TEXT"); + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + b.Property("StoryArc") .HasColumnType("TEXT"); @@ -706,15 +877,30 @@ namespace API.Data.Migrations b.Property("Summary") .HasColumnType("TEXT"); + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + b.Property("Title") .HasColumnType("TEXT"); b.Property("TitleName") .HasColumnType("TEXT"); + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + b.Property("TotalCount") .HasColumnType("INTEGER"); + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + b.Property("VolumeId") .HasColumnType("INTEGER"); @@ -726,6 +912,9 @@ namespace API.Data.Migrations b.Property("WordCount") .HasColumnType("INTEGER"); + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("VolumeId"); @@ -814,6 +1003,57 @@ namespace API.Data.Migrations b.ToTable("Device"); }); + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.Property("Id") @@ -856,12 +1096,37 @@ namespace API.Data.Migrations b.ToTable("Genre"); }); + modelBuilder.Entity("API.Entities.History.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.Library", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("AllowScrobbling") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -906,6 +1171,12 @@ namespace API.Data.Migrations b.Property("Name") .HasColumnType("TEXT"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("Type") .HasColumnType("INTEGER"); @@ -973,6 +1244,9 @@ namespace API.Data.Migrations b.Property("Extension") .HasColumnType("TEXT"); + b.Property("FileName") + .HasColumnType("TEXT"); + b.Property("FilePath") .HasColumnType("TEXT"); @@ -1001,26 +1275,6 @@ namespace API.Data.Migrations 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") @@ -1175,6 +1429,9 @@ namespace API.Data.Migrations b.Property("AverageExternalRating") .HasColumnType("INTEGER"); + b.Property("CbrId") + .HasColumnType("INTEGER"); + b.Property("GoogleBooksId") .HasColumnType("TEXT"); @@ -1241,6 +1498,9 @@ namespace API.Data.Migrations b.Property("GenresLocked") .HasColumnType("INTEGER"); + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + b.Property("InkerLocked") .HasColumnType("INTEGER"); @@ -1253,6 +1513,9 @@ namespace API.Data.Migrations b.Property("LettererLocked") .HasColumnType("INTEGER"); + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + b.Property("MaxCount") .HasColumnType("INTEGER"); @@ -1290,6 +1553,9 @@ namespace API.Data.Migrations b.Property("TagsLocked") .HasColumnType("INTEGER"); + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + b.Property("TotalCount") .HasColumnType("INTEGER"); @@ -1339,26 +1605,209 @@ namespace API.Data.Migrations b.ToTable("SeriesRelation"); }); - modelBuilder.Entity("API.Entities.Person", b => + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + b.Property("Name") .HasColumnType("TEXT"); b.Property("NormalizedName") .HasColumnType("TEXT"); - b.Property("Role") - .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); b.HasKey("Id"); b.ToTable("Person"); }); + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.Property("Id") @@ -1399,9 +1848,15 @@ namespace API.Data.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + b.Property("Promoted") .HasColumnType("INTEGER"); + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("StartingMonth") .HasColumnType("INTEGER"); @@ -1617,8 +2072,8 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("AvgHoursToRead") - .HasColumnType("INTEGER"); + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); b.Property("CoverImage") .HasColumnType("TEXT"); @@ -1632,12 +2087,18 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("DontMatch") + .HasColumnType("INTEGER"); + b.Property("FolderPath") .HasColumnType("TEXT"); b.Property("Format") .HasColumnType("INTEGER"); + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + b.Property("LastChapterAdded") .HasColumnType("TEXT"); @@ -1665,6 +2126,9 @@ namespace API.Data.Migrations b.Property("LocalizedNameLocked") .HasColumnType("INTEGER"); + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + b.Property("MaxHoursToRead") .HasColumnType("INTEGER"); @@ -1686,6 +2150,12 @@ namespace API.Data.Migrations b.Property("Pages") .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("SortName") .HasColumnType("TEXT"); @@ -1763,15 +2233,27 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + b.Property("Created") .HasColumnType("TEXT"); b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("Description") + .HasColumnType("TEXT"); + b.Property("FileName") .HasColumnType("TEXT"); + b.Property("GitHubPath") + .HasColumnType("TEXT"); + b.Property("IsDefault") .HasColumnType("INTEGER"); @@ -1787,9 +2269,15 @@ namespace API.Data.Migrations b.Property("NormalizedName") .HasColumnType("TEXT"); + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + b.Property("Provider") .HasColumnType("INTEGER"); + b.Property("ShaHash") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.ToTable("SiteTheme"); @@ -1821,12 +2309,15 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("AvgHoursToRead") - .HasColumnType("INTEGER"); + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); b.Property("CoverImage") .HasColumnType("TEXT"); + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + b.Property("Created") .HasColumnType("TEXT"); @@ -1839,6 +2330,9 @@ namespace API.Data.Migrations b.Property("LastModifiedUtc") .HasColumnType("TEXT"); + b.Property("LookupName") + .HasColumnType("TEXT"); + b.Property("MaxHoursToRead") .HasColumnType("INTEGER"); @@ -1860,6 +2354,12 @@ namespace API.Data.Migrations b.Property("Pages") .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + b.Property("SeriesId") .HasColumnType("INTEGER"); @@ -1873,6 +2373,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") @@ -1903,21 +2418,6 @@ namespace API.Data.Migrations 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") @@ -2092,21 +2592,6 @@ namespace API.Data.Migrations 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") @@ -2133,6 +2618,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") @@ -2354,6 +2850,17 @@ namespace API.Data.Migrations b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -2450,6 +2957,55 @@ namespace API.Data.Migrations b.Navigation("TargetSeries"); }); + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2581,6 +3137,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) @@ -2611,21 +3182,6 @@ namespace API.Data.Migrations .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) @@ -2752,21 +3308,6 @@ namespace API.Data.Migrations .IsRequired(); }); - modelBuilder.Entity("PersonSeriesMetadata", b => - { - b.HasOne("API.Entities.Person", null) - .WithMany() - .HasForeignKey("PeopleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Metadata.SeriesMetadata", null) - .WithMany() - .HasForeignKey("SeriesMetadatasId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("SeriesMetadataTag", b => { b.HasOne("API.Entities.Metadata.SeriesMetadata", null) @@ -2791,6 +3332,8 @@ namespace API.Data.Migrations { b.Navigation("Bookmarks"); + b.Navigation("Collections"); + b.Navigation("DashboardStreams"); b.Navigation("Devices"); @@ -2822,6 +3365,8 @@ namespace API.Data.Migrations { b.Navigation("Files"); + b.Navigation("People"); + b.Navigation("UserProgress"); }); @@ -2836,6 +3381,23 @@ namespace API.Data.Migrations b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.Navigation("Items"); diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index 2878938a5..a672259ad 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; @@ -14,9 +16,11 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; #nullable enable + public interface IAppUserProgressRepository { void Update(AppUserProgress userProgress); + void Remove(AppUserProgress userProgress); Task CleanupAbandonedChapters(); Task UserHasProgress(LibraryType libraryType, int userId); Task GetUserProgressAsync(int chapterId, int userId); @@ -36,8 +40,9 @@ 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 { private readonly DataContext _context; @@ -54,6 +59,11 @@ public class AppUserProgressRepository : IAppUserProgressRepository _context.Entry(userProgress).State = EntityState.Modified; } + public void Remove(AppUserProgress userProgress) + { + _context.Remove(userProgress); + } + /// /// This will remove any entries that have chapterIds that no longer exists. This will execute the save as well. /// @@ -167,9 +177,10 @@ public class AppUserProgressRepository : IAppUserProgressRepository (appUserProgresses, chapter) => new {appUserProgresses, chapter}) .Where(p => p.appUserProgresses.SeriesId == seriesId && p.appUserProgresses.AppUserId == userId && p.appUserProgresses.PagesRead >= p.chapter.Pages) - .Select(p => p.chapter.Range) + .Where(p => p.chapter.MaxNumber != Parser.SpecialVolumeNumber) + .Select(p => p.chapter.MaxNumber) .ToListAsync(); - return list.Count == 0 ? 0 : list.DefaultIfEmpty().Where(d => d != null).Max(d => (int) Math.Floor(Parser.MaxNumberFromRange(d))); + return list.Count == 0 ? 0 : (int) list.DefaultIfEmpty().Max(d => d); } public async Task GetHighestFullyReadVolumeForSeries(int seriesId, int userId) @@ -179,8 +190,10 @@ public class AppUserProgressRepository : IAppUserProgressRepository (appUserProgresses, chapter) => new {appUserProgresses, chapter}) .Where(p => p.appUserProgresses.SeriesId == seriesId && p.appUserProgresses.AppUserId == userId && p.appUserProgresses.PagesRead >= p.chapter.Pages) + .Where(p => p.chapter.MaxNumber != Parser.SpecialVolumeNumber) .Select(p => p.chapter.Volume.MaxNumber) .ToListAsync(); + return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max(); } @@ -231,6 +244,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/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index a8bb699ff..f34032d79 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -22,11 +22,16 @@ public enum ChapterIncludes None = 1, Volumes = 2, Files = 4, + People = 8, + Genres = 16, + Tags = 32 } public interface IChapterRepository { void Update(Chapter chapter); + void Remove(Chapter chapter); + void Remove(IList chapters); Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None); Task GetChapterInfoDtoAsync(int chapterId); Task GetChapterTotalPagesAsync(int chapterId); @@ -34,7 +39,7 @@ public interface IChapterRepository Task GetChapterDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task GetChapterMetadataDtoAsync(int chapterId, ChapterIncludes includes = ChapterIncludes.Files); Task> GetFilesForChapterAsync(int chapterId); - Task> GetChaptersAsync(int volumeId); + Task> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None); Task> GetFilesForChaptersAsync(IReadOnlyList chapterIds); Task GetChapterCoverImageAsync(int chapterId); Task> GetAllCoverImagesAsync(); @@ -42,6 +47,7 @@ public interface IChapterRepository Task> GetCoverImagesForLockedChaptersAsync(); Task AddChapterModifiers(int userId, ChapterDto chapter); IEnumerable GetChaptersForSeries(int seriesId); + Task> GetAllChaptersForSeries(int seriesId); } public class ChapterRepository : IChapterRepository { @@ -59,6 +65,16 @@ public class ChapterRepository : IChapterRepository _context.Entry(chapter).State = EntityState.Modified; } + public void Remove(Chapter chapter) + { + _context.Chapter.Remove(chapter); + } + + public void Remove(IList chapters) + { + _context.Chapter.RemoveRange(chapters); + } + public async Task> GetChaptersByIdsAsync(IList chapterIds, ChapterIncludes includes = ChapterIncludes.None) { return await _context.Chapter @@ -78,7 +94,7 @@ public class ChapterRepository : IChapterRepository .Where(c => c.Id == chapterId) .Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new { - ChapterNumber = chapter.Range, + ChapterNumber = chapter.MinNumber, VolumeNumber = volume.Name, VolumeId = volume.Id, chapter.IsSpecial, @@ -102,8 +118,8 @@ public class ChapterRepository : IChapterRepository }) .Select(data => new ChapterInfoDto() { - ChapterNumber = data.ChapterNumber, - VolumeNumber = data.VolumeNumber + string.Empty, + ChapterNumber = data.ChapterNumber + string.Empty, // TODO: Fix this + VolumeNumber = data.VolumeNumber + string.Empty, // TODO: Fix this VolumeId = data.VolumeId, IsSpecial = data.IsSpecial, SeriesId = data.SeriesId, @@ -175,6 +191,7 @@ public class ChapterRepository : IChapterRepository { return await _context.Chapter .Includes(includes) + .OrderBy(c => c.SortOrder) .FirstOrDefaultAsync(c => c.Id == chapterId); } @@ -183,10 +200,12 @@ public class ChapterRepository : IChapterRepository ///
/// /// - public async Task> GetChaptersAsync(int volumeId) + public async Task> GetChaptersAsync(int volumeId, ChapterIncludes includes = ChapterIncludes.None) { return await _context.Chapter .Where(c => c.VolumeId == volumeId) + .Includes(includes) + .OrderBy(c => c.SortOrder) .ToListAsync(); } @@ -267,11 +286,28 @@ public class ChapterRepository : IChapterRepository return chapter; } + /// + /// Includes Volumes + /// + /// + /// public IEnumerable GetChaptersForSeries(int seriesId) { return _context.Chapter .Where(c => c.Volume.SeriesId == seriesId) + .OrderBy(c => c.SortOrder) .Include(c => c.Volume) .AsEnumerable(); } + + public async Task> GetAllChaptersForSeries(int seriesId) + { + return await _context.Chapter + .Where(c => c.Volume.SeriesId == seriesId) + .OrderBy(c => c.SortOrder) + .Include(c => c.Volume) + .Include(c => c.People) + .ThenInclude(cp => cp.Person) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a7c942734..562f59e91 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -3,44 +3,64 @@ 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 API.Services.Plus; 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); + Task> GetAllCollectionsForSyncing(DateTime expirationTime); } + public class CollectionTagRepository : ICollectionTagRepository { private readonly DataContext _context; @@ -52,17 +72,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 +85,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 +139,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 +170,61 @@ 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) + .ToListAsync(); + + + tag.AgeRating = maxAgeRating.Count != 0 ? maxAgeRating.Max() : AgeRating.Unknown; + 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> GetAllCollectionsForSyncing(DateTime expirationTime) { - return await _context.CollectionTag + return await _context.AppUserCollection + .Where(c => c.Source == ScrobbleProvider.Mal) + .Where(c => c.LastSyncUtc <= expirationTime) + .Include(c => c.Items) + .AsSplitQuery() + .ToListAsync(); + } + + public async Task GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None) + { + return await _context.AppUserCollection .Where(c => c.Id == tagId) .Includes(includes) .AsSplitQuery() @@ -190,16 +244,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/CoverDbRepository.cs b/API/Data/Repositories/CoverDbRepository.cs new file mode 100644 index 000000000..ed13493ab --- /dev/null +++ b/API/Data/Repositories/CoverDbRepository.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using API.DTOs.CoverDb; +using API.Entities; +using API.Entities.Person; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace API.Data.Repositories; +#nullable enable + +/// +/// This is a manual repository, not a DB repo +/// +public class CoverDbRepository +{ + private readonly List _authors; + + public CoverDbRepository(string filePath) + { + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + // Read and deserialize YAML file + var yamlContent = File.ReadAllText(filePath); + var peopleData = deserializer.Deserialize(yamlContent); + _authors = peopleData.People; + } + + public CoverDbAuthor? FindAuthorByNameOrAlias(string name) + { + return _authors.Find(author => + author.Name.Equals(name, StringComparison.OrdinalIgnoreCase) || + author.Aliases.Contains(name, StringComparer.OrdinalIgnoreCase)); + } + + public CoverDbAuthor? FindBestAuthorMatch(Person person) + { + var aniListId = person.AniListId > 0 ? $"{person.AniListId}" : string.Empty; + var highestScore = 0; + CoverDbAuthor? bestMatch = null; + + foreach (var author in _authors) + { + var score = 0; + + // Check metadata IDs and add points if they match + if (!string.IsNullOrEmpty(author.Ids.AmazonId) && author.Ids.AmazonId == person.Asin) + { + score += 10; + } + if (!string.IsNullOrEmpty(author.Ids.AnilistId) && author.Ids.AnilistId == aniListId) + { + score += 10; + } + if (!string.IsNullOrEmpty(author.Ids.HardcoverId) && author.Ids.HardcoverId == person.HardcoverId) + { + score += 10; + } + + // Check for exact name match + if (author.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase)) + { + score += 7; + } + + // Check for alias match + if (author.Aliases.Contains(person.Name, StringComparer.OrdinalIgnoreCase)) + { + score += 5; + } + + // Update the best match if current score is higher + if (score <= highestScore) continue; + + highestScore = score; + bestMatch = author; + } + + return bestMatch; + } + +} diff --git a/API/Data/Repositories/EmailHistoryRepository.cs b/API/Data/Repositories/EmailHistoryRepository.cs new file mode 100644 index 000000000..e5ed1377a --- /dev/null +++ b/API/Data/Repositories/EmailHistoryRepository.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Email; +using API.Entities; +using API.Helpers; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IEmailHistoryRepository +{ + Task> GetEmailDtos(UserParams userParams); +} + +public class EmailHistoryRepository : IEmailHistoryRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public EmailHistoryRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + + public async Task> GetEmailDtos(UserParams userParams) + { + return await _context.EmailHistory + .OrderByDescending(h => h.SendDate) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } +} diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index 31de47d21..45882b5c4 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.DTOs.KavitaPlus.Manage; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -31,13 +32,12 @@ public interface IExternalSeriesMetadataRepository void Remove(IEnumerable? recommendations); void Remove(ExternalSeriesMetadata metadata); Task GetExternalSeriesMetadata(int seriesId); - Task ExternalSeriesMetadataNeedsRefresh(int seriesId); - Task GetSeriesDetailPlusDto(int seriesId); + Task NeedsDataRefresh(int seriesId); + Task GetSeriesDetailPlusDto(int seriesId); Task LinkRecommendationsToSeries(Series series); Task IsBlacklistedSeries(int seriesId); - Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true); - Task RemoveFromBlacklist(int seriesId); - Task> GetAllSeriesIdsWithoutMetadata(int limit); + Task> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false); + Task> GetAllSeries(ManageMatchFilterDto filter); } public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepository @@ -106,7 +106,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .FirstOrDefaultAsync(); } - public async Task ExternalSeriesMetadataNeedsRefresh(int seriesId) + public async Task NeedsDataRefresh(int seriesId) { var row = await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) @@ -114,7 +114,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor return row == null || row.ValidUntilUtc <= DateTime.UtcNow; } - public async Task GetSeriesDetailPlusDto(int seriesId) + public async Task GetSeriesDetailPlusDto(int seriesId) { var seriesDetailDto = await _context.ExternalSeriesMetadata .Where(m => m.SeriesId == seriesId) @@ -157,8 +157,8 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .OrderByDescending(r => r.Score); } - IEnumerable ratings = new List(); - if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Any()) + IEnumerable ratings = []; + if (seriesDetailDto.ExternalRatings != null && seriesDetailDto.ExternalRatings.Count != 0) { ratings = seriesDetailDto.ExternalRatings .Select(r => _mapper.Map(r)); @@ -191,6 +191,7 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor .Where(r => EF.Functions.Like(r.Name, series.Name) || EF.Functions.Like(r.Name, series.LocalizedName)) .ToListAsync(); + foreach (var rec in recMatches) { rec.SeriesId = series.Id; @@ -201,55 +202,38 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor public Task IsBlacklistedSeries(int seriesId) { - return _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId); + return _context.Series + .Where(s => s.Id == seriesId) + .Select(s => s.IsBlacklisted) + .FirstOrDefaultAsync(); } - /// - /// Creates a new instance against SeriesId and Saves to the DB - /// - /// - /// - public async Task CreateBlacklistedSeries(int seriesId, bool saveChanges = true) - { - if (seriesId <= 0 || await _context.SeriesBlacklist.AnyAsync(s => s.SeriesId == seriesId)) return; - await _context.SeriesBlacklist.AddAsync(new SeriesBlacklist() - { - SeriesId = seriesId - }); - if (saveChanges) - { - await _context.SaveChangesAsync(); - } - } - - /// - /// Removes the Series from Blacklist and Saves to the DB - /// - /// - public async Task RemoveFromBlacklist(int seriesId) - { - var seriesBlacklist = await _context.SeriesBlacklist.FirstOrDefaultAsync(sb => sb.SeriesId == seriesId); - - if (seriesBlacklist != null) - { - // Remove the SeriesBlacklist entity from the context - _context.SeriesBlacklist.Remove(seriesBlacklist); - - // Save the changes to the database - await _context.SaveChangesAsync(); - } - } - - public async Task> GetAllSeriesIdsWithoutMetadata(int limit) + public async Task> GetSeriesThatNeedExternalMetadata(int limit, bool includeStaleData = false) { return await _context.Series .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) - .Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) + .Where(s => s.Library.AllowMetadataMatching) + .WhereIf(includeStaleData, s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc < DateTime.UtcNow) + .Where(s => s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.AniListId == 0) + .Where(s => !s.IsBlacklisted && !s.DontMatch) .OrderByDescending(s => s.Library.Type) .ThenBy(s => s.NormalizedName) .Select(s => s.Id) .Take(limit) .ToListAsync(); } + + public async Task> GetAllSeries(ManageMatchFilterDto filter) + { + return await _context.Series + .Include(s => s.Library) + .Include(s => s.ExternalSeriesMetadata) + .Where(s => !ExternalMetadataService.NonEligibleLibraryTypes.Contains(s.Library.Type)) + .Where(s => s.Library.AllowMetadataMatching) + .FilterMatchState(filter.MatchStateOption) + .OrderBy(s => s.NormalizedName) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 94e3c8e7f..ef9dfa7ec 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -6,11 +6,13 @@ using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IGenreRepository { @@ -19,12 +21,12 @@ public interface IGenreRepository Task FindByNameAsync(string genreName); Task> GetAllGenresAsync(); Task> GetAllGenresByNamesAsync(IEnumerable normalizedNames); - Task> GetAllGenreDtosAsync(int userId); Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); - Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds, int userId); + Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null, QueryContext context = QueryContext.None); Task GetCountAsync(); Task GetRandomGenre(); Task GetGenreById(int id); + Task> GetAllGenresNotInListAsync(ICollection genreNames); } public class GenreRepository : IGenreRepository @@ -69,27 +71,6 @@ public class GenreRepository : IGenreRepository await _context.SaveChangesAsync(); } - /// - /// Returns a set of Genre tags for a set of library Ids. UserId will restrict returned Genres based on user's age restriction. - /// - /// - /// - /// - public async Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds, int userId) - { - var userRating = await _context.AppUser.GetUserAgeRestriction(userId); - return await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) - .RestrictAgainstAgeRestriction(userRating) - .SelectMany(s => s.Metadata.Genres) - .AsSplitQuery() - .Distinct() - .OrderBy(p => p.NormalizedTitle) - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - public async Task GetCountAsync() { return await _context.Genre.CountAsync(); @@ -128,14 +109,60 @@ public class GenreRepository : IGenreRepository .ToListAsync(); } - public async Task> GetAllGenreDtosAsync(int userId) + /// + /// Returns a set of Genre tags for a set of library Ids. + /// UserId will restrict returned Genres based on user's age restriction and library access. + /// + /// + /// + /// + public async Task> GetAllGenreDtosForLibrariesAsync(int userId, IList? libraryIds = null, QueryContext context = QueryContext.None) { - var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - return await _context.Genre - .RestrictAgainstAgeRestriction(ageRating) - .OrderBy(g => g.NormalizedTitle) - .AsNoTracking() + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId, context).ToListAsync(); + + if (libraryIds is {Count: > 0}) + { + userLibs = userLibs.Where(libraryIds.Contains).ToList(); + } + + return await _context.Series + .Where(s => userLibs.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) + .SelectMany(s => s.Metadata.Genres) + .AsSplitQuery() + .Distinct() + .OrderBy(p => p.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } + + /// + /// Gets all genres that are not already present in the system. + /// Normalizes genres for lookup, but returns non-normalized names for creation. + /// + /// The list of genre names (non-normalized). + /// A list of genre names that do not exist in the system. + public async Task> GetAllGenresNotInListAsync(ICollection genreNames) + { + // Group the genres by their normalized names, keeping track of the original names + var normalizedToOriginalMap = genreNames + .Distinct() + .GroupBy(Parser.Normalize) + .ToDictionary(group => group.Key, group => group.First()); // Take the first original name for each normalized name + + var normalizedGenreNames = normalizedToOriginalMap.Keys.ToList(); + + // Query the database for existing genres using the normalized names + var existingGenres = await _context.Genre + .Where(g => normalizedGenreNames.Contains(g.NormalizedTitle)) // Assuming you have a normalized field + .Select(g => g.NormalizedTitle) + .ToListAsync(); + + // Find the normalized genres that do not exist in the database + var missingGenres = normalizedGenreNames.Except(existingGenres).ToList(); + + // Return the original non-normalized genres for the missing ones + return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); + } } diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index bc19c5312..b3f905dce 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -18,6 +18,7 @@ using Kavita.Common.Extensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum LibraryIncludes @@ -39,7 +40,7 @@ public interface ILibraryRepository Task LibraryExists(string libraryName); Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes = LibraryIncludes.None); IEnumerable GetLibraryDtosForUsernameAsync(string userName); - Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None); + Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None, bool track = true); Task> GetLibrariesForUserIdAsync(int userId); IEnumerable GetLibraryIdsForUserIdAsync(int userId, QueryContext queryContext = QueryContext.None); Task GetLibraryTypeAsync(int libraryId); @@ -105,13 +106,16 @@ public class LibraryRepository : ILibraryRepository ///
/// /// - public async Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None) + public async Task> GetLibrariesAsync(LibraryIncludes includes = LibraryIncludes.None, bool track = true) { - return await _context.Library + var query = _context.Library .Include(l => l.AppUsers) .Includes(includes) - .AsSplitQuery() - .ToListAsync(); + .AsSplitQuery(); + + if (track) return await query.ToListAsync(); + + return await query.AsNoTracking().ToListAsync(); } /// @@ -258,7 +262,7 @@ public class LibraryRepository : ILibraryRepository public async Task> GetAllLanguagesForLibrariesAsync(List? libraryIds) { var ret = await _context.Series - .WhereIf(libraryIds is {Count: > 0} , s => libraryIds.Contains(s.LibraryId)) + .WhereIf(libraryIds is {Count: > 0} , s => libraryIds!.Contains(s.LibraryId)) .Select(s => s.Metadata.Language) .AsSplitQuery() .AsNoTracking() @@ -319,7 +323,7 @@ public class LibraryRepository : ILibraryRepository /// public async Task DoAnySeriesFoldersMatch(IEnumerable folders) { - var normalized = folders.Select(Services.Tasks.Scanner.Parser.Parser.NormalizePath); + var normalized = folders.Select(Parser.NormalizePath); return await _context.Series.AnyAsync(s => normalized.Contains(s.FolderPath)); } diff --git a/API/Data/Repositories/MediaErrorRepository.cs b/API/Data/Repositories/MediaErrorRepository.cs index c2e932d32..40501768e 100644 --- a/API/Data/Repositories/MediaErrorRepository.cs +++ b/API/Data/Repositories/MediaErrorRepository.cs @@ -9,15 +9,18 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IMediaErrorRepository { void Attach(MediaError error); void Remove(MediaError error); + void Remove(IList errors); Task Find(string filename); IEnumerable GetAllErrorDtosAsync(); Task ExistsAsync(MediaError error); Task DeleteAll(); + Task> GetAllErrorsAsync(IList comments); } public class MediaErrorRepository : IMediaErrorRepository @@ -43,6 +46,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.Remove(error); } + public void Remove(IList errors) + { + _context.MediaError.RemoveRange(errors); + } + public Task Find(string filename) { return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync(); @@ -70,4 +78,11 @@ public class MediaErrorRepository : IMediaErrorRepository _context.MediaError.RemoveRange(await _context.MediaError.ToListAsync()); await _context.SaveChangesAsync(); } + + public Task> GetAllErrorsAsync(IList comments) + { + return _context.MediaError + .Where(m => comments.Contains(m.Comment)) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 1c9e71e1e..db66ecd79 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,29 +1,46 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; -using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IPersonRepository { void Attach(Person person); + void Attach(IEnumerable person); void Remove(Person person); + void Remove(ChapterPeople person); + void Remove(SeriesMetadataPeople person); + void Update(Person person); + Task> GetAllPeople(); Task> GetAllPersonDtosAsync(int userId); Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); Task RemoveAllPeopleNoLongerAssociated(); - Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds, int userId); - Task GetCountAsync(); + Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); - Task> GetAllPeopleByRoleAndNames(PersonRole role, IEnumerable normalizeNames); + Task GetCoverImageAsync(int personId); + Task GetCoverImageByNameAsync(string name); + Task> GetRolesForPersonByName(int personId, int userId); + Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); + Task GetPersonById(int personId); + Task GetPersonDtoByName(string name, int userId); + Task IsNameUnique(string name); + + Task> GetSeriesKnownFor(int personId); + Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); + Task> GetPeopleByNames(List normalizedNames); + Task GetPersonByAniListId(int aniListId); } public class PersonRepository : IPersonRepository @@ -42,17 +59,37 @@ public class PersonRepository : IPersonRepository _context.Person.Attach(person); } + public void Attach(IEnumerable person) + { + _context.Person.AttachRange(person); + } + public void Remove(Person person) { _context.Person.Remove(person); } + public void Remove(ChapterPeople person) + { + _context.ChapterPeople.Remove(person); + } + + public void Remove(SeriesMetadataPeople person) + { + _context.SeriesMetadataPeople.Remove(person); + } + + public void Update(Person person) + { + _context.Person.Update(person); + } + public async Task RemoveAllPeopleNoLongerAssociated() { var peopleWithNoConnections = await _context.Person - .Include(p => p.SeriesMetadatas) - .Include(p => p.ChapterMetadatas) - .Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0) + .Include(p => p.SeriesMetadataPeople) + .Include(p => p.ChapterPeople) + .Where(p => p.SeriesMetadataPeople.Count == 0 && p.ChapterPeople.Count == 0) .AsSplitQuery() .ToListAsync(); @@ -61,13 +98,21 @@ public class PersonRepository : IPersonRepository await _context.SaveChangesAsync(); } - public async Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds, int userId) + + public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + if (libraryIds is {Count: > 0}) + { + userLibs = userLibs.Where(libraryIds.Contains).ToList(); + } + return await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(s => s.Metadata.People) + .SelectMany(s => s.Metadata.People.Select(p => p.Person)) .Distinct() .OrderBy(p => p.Name) .AsNoTracking() @@ -76,18 +121,144 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task GetCountAsync() - { - return await _context.Person.CountAsync(); - } - public async Task> GetAllPeopleByRoleAndNames(PersonRole role, IEnumerable normalizeNames) + public async Task GetCoverImageAsync(int personId) { return await _context.Person - .Where(p => p.Role == role && normalizeNames.Contains(p.NormalizedName)) + .Where(c => c.Id == personId) + .Select(c => c.CoverImage) + .SingleOrDefaultAsync(); + } + + public async Task GetCoverImageByNameAsync(string name) + { + var normalized = name.ToNormalized(); + return await _context.Person + .Where(c => c.NormalizedName == normalized) + .Select(c => c.CoverImage) + .SingleOrDefaultAsync(); + } + + public async Task> GetRolesForPersonByName(int personId, int userId) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + // Query roles from ChapterPeople + var chapterRoles = await _context.Person + .Where(p => p.Id == personId) + .RestrictAgainstAgeRestriction(ageRating) + .SelectMany(p => p.ChapterPeople.Select(cp => cp.Role)) + .Distinct() + .ToListAsync(); + + // Query roles from SeriesMetadataPeople + var seriesRoles = await _context.Person + .Where(p => p.Id == personId) + .RestrictAgainstAgeRestriction(ageRating) + .SelectMany(p => p.SeriesMetadataPeople.Select(smp => smp.Role)) + .Distinct() + .ToListAsync(); + + // Combine and return distinct roles + return chapterRoles.Union(seriesRoles).Distinct(); + } + + public async Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams) + { + List roles = [PersonRole.Writer, PersonRole.CoverArtist]; + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var query = _context.Person + .Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) + .RestrictAgainstAgeRestriction(ageRating) + .Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + CoverImage = p.CoverImage, + SeriesCount = p.SeriesMetadataPeople + .Where(smp => roles.Contains(smp.Role)) + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count(), + IssueCount = p.ChapterPeople + .Where(cp => roles.Contains(cp.Role)) + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() + }) + .OrderBy(p => p.Name); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task GetPersonById(int personId) + { + return await _context.Person.Where(p => p.Id == personId) + .FirstOrDefaultAsync(); + } + + public async Task GetPersonDtoByName(string name, int userId) + { + var normalized = name.ToNormalized(); + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + return await _context.Person + .Where(p => p.NormalizedName == normalized) + .RestrictAgainstAgeRestriction(ageRating) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + + public async Task IsNameUnique(string name) + { + return !(await _context.Person.AnyAsync(p => p.Name == name)); + } + + public async Task> GetSeriesKnownFor(int personId) + { + List notValidRoles = [PersonRole.Location, PersonRole.Team, PersonRole.Other, PersonRole.Publisher, PersonRole.Translator]; + return await _context.Person + .Where(p => p.Id == personId) + .SelectMany(p => p.SeriesMetadataPeople.Where(smp => !notValidRoles.Contains(smp.Role))) + .Select(smp => smp.SeriesMetadata) + .Select(sm => sm.Series) + .Distinct() + .OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating) + .Take(20) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } + public async Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + return await _context.ChapterPeople + .Where(cp => cp.PersonId == personId && cp.Role == role) + .Select(cp => cp.Chapter) + .RestrictAgainstAgeRestriction(ageRating) + .OrderBy(ch => ch.SortOrder) + .Take(20) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetPeopleByNames(List normalizedNames) + { + return await _context.Person + .Where(p => normalizedNames.Contains(p.NormalizedName)) + .OrderBy(p => p.Name) + .ToListAsync(); + } + + public async Task GetPersonByAniListId(int aniListId) + { + return await _context.Person + .Where(p => p.AniListId == aniListId) + .FirstOrDefaultAsync(); + } public async Task> GetAllPeople() { @@ -99,6 +270,7 @@ public class PersonRepository : IPersonRepository public async Task> GetAllPersonDtosAsync(int userId) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.Person .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) @@ -109,8 +281,9 @@ public class PersonRepository : IPersonRepository public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.Person - .Where(p => p.Role == role) + .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 9c3d40011..6d4a14bd9 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs; using API.DTOs.ReadingLists; using API.Entities; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum ReadingListIncludes @@ -36,6 +38,8 @@ public interface IReadingListRepository Task> GetReadingListItemsByIdAsync(int readingListId); Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted); + Task> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, + bool includePromoted); void Remove(ReadingListItem item); void Add(ReadingList list); void BulkRemove(IEnumerable items); @@ -45,10 +49,15 @@ public interface IReadingListRepository Task> GetRandomCoverImagesAsync(int readingListId); Task> GetAllCoverImagesAsync(); Task ReadingListExists(string name); - IEnumerable GetReadingListCharactersAsync(int readingListId); + Task ReadingListExistsForUser(string name, int userId); + IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role); + Task GetReadingListAllPeopleAsync(int readingListId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); + Task GetReadingListInfoAsync(int readingListId); } public class ReadingListRepository : IReadingListRepository @@ -82,7 +91,7 @@ public class ReadingListRepository : IReadingListRepository return await _context.ReadingList .Where(c => c.Id == readingListId) .Select(c => c.CoverImage) - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); } public async Task> GetAllCoverImagesAsync() @@ -101,6 +110,7 @@ public class ReadingListRepository : IReadingListRepository .SelectMany(r => r.Items.Select(ri => ri.Chapter.CoverImage)) .Where(t => !string.IsNullOrEmpty(t)) .ToListAsync(); + return data .OrderBy(_ => random.Next()) .Take(4) @@ -115,17 +125,97 @@ public class ReadingListRepository : IReadingListRepository .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized)); } - public IEnumerable GetReadingListCharactersAsync(int readingListId) + public async Task ReadingListExistsForUser(string name, int userId) + { + var normalized = name.ToNormalized(); + return await _context.ReadingList + .AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId); + } + + public IEnumerable GetReadingListPeopleAsync(int readingListId, PersonRole role) { return _context.ReadingListItem .Where(item => item.ReadingListId == readingListId) - .SelectMany(item => item.Chapter.People.Where(p => p.Role == PersonRole.Character)) - .OrderBy(p => p.NormalizedName) + .SelectMany(item => item.Chapter.People) + .Where(p => p.Role == role) + .OrderBy(p => p.Person.NormalizedName) + .Select(p => p.Person) .Distinct() .ProjectTo(_mapper.ConfigurationProvider) .AsEnumerable(); } + public async Task GetReadingListAllPeopleAsync(int readingListId) + { + var allPeople = await _context.ReadingListItem + .Where(item => item.ReadingListId == readingListId) + .SelectMany(item => item.Chapter.People) + .OrderBy(p => p.Person.NormalizedName) + .Select(p => new + { + Role = p.Role, + Person = _mapper.Map(p.Person) + }) + .Distinct() + .ToListAsync(); + + // Create the ReadingListCast object + var cast = new ReadingListCast(); + + // Group people by role and populate the appropriate collections + foreach (var personGroup in allPeople.GroupBy(p => p.Role)) + { + var people = personGroup.Select(pg => pg.Person).ToList(); + + switch (personGroup.Key) + { + case PersonRole.Writer: + cast.Writers = people; + break; + case PersonRole.CoverArtist: + cast.CoverArtists = people; + break; + case PersonRole.Publisher: + cast.Publishers = people; + break; + case PersonRole.Character: + cast.Characters = people; + break; + case PersonRole.Penciller: + cast.Pencillers = people; + break; + case PersonRole.Inker: + cast.Inkers = people; + break; + case PersonRole.Imprint: + cast.Imprints = people; + break; + case PersonRole.Colorist: + cast.Colorists = people; + break; + case PersonRole.Letterer: + cast.Letterers = people; + break; + case PersonRole.Editor: + cast.Editors = people; + break; + case PersonRole.Translator: + cast.Translators = people; + break; + case PersonRole.Team: + cast.Teams = people; + break; + case PersonRole.Location: + cast.Locations = people; + break; + case PersonRole.Other: + break; + } + } + + return cast; + } + public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); @@ -156,6 +246,51 @@ public class ReadingListRepository : IReadingListRepository .FirstOrDefaultAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId); } + public async Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items) + { + return await _context.ReadingList + .Where(c => ids.Contains(c.Id)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + public async Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items) + { + return await _context.ReadingList + .Where(rl => rl.Items.Any(rli => rli.SeriesId == seriesId)) + .Includes(includes) + .AsSplitQuery() + .ToListAsync(); + } + + /// + /// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo + /// + /// + /// + public async Task GetReadingListInfoAsync(int readingListId) + { + // Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub) + var readingList = await _context.ReadingList + .Where(rl => rl.Id == readingListId) + .Include(rl => rl.Items) + .ThenInclude(item => item.Series) + .Include(rl => rl.Items) + .ThenInclude(item => item.Volume) + .Include(rl => rl.Items) + .ThenInclude(item => item.Chapter) + .Select(rl => new ReadingListInfoDto() + { + WordCount = rl.Items.Sum(item => item.Chapter.WordCount), + Pages = rl.Items.Sum(item => item.Chapter.Pages), + IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub), + }) + .FirstOrDefaultAsync(); + + return readingList; + } + + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); @@ -169,10 +304,11 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForUserAsync(int userId, bool includePromoted, UserParams userParams, bool sortByLastModified = true) { - var userAgeRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) - .Where(l => l.AgeRating >= userAgeRating); + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()); + query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.NormalizedTitle); var finalQuery = query.ProjectTo(_mapper.ConfigurationProvider) @@ -183,8 +319,10 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); var query = _context.ReadingList .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .Where(l => l.Items.Any(i => i.SeriesId == seriesId)) .AsSplitQuery() .OrderBy(l => l.Title) @@ -194,6 +332,21 @@ public class ReadingListRepository : IReadingListRepository return await query.ToListAsync(); } + public async Task> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, bool includePromoted) + { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); + var query = _context.ReadingList + .Where(l => l.AppUserId == userId || (includePromoted && l.Promoted )) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) + .Where(l => l.Items.Any(i => i.ChapterId == chapterId)) + .AsSplitQuery() + .OrderBy(l => l.Title) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + + return await query.ToListAsync(); + } + public async Task GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None) { return await _context.ReadingList @@ -206,13 +359,7 @@ public class ReadingListRepository : IReadingListRepository public async Task> GetReadingListItemDtosByIdAsync(int readingListId, int userId) { - var userLibraries = _context.Library - .Include(l => l.AppUsers) - .Where(library => library.AppUsers.Any(user => user.Id == userId)) - .AsSplitQuery() - .AsNoTracking() - .Select(library => library.Id) - .ToList(); + var userLibraries = _context.Library.GetUserLibraries(userId); var items = await _context.ReadingListItem .Where(s => s.ReadingListId == readingListId) @@ -223,7 +370,9 @@ public class ReadingListRepository : IReadingListRepository chapter.ReleaseDate, ReadingListItem = data, ChapterTitleName = chapter.TitleName, - FileSize = chapter.Files.Sum(f => f.Bytes) + FileSize = chapter.Files.Sum(f => f.Bytes), + chapter.Summary, + chapter.IsSpecial }) .Join(_context.Volume, s => s.ReadingListItem.VolumeId, volume => volume.Id, (data, volume) => new @@ -234,6 +383,8 @@ public class ReadingListRepository : IReadingListRepository data.ReleaseDate, data.ChapterTitleName, data.FileSize, + data.Summary, + data.IsSpecial, VolumeId = volume.Id, VolumeNumber = volume.Name, }) @@ -251,6 +402,8 @@ public class ReadingListRepository : IReadingListRepository data.ReleaseDate, data.ChapterTitleName, data.FileSize, + data.Summary, + data.IsSpecial, LibraryName = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Name).Single(), LibraryType = _context.Library.Where(l => l.Id == s.LibraryId).Select(l => l.Type).Single() }) @@ -272,7 +425,9 @@ public class ReadingListRepository : IReadingListRepository LibraryType = data.LibraryType, ChapterTitleName = data.ChapterTitleName, LibraryName = data.LibraryName, - FileSize = data.FileSize + FileSize = data.FileSize, + Summary = data.Summary, + IsSpecial = data.IsSpecial }) .Where(o => userLibraries.Contains(o.LibraryId)) .OrderBy(rli => rli.Order) @@ -306,8 +461,10 @@ public class ReadingListRepository : IReadingListRepository public async Task GetReadingListDtoByIdAsync(int readingListId, int userId) { + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); return await _context.ReadingList .Where(r => r.Id == readingListId && (r.AppUserId == userId || r.Promoted)) + .RestrictAgainstAgeRestriction(user.GetAgeRestriction()) .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); } diff --git a/API/Data/Repositories/ScrobbleEventRepository.cs b/API/Data/Repositories/ScrobbleEventRepository.cs index 7d3567831..c5f30c2ec 100644 --- a/API/Data/Repositories/ScrobbleEventRepository.cs +++ b/API/Data/Repositories/ScrobbleEventRepository.cs @@ -12,6 +12,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface IScrobbleRepository { @@ -19,16 +20,21 @@ public interface IScrobbleRepository void Attach(ScrobbleError error); void Remove(ScrobbleEvent evt); void Remove(IEnumerable events); + void Remove(IEnumerable errors); void Update(ScrobbleEvent evt); Task> GetByEvent(ScrobbleEventType type, bool isProcessed = false); Task> GetProcessedEvents(int daysAgo); Task Exists(int userId, int seriesId, ScrobbleEventType eventType); Task> GetScrobbleErrors(); + Task> GetAllScrobbleErrorsForSeries(int seriesId); Task ClearScrobbleErrors(); Task HasErrorForSeries(int seriesId); Task GetEvent(int userId, int seriesId, ScrobbleEventType eventType); Task> GetUserEventsForSeries(int userId, int seriesId); Task> GetUserEvents(int userId, ScrobbleEventFilter filter, UserParams pagination); + Task> GetAllEventsForSeries(int seriesId); + Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds); + Task> GetEvents(); } /// @@ -65,6 +71,11 @@ public class ScrobbleRepository : IScrobbleRepository _context.ScrobbleEvent.RemoveRange(events); } + public void Remove(IEnumerable errors) + { + _context.ScrobbleError.RemoveRange(errors); + } + public void Update(ScrobbleEvent evt) { _context.Entry(evt).State = EntityState.Modified; @@ -78,6 +89,7 @@ public class ScrobbleRepository : IScrobbleRepository .Include(s => s.Series) .ThenInclude(s => s.Metadata) .Include(s => s.AppUser) + .ThenInclude(u => u.UserPreferences) .Where(s => s.ScrobbleEventType == type) .Where(s => s.IsProcessed == isProcessed) .AsSplitQuery() @@ -88,6 +100,11 @@ public class ScrobbleRepository : IScrobbleRepository .ToListAsync(); } + /// + /// Returns all processed events that were processed 7 or more days ago + /// + /// + /// public async Task> GetProcessedEvents(int daysAgo) { var date = DateTime.UtcNow.Subtract(TimeSpan.FromDays(daysAgo)); @@ -111,6 +128,13 @@ public class ScrobbleRepository : IScrobbleRepository .ToListAsync(); } + public async Task> GetAllScrobbleErrorsForSeries(int seriesId) + { + return await _context.ScrobbleError + .Where(e => e.SeriesId == seriesId) + .ToListAsync(); + } + public async Task ClearScrobbleErrors() { _context.ScrobbleError.RemoveRange(_context.ScrobbleError); @@ -143,14 +167,35 @@ public class ScrobbleRepository : IScrobbleRepository var query = _context.ScrobbleEvent .Where(e => e.AppUserId == userId) .Include(e => e.Series) - .SortBy(filter.Field, filter.IsDescending) .WhereIf(!string.IsNullOrEmpty(filter.Query), s => EF.Functions.Like(s.Series.Name, $"%{filter.Query}%") ) .WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review) + .SortBy(filter.Field, filter.IsDescending) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); return await PagedList.CreateAsync(query, pagination.PageNumber, pagination.PageSize); } + + public async Task> GetAllEventsForSeries(int seriesId) + { + return await _context.ScrobbleEvent + .Where(e => e.SeriesId == seriesId) + .ToListAsync(); + } + + public async Task> GetAllEventsWithSeriesIds(IEnumerable seriesIds) + { + return await _context.ScrobbleEvent + .Where(e => seriesIds.Contains(e.SeriesId)) + .ToListAsync(); + } + + public async Task> GetEvents() + { + return await _context.ScrobbleEvent + .Include(e => e.AppUser) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 9c6dcc2bb..31ddc22f1 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -7,12 +8,14 @@ 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; using API.DTOs.Filtering.v2; using API.DTOs.Metadata; using API.DTOs.ReadingLists; +using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.Search; using API.DTOs.SeriesDetail; @@ -36,12 +39,16 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum SeriesIncludes { None = 1, Volumes = 2, + /// + /// This will include all necessary includes + /// Metadata = 4, Related = 8, Library = 16, @@ -49,8 +56,7 @@ public enum SeriesIncludes ExternalReviews = 64, ExternalRatings = 128, ExternalRecommendations = 256, - ExternalMetadata = 512 - + ExternalMetadata = 512, } /// @@ -61,6 +67,7 @@ public enum QueryContext { None = 1, Search = 2, + [Obsolete("Use Dashboard")] Recommended = 3, Dashboard = 4, } @@ -69,9 +76,11 @@ public interface ISeriesRepository { void Add(Series series); void Attach(Series series); + void Attach(SeriesRelation relation); void Update(Series series); void Remove(Series series); void Remove(IEnumerable series); + void Detach(Series series); Task DoesSeriesNameExistInLibrary(string name, int libraryId, MangaFormat format); /// /// Adds user information like progress, ratings, etc @@ -89,13 +98,14 @@ public interface ISeriesRepository /// /// /// + /// Includes Files in the Search /// - Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery); + Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery, bool includeChapterAndFiles = true); Task> GetSeriesForLibraryIdAsync(int libraryId, SeriesIncludes includes = SeriesIncludes.None); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task GetSeriesByIdAsync(int seriesId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata); Task> GetSeriesDtoByIdsAsync(IEnumerable seriesIds, AppUser user); - Task> GetSeriesByIdsAsync(IList seriesIds); + Task> GetSeriesByIdsAsync(IList seriesIds, bool fullSeries = true); Task GetChapterIdsForSeriesAsync(IList seriesIds); Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); /// @@ -134,14 +144,19 @@ public interface ISeriesRepository Task> GetWantToReadForUserAsync(int userId); Task IsSeriesInWantToRead(int userId, int seriesId); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); + Task GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None); Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); + + Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); + Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None); public Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, 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 /// @@ -153,8 +168,10 @@ public interface ISeriesRepository Task GetAverageUserRating(int seriesId, int userId); Task RemoveFromOnDeck(int seriesId, int userId); Task ClearOnDeckRemoval(int seriesId, int userId); - Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto); - Task GetPlusSeriesDto(int seriesId); + Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None); + Task GetPlusSeriesDto(int seriesId); + Task GetCountAsync(); + Task MatchSeries(ExternalSeriesDetailDto externalSeries); } public class SeriesRepository : ISeriesRepository @@ -183,6 +200,11 @@ public class SeriesRepository : ISeriesRepository _context.Series.Attach(series); } + public void Attach(SeriesRelation relation) + { + _context.SeriesRelation.Attach(relation); + } + public void Attach(ExternalSeriesMetadata metadata) { _context.ExternalSeriesMetadata.Attach(metadata); @@ -203,6 +225,11 @@ public class SeriesRepository : ISeriesRepository _context.Series.RemoveRange(series); } + public void Detach(Series series) + { + _context.Entry(series).State = EntityState.Detached; + } + /// /// Returns if a series name and format exists already in a library /// @@ -342,32 +369,26 @@ 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) + public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery, bool includeChapterAndFiles = true) { const int maxRecords = 15; var result = new SearchResultGroupDto(); var searchQueryNormalized = searchQuery.ToNormalized(); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); - var seriesIds = _context.Series + var seriesIds = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(userRating) .Select(s => s.Id) - .ToList(); + .ToListAsync(); 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,92 +440,77 @@ 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) + .Distinct() .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(); - var fileIds = _context.Series - .Where(s => seriesIds.Contains(s.Id)) - .AsSplitQuery() - .SelectMany(s => s.Volumes) - .SelectMany(v => v.Chapters) - .SelectMany(c => c.Files.Select(f => f.Id)); + result.Files = new List(); + result.Chapters = new List(); - // Need to check if an admin - var user = await _context.AppUser.FirstAsync(u => u.Id == userId); - if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) + + if (includeChapterAndFiles) { - result.Files = await _context.MangaFile - .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) + var fileIds = _context.Series + .Where(s => seriesIds.Contains(s.Id)) .AsSplitQuery() - .OrderBy(f => f.FilePath) + .SelectMany(s => s.Volumes) + .SelectMany(v => v.Chapters) + .SelectMany(c => c.Files.Select(f => f.Id)); + + // Need to check if an admin + var user = await _context.AppUser.FirstAsync(u => u.Id == userId); + if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) + { + result.Files = await _context.MangaFile + .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) + .AsSplitQuery() + .OrderBy(f => f.FilePath) + .Take(maxRecords) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + result.Chapters = await _context.Chapter + .Include(c => c.Files) + .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%") + || EF.Functions.Like(c.ISBN, $"%{searchQuery}%") + || EF.Functions.Like(c.Range, $"%{searchQuery}%") + ) + .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) + .AsSplitQuery() + .OrderBy(c => c.TitleName) .Take(maxRecords) - .ProjectTo(_mapper.ConfigurationProvider) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - else - { - result.Files = new List(); - } - - result.Chapters = await _context.Chapter - .Include(c => c.Files) - .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%") - || EF.Functions.Like(c.ISBN, $"%{searchQuery}%") - ) - .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) - .AsSplitQuery() - .OrderBy(c => c.TitleName) - .Take(maxRecords) - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); return result; } @@ -543,27 +549,23 @@ public class SeriesRepository : ISeriesRepository .SingleOrDefaultAsync(); } - public async Task GetSeriesByIdForUserAsync(int seriesId, int userId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata) - { - return await _context.Series - .Where(s => s.Id == seriesId) - .Includes(includes) - .SingleOrDefaultAsync(); - } - /// /// Returns Full Series including all external links /// /// + /// Include all the includes or just the Series /// - public async Task> GetSeriesByIdsAsync(IList seriesIds) + public async Task> GetSeriesByIdsAsync(IList seriesIds, bool fullSeries = true) { - return await _context.Series - .Include(s => s.Volumes) + var query = _context.Series + .Where(s => seriesIds.Contains(s.Id)) + .AsSplitQuery(); + + if (!fullSeries) return await query.ToListAsync(); + + return await query.Include(s => s.Volumes) .Include(s => s.Relations) .Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags) - .Include(s => s.ExternalSeriesMetadata) @@ -573,9 +575,6 @@ public class SeriesRepository : ISeriesRepository .ThenInclude(e => e.ExternalReviews) .Include(s => s.ExternalSeriesMetadata) .ThenInclude(e => e.ExternalRecommendations) - - .Where(s => seriesIds.Contains(s.Id)) - .AsSplitQuery() .ToListAsync(); } @@ -671,6 +670,7 @@ public class SeriesRepository : ISeriesRepository .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) .Include(m => m.People) + .ThenInclude(p => p.Person) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() @@ -703,25 +703,24 @@ public class SeriesRepository : ISeriesRepository return await query.ToListAsync(); } - public async Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto) + public async Task> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None) { - var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None); + var query = await CreateFilteredSearchQueryableV2(userId, filterDto, queryContext); var retSeries = query .ProjectTo(_mapper.ConfigurationProvider) - .AsSplitQuery() .AsNoTracking(); return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } - public async Task GetPlusSeriesDto(int seriesId) + public async Task GetPlusSeriesDto(int seriesId) { return await _context.Series .Where(s => s.Id == seriesId) - .Select(series => new PlusSeriesDto() + .Select(series => new PlusSeriesRequestDto() { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, AltSeriesName = series.LocalizedName, AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, @@ -739,6 +738,11 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); } + public async Task GetCountAsync() + { + return await _context.Series.CountAsync(); + } + public async Task AddSeriesModifiers(int userId, IList series) { var userProgress = await _context.AppUserProgresses @@ -967,6 +971,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 @@ -974,15 +992,15 @@ public class SeriesRepository : ISeriesRepository .HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max) .HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min) .HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery) - .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId) + .HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating / 100f, userId) .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) - .HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds) + .HasPeopleLegacy(hasPeopleFilter, FilterComparison.Contains, allPeopleIds) .WhereIf(onlyParentSeries, s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel)) @@ -1060,10 +1078,13 @@ public class SeriesRepository : ISeriesRepository query = ApplyWantToReadFilter(filter, query, userId); + query = await ApplyCollectionFilter(filter, query, userId, userRating); + + + query = BuildFilterQuery(userId, filter, query); - query = query .WhereIf(userLibraries.Count > 0, s => userLibraries.Contains(s.LibraryId)) .WhereIf(onlyParentSeries, s => @@ -1074,7 +1095,52 @@ public class SeriesRepository : ISeriesRepository return ApplyLimit(query .Sort(userId, filter.SortOptions) - .AsSplitQuery(), filter.LimitTo); + .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) @@ -1085,6 +1151,7 @@ public class SeriesRepository : ISeriesRepository var seriesIds = _context.AppUser.Where(u => u.Id == userId) .SelectMany(u => u.WantToRead) .Select(s => s.SeriesId); + if (bool.Parse(wantToReadStmt.Value)) { query = query.Where(s => seriesIds.Contains(s.Id)); @@ -1101,6 +1168,7 @@ public class SeriesRepository : ISeriesRepository { var filterIncludeLibs = new List(); var filterExcludeLibs = new List(); + if (filter.Statements != null) { foreach (var stmt in filter.Statements.Where(stmt => stmt.Field == FilterField.Libraries)) @@ -1142,7 +1210,7 @@ public class SeriesRepository : ISeriesRepository private static IQueryable BuildFilterQuery(int userId, FilterV2Dto filterDto, IQueryable query) { - if (filterDto.Statements == null || !filterDto.Statements.Any()) return query; + if (filterDto.Statements == null || filterDto.Statements.Count == 0) return query; var queries = filterDto.Statements @@ -1161,6 +1229,7 @@ public class SeriesRepository : ISeriesRepository private static IQueryable BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable query) { + var value = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value); return statement.Field switch { @@ -1172,20 +1241,25 @@ public class SeriesRepository : ISeriesRepository (IList) value), FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList) value), FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList) value), - FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId), + FilterField.UserRating => query.HasRating(true, statement.Comparison, (float) 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), - FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value), - FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value), + FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Translator), + FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Character), + FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Publisher), + FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Editor), + FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.CoverArtist), + FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Letterer), + FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Inker), + FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Inker), + FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Imprint), + FilterField.Team => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Team), + FilterField.Location => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Location), + FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Penciller), + FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList) value, PersonRole.Writer), 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, @@ -1197,6 +1271,7 @@ public class SeriesRepository : ISeriesRepository FilterField.ReleaseYear => query.HasReleaseYear(true, statement.Comparison, (int) value), FilterField.ReadTime => query.HasAverageReadTime(true, statement.Comparison, (int) value), FilterField.ReadingDate => query.HasReadingDate(true, statement.Comparison, (DateTime) value, userId), + FilterField.ReadLast => query.HasReadLast(true, statement.Comparison, (int) value, userId), FilterField.AverageRating => query.HasAverageRating(true, statement.Comparison, (float) value), _ => throw new ArgumentOutOfRangeException() }; @@ -1213,7 +1288,7 @@ public class SeriesRepository : ISeriesRepository var query = sQuery .WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id))) - .WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id))) + .WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.PersonId))) .WhereIf(hasCollectionTagFilter, s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) .WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId)) @@ -1237,51 +1312,30 @@ 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)) .Include(m => m.People) + .ThenInclude(p => p.Person) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .SingleOrDefaultAsync(); - - if (metadataDto != null) - { - metadataDto.CollectionTags = await _context.CollectionTag - .Include(t => t.SeriesMetadatas) - .Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId)) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .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); } @@ -1473,7 +1527,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { - var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Recommended) + var libraryIds = GetLibraryIdsForUser(userId, libraryId, QueryContext.Dashboard) .Where(id => libraryId == 0 || id == libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); @@ -1554,14 +1608,44 @@ public class SeriesRepository : ISeriesRepository /// /// Return a Series by Folder path. Null if not found. /// - /// This will be normalized in the query + /// This will be normalized in the query and checked against FolderPath and LowestFolderPath /// Additional relationships to include with the base query /// public async Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None) { var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); + if (string.IsNullOrEmpty(normalized)) return null; + return await _context.Series - .Where(s => s.FolderPath != null && s.FolderPath.Equals(normalized)) + .Where(s => (!string.IsNullOrEmpty(s.FolderPath) && s.FolderPath.Equals(normalized) || (!string.IsNullOrEmpty(s.LowestFolderPath) && s.LowestFolderPath.Equals(normalized)))) + .Includes(includes) + .SingleOrDefaultAsync(); + } + + public async Task GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None) + { + // Check if the path ends with a file (has a file extension) + string directoryPath; + if (Path.HasExtension(path)) + { + // Remove the file part and get the directory path + directoryPath = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directoryPath)) return null; + } + else + { + // Use the path as is if it doesn't end with a file + directoryPath = path; + } + + // Normalize the directory path + var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(directoryPath); + if (string.IsNullOrEmpty(normalized)) return null; + + normalized = normalized.TrimEnd('/'); + + return await _context.Series + .Where(s => !string.IsNullOrEmpty(s.LowestFolderPath) && EF.Functions.Like(normalized, s.LowestFolderPath + "%")) .Includes(includes) .SingleOrDefaultAsync(); } @@ -1619,6 +1703,7 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Metadata) .ThenInclude(m => m.People) + .ThenInclude(p => p.Person) .Include(s => s.Metadata) .ThenInclude(m => m.Genres) @@ -1629,6 +1714,7 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(cm => cm.People) + .ThenInclude(p => p.Person) .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) @@ -1644,9 +1730,78 @@ public class SeriesRepository : ISeriesRepository .AsSplitQuery(); return query.SingleOrDefaultAsync(); + #nullable enable } + public async Task GetSeriesByAnyName(string seriesName, string localizedName, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) + { + var libraryIds = GetLibraryIdsForUser(userId); + var normalizedSeries = seriesName.ToNormalized(); + var normalizedLocalized = localizedName.ToNormalized(); + + var query = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value); + } + else + { + // Otherwise, use name checks + query = query.Where(s => + s.NormalizedName.Equals(normalizedSeries) + || s.NormalizedName.Equals(normalizedLocalized) + || s.NormalizedLocalizedName.Equals(normalizedSeries) + || (!string.IsNullOrEmpty(normalizedLocalized) && s.NormalizedLocalizedName.Equals(normalizedLocalized)) + || (s.OriginalName != null && s.OriginalName.Equals(seriesName)) + ); + } + + return await query + .Includes(includes) + .FirstOrDefaultAsync(); + } + + + public async Task GetSeriesByAnyName(IList names, IList formats, + int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None) + { + var libraryIds = GetLibraryIdsForUser(userId); + names = names.Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList(); + var normalizedNames = names.Select(s => s.ToNormalized()).ToList(); + + + var query = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => formats.Contains(s.Format)); + + if (aniListId.HasValue && aniListId.Value > 0) + { + // If AniList ID is provided, override name checks + query = query.Where(s => s.ExternalSeriesMetadata.AniListId == aniListId.Value || + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + else + { + // Otherwise, use name checks + query = query.Where(s => + normalizedNames.Contains(s.NormalizedName) + || normalizedNames.Contains(s.NormalizedLocalizedName) + || names.Contains(s.OriginalName)); + } + + return await query + .Includes(includes) + .FirstOrDefaultAsync(); + } + public async Task> GetAllSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format) { @@ -1676,45 +1831,36 @@ public class SeriesRepository : ISeriesRepository /// public async Task> RemoveSeriesNotInList(IList seenSeries, int libraryId) { - if (seenSeries.Count == 0) return Array.Empty(); + if (!seenSeries.Any()) return Array.Empty(); + + // Get all series from DB in one go, based on libraryId + var dbSeries = await _context.Series + .Where(s => s.LibraryId == libraryId) + .ToListAsync(); + + // Get a set of matching series ids for the given parsedSeries + var ids = new HashSet(); - var ids = new List(); foreach (var parsedSeries in seenSeries) { - try + var matchingSeries = dbSeries + .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName) + .OrderBy(s => s.Id) // Sort to handle potential duplicates + .ToList(); + + // Prefer the first match or handle duplicates by choosing the last one + if (matchingSeries.Count != 0) { - var seriesId = await _context.Series - .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && - s.LibraryId == libraryId) - .Select(s => s.Id) - .SingleOrDefaultAsync(); - if (seriesId > 0) - { - ids.Add(seriesId); - } - } - catch (Exception) - { - // This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them - // This here will delete the 2nd one as the first is the one to likely be used. - var sId = _context.Series - .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && - s.LibraryId == libraryId) - .Select(s => s.Id) - .OrderBy(s => s) - .Last(); - if (sId > 0) - { - ids.Add(sId); - } + ids.Add(matchingSeries.Last().Id); } } - var seriesToRemove = await _context.Series - .Where(s => s.LibraryId == libraryId) + // Filter out series that are not in the seenSeries + var seriesToRemove = dbSeries .Where(s => !ids.Contains(s.Id)) - .ToListAsync(); + .ToList(); + // Remove series in bulk _context.Series.RemoveRange(seriesToRemove); return seriesToRemove; @@ -1817,19 +1963,7 @@ public class SeriesRepository : ISeriesRepository AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting, userRating), AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion, userRating), Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi, userRating), - // Parent = await _context.Series - // .SelectMany(s => - // s.TargetSeries.Where(r => r.TargetSeriesId == seriesId - // && usersSeriesIds.Contains(r.TargetSeriesId) - // && r.RelationKind != RelationKind.Prequel - // && r.RelationKind != RelationKind.Sequel - // && r.RelationKind != RelationKind.Edition) - // .Select(sr => sr.Series)) - // .RestrictAgainstAgeRestriction(userRating) - // .AsSplitQuery() - // .AsNoTracking() - // .ProjectTo(_mapper.ConfigurationProvider) - // .ToListAsync(), + Annuals = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Annual, userRating), Parent = await _context.SeriesRelation .Where(r => r.TargetSeriesId == seriesId && usersSeriesIds.Contains(r.TargetSeriesId) @@ -1891,8 +2025,8 @@ public class SeriesRepository : ISeriesRepository VolumeId = c.VolumeId, ChapterId = c.Id, Format = c.Volume.Series.Format, - ChapterNumber = c.Number, - ChapterRange = c.Range, + ChapterNumber = c.MinNumber + string.Empty, // TODO: Refactor this + ChapterRange = c.Range, // TODO: Refactor this IsSpecial = c.IsSpecial, VolumeNumber = c.Volume.MinNumber, ChapterTitle = c.Title, @@ -1923,17 +2057,25 @@ public class SeriesRepository : ISeriesRepository public async Task> GetWantToReadForUserV2Async(int userId, UserParams userParams, FilterV2Dto filter) { var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); - var query = _context.AppUser + var seriesIds = await _context.AppUser .Where(user => user.Id == userId) .SelectMany(u => u.WantToRead) .Where(s => libraryIds.Contains(s.Series.LibraryId)) - .Select(w => w.Series) + .Select(w => w.Series.Id) + .Distinct() + .ToListAsync(); + + var query = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None); + + // Apply the Want to Read filtering + query = query.Where(s => seriesIds.Contains(s.Id)); + + var retSeries = query + .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() .AsNoTracking(); - var filteredQuery = await CreateFilteredSearchQueryableV2(userId, filter, QueryContext.None, query); - - return await PagedList.CreateAsync(filteredQuery.ProjectTo(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize); + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } public async Task> GetWantToReadForUserAsync(int userId) @@ -1953,9 +2095,6 @@ public class SeriesRepository : ISeriesRepository /// Uses multiple names to find a match against a series. If not, returns null. /// /// This does not restrict to the user at all. That is handled at the API level. - /// - /// - /// public async Task GetSeriesDtoByNamesAndMetadataIds(IEnumerable names, LibraryType libraryType, string aniListUrl, string malUrl) { var libraryIds = await _context.Library @@ -1989,6 +2128,47 @@ public class SeriesRepository : ISeriesRepository .FirstOrDefaultAsync(); // Some users may have improperly configured libraries } + public async Task MatchSeries(ExternalSeriesDetailDto externalSeries) + { + var libraryIds = await _context.Library + .Where(lib => externalSeries.PlusMediaFormat.ConvertToLibraryTypes().Contains(lib.Type)) + .Select(l => l.Id) + .ToListAsync(); + + var normalizedNames = (externalSeries.Synonyms ?? Enumerable.Empty()) + .Prepend(externalSeries.Name) + .Select(n => n.ToNormalized()) + .ToList(); + + var aniListWebLink = + ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, externalSeries.AniListId); + var malWebLink = + ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, externalSeries.MALId); + + Series? result = null; + if (!string.IsNullOrEmpty(aniListWebLink) || !string.IsNullOrEmpty(malWebLink)) + { + result = await _context.Series + .Where(s => !string.IsNullOrEmpty(s.Metadata.WebLinks)) + .Where(s => libraryIds.Contains(s.Library.Id)) + .WhereIf(!string.IsNullOrEmpty(aniListWebLink), s => s.Metadata.WebLinks.Contains(aniListWebLink)) + .WhereIf(!string.IsNullOrEmpty(malWebLink), s => s.Metadata.WebLinks.Contains(malWebLink)) + .Include(s => s.Metadata) + .AsSplitQuery() + .FirstOrDefaultAsync(); + } + + if (result != null) return result; + + return await _context.Series + .Where(s => normalizedNames.Contains(s.NormalizedName) || + normalizedNames.Contains(s.NormalizedLocalizedName)) + .Where(s => libraryIds.Contains(s.Library.Id)) + .AsSplitQuery() + .Include(s => s.Metadata) + .FirstOrDefaultAsync(); // Some users may have improperly configured libraries + } + /// /// Returns the Average rating for all users within Kavita instance /// @@ -2055,15 +2235,17 @@ public class SeriesRepository : ISeriesRepository LastScanned = s.LastFolderScanned, SeriesName = s.Name, FolderPath = s.FolderPath, + LowestFolderPath = s.LowestFolderPath, Format = s.Format, LibraryRoots = s.Library.Folders.Select(f => f.Path) - }).ToListAsync(); + }) + .ToListAsync(); var map = new Dictionary>(); foreach (var series in info) { - if (series.FolderPath == null) continue; - if (!map.ContainsKey(series.FolderPath)) + if (string.IsNullOrEmpty(series.FolderPath)) continue; + if (!map.TryGetValue(series.FolderPath, out var value)) { map.Add(series.FolderPath, new List() { @@ -2072,27 +2254,42 @@ public class SeriesRepository : ISeriesRepository } else { - map[series.FolderPath].Add(series); + value.Add(series); } + + if (string.IsNullOrEmpty(series.LowestFolderPath) || series.FolderPath.Equals(series.LowestFolderPath)) continue; + if (!map.TryGetValue(series.LowestFolderPath, out var value2)) + { + map.Add(series.LowestFolderPath, new List() + { + series + }); + } + else + { + value2.Add(series); + } } return map; } /// - /// 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/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 6d67b36b5..90246e75f 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -1,24 +1,32 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ISettingsRepository { void Update(ServerSetting settings); + void Update(MetadataSettings settings); + void RemoveRange(List fieldMappings); Task GetSettingsDtoAsync(); Task GetSettingAsync(ServerSettingKey key); Task> GetSettingsAsync(); void Remove(ServerSetting setting); Task GetExternalSeriesMetadata(int seriesId); + Task GetMetadataSettings(); + Task GetMetadataSettingDto(); } public class SettingsRepository : ISettingsRepository { @@ -36,6 +44,16 @@ public class SettingsRepository : ISettingsRepository _context.Entry(settings).State = EntityState.Modified; } + public void Update(MetadataSettings settings) + { + _context.Entry(settings).State = EntityState.Modified; + } + + public void RemoveRange(List fieldMappings) + { + _context.MetadataFieldMapping.RemoveRange(fieldMappings); + } + public void Remove(ServerSetting setting) { _context.Remove(setting); @@ -48,6 +66,21 @@ public class SettingsRepository : ISettingsRepository .FirstOrDefaultAsync(); } + public async Task GetMetadataSettings() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .FirstAsync(); + } + + public async Task GetMetadataSettingDto() + { + return await _context.MetadataSettings + .Include(m => m.FieldMappings) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstAsync(); + } + public async Task GetSettingsDtoAsync() { var settings = await _context.ServerSetting diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs index 4e1a01c98..33517e846 100644 --- a/API/Data/Repositories/SiteThemeRepository.cs +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -8,6 +8,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ISiteThemeRepository { @@ -19,6 +20,8 @@ public interface ISiteThemeRepository Task GetThemeDtoByName(string themeName); Task GetDefaultTheme(); Task> GetThemes(); + Task GetTheme(int themeId); + Task IsThemeInUse(int themeId); } public class SiteThemeRepository : ISiteThemeRepository @@ -88,6 +91,19 @@ public class SiteThemeRepository : ISiteThemeRepository .ToListAsync(); } + public async Task GetTheme(int themeId) + { + return await _context.SiteTheme + .Where(t => t.Id == themeId) + .FirstOrDefaultAsync(); + } + + public async Task IsThemeInUse(int themeId) + { + return await _context.AppUserPreferences + .AnyAsync(p => p.Theme.Id == themeId); + } + public async Task GetThemeDto(int themeId) { return await _context.SiteTheme diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 4423c09e3..c4f189957 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -5,11 +5,13 @@ using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable public interface ITagRepository { @@ -19,7 +21,8 @@ public interface ITagRepository Task> GetAllTagsByNameAsync(IEnumerable normalizedNames); Task> GetAllTagDtosAsync(int userId); Task RemoveAllTagNoLongerAssociated(); - Task> GetAllTagDtosForLibrariesAsync(IList libraryIds, int userId); + Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); + Task> GetAllTagsNotInListAsync(ICollection tags); } public class TagRepository : ITagRepository @@ -57,11 +60,18 @@ public class TagRepository : ITagRepository await _context.SaveChangesAsync(); } - public async Task> GetAllTagDtosForLibrariesAsync(IList libraryIds, int userId) + public async Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null) { var userRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + if (libraryIds is {Count: > 0}) + { + userLibs = userLibs.Where(libraryIds.Contains).ToList(); + } + return await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) + .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(userRating) .SelectMany(s => s.Metadata.Tags) .AsSplitQuery() @@ -72,6 +82,28 @@ public class TagRepository : ITagRepository .ToListAsync(); } + public async Task> GetAllTagsNotInListAsync(ICollection tags) + { + // Create a dictionary mapping normalized names to non-normalized names + var normalizedToOriginalMap = tags.Distinct() + .GroupBy(Parser.Normalize) + .ToDictionary(group => group.Key, group => group.First()); + + var normalizedTagNames = normalizedToOriginalMap.Keys.ToList(); + + // Query the database for existing genres using the normalized names + var existingTags = await _context.Tag + .Where(g => normalizedTagNames.Contains(g.NormalizedTitle)) // Assuming you have a normalized field + .Select(g => g.NormalizedTitle) + .ToListAsync(); + + // Find the normalized genres that do not exist in the database + var missingTags = normalizedTagNames.Except(existingTags).ToList(); + + // Return the original non-normalized genres for the missing ones + return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 9515a3f11..ef790f29e 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -7,6 +7,7 @@ using API.DTOs; using API.DTOs.Account; using API.DTOs.Dashboard; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Account; using API.DTOs.Reader; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -15,12 +16,14 @@ using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions.Filtering; +using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable [Flags] public enum AppUserIncludes @@ -38,7 +41,8 @@ public enum AppUserIncludes SmartFilters = 1024, DashboardStreams = 2048, SideNavStreams = 4096, - ExternalSources = 8192 // 2^13 + ExternalSources = 8192, + Collections = 16384 // 2^14 } public interface IUserRepository @@ -53,10 +57,13 @@ public interface IUserRepository void Delete(AppUser? user); void Delete(AppUserBookmark bookmark); void Delete(IEnumerable streams); + void Delete(AppUserDashboardStream stream); void Delete(IEnumerable streams); + void Delete(AppUserSideNavStream stream); 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); @@ -76,9 +83,9 @@ public interface IUserRepository Task> GetAllPreferencesByThemeAsync(int themeId); Task HasAccessToLibrary(int libraryId, int userId); Task HasAccessToSeries(int userId, int seriesId); - Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None); + Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true); 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); @@ -90,10 +97,13 @@ public interface IUserRepository Task> GetDashboardStreamWithFilter(int filterId); Task> GetSideNavStreams(int userId, bool visibleOnly = false); Task GetSideNavStream(int streamId); + Task GetSideNavStreamWithUser(int streamId); Task> GetSideNavStreamWithFilter(int filterId); Task> GetSideNavStreamsByLibraryId(int libraryId); Task> GetSideNavStreamWithExternalSource(int externalSourceId); Task> GetDashboardStreamsByIds(IList streamIds); + Task> GetUserTokenInfo(); + Task GetUserByDeviceEmail(string deviceEmail); } public class UserRepository : IUserRepository @@ -160,11 +170,21 @@ public class UserRepository : IUserRepository _context.AppUserDashboardStream.RemoveRange(streams); } + public void Delete(AppUserDashboardStream stream) + { + _context.AppUserDashboardStream.Remove(stream); + } + public void Delete(IEnumerable streams) { _context.AppUserSideNavStream.RemoveRange(streams); } + public void Delete(AppUserSideNavStream stream) + { + _context.AppUserSideNavStream.Remove(stream); + } + /// /// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags. /// @@ -281,10 +301,17 @@ public class UserRepository : IUserRepository .AnyAsync(s => s.Id == seriesId); } - public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None) + public async Task> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None, bool track = true) { - return await _context.AppUser - .Includes(includeFlags) + var query = _context.AppUser + .Includes(includeFlags); + if (track) + { + return await query.ToListAsync(); + } + + return await query + .AsNoTracking() .ToListAsync(); } @@ -298,11 +325,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) @@ -380,6 +409,7 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task> GetDashboardStreamWithFilter(int filterId) { return await _context.AppUserDashboardStream @@ -416,10 +446,10 @@ public class UserRepository : IUserRepository .Select(d => d.LibraryId) .ToList(); - var libraryDtos = _context.Library + var libraryDtos = await _context.Library .Where(l => libraryIds.Contains(l.Id)) .ProjectTo(_mapper.ConfigurationProvider) - .ToList(); + .ToListAsync(); foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library)) { @@ -443,13 +473,21 @@ public class UserRepository : IUserRepository return sideNavStreams; } - public async Task GetSideNavStream(int streamId) + public async Task GetSideNavStream(int streamId) { return await _context.AppUserSideNavStream .Include(d => d.SmartFilter) .FirstOrDefaultAsync(d => d.Id == streamId); } + public async Task GetSideNavStreamWithUser(int streamId) + { + return await _context.AppUserSideNavStream + .Include(d => d.SmartFilter) + .Include(d => d.AppUser) + .FirstOrDefaultAsync(d => d.Id == streamId); + } + public async Task> GetSideNavStreamWithFilter(int filterId) { return await _context.AppUserSideNavStream @@ -479,10 +517,47 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetUserTokenInfo() + { + var users = await _context.AppUser + .Select(u => new + { + u.Id, + u.UserName, + u.AniListAccessToken, // JWT Token + u.MalAccessToken // JWT Token + }) + .ToListAsync(); + + var userTokenInfos = users.Select(user => new UserTokenInfo + { + UserId = user.Id, + Username = user.UserName, + IsAniListTokenSet = !string.IsNullOrEmpty(user.AniListAccessToken), + AniListValidUntilUtc = JwtHelper.GetTokenExpiry(user.AniListAccessToken), + IsAniListTokenValid = JwtHelper.IsTokenValid(user.AniListAccessToken), + IsMalTokenSet = !string.IsNullOrEmpty(user.MalAccessToken), + }); + + return userTokenInfos; + } + + /// + /// Returns the first user with a device email matching + /// + /// + /// + public async Task GetUserByDeviceEmail(string deviceEmail) + { + return await _context.AppUser + .Where(u => u.Devices.Any(d => d.EmailAddress == deviceEmail)) + .FirstOrDefaultAsync(); + } + 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 +566,23 @@ 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) return ArraySegment.Empty; + + if (_userManager == null) + { + // userManager is null on Unit Tests only + return await _context.UserRoles + .Where(ur => ur.UserId == userId) + .Select(ur => ur.Role.Name) + .ToListAsync(); + } + + return await _userManager.GetRolesAsync(user); + } + public async Task GetUserRatingAsync(int seriesId, int userId) { return await _context.AppUserRating diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 53a45a946..4b07ade96 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Extensions.QueryExtensions; using API.Services; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -13,22 +15,39 @@ using Kavita.Common; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; +#nullable enable + +[Flags] +public enum VolumeIncludes +{ + None = 1, + Chapters = 2, + People = 4, + Tags = 8, + /// + /// This will include Chapters by default + /// + Files = 16 +} public interface IVolumeRepository { void Add(Volume volume); void Update(Volume volume); void Remove(Volume volume); + void Remove(IList volumes); Task> GetFilesForVolume(int volumeId); Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); - Task> GetVolumesDtoAsync(int seriesId, int userId); - Task GetVolumeAsync(int volumeId); + Task> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters); + Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files); Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); + Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None); Task GetVolumeByIdAsync(int volumeId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); + Task> GetCoverImagesForLockedVolumesAsync(); } public class VolumeRepository : IVolumeRepository { @@ -55,6 +74,10 @@ public class VolumeRepository : IVolumeRepository { _context.Volume.Remove(volume); } + public void Remove(IList volumes) + { + _context.Volume.RemoveRange(volumes); + } /// /// Returns a list of non-tracked files for a given volume. @@ -111,9 +134,18 @@ public class VolumeRepository : IVolumeRepository if (includeChapters) { - query = query.Include(v => v.Chapters).AsSplitQuery(); + query = query + .Includes(VolumeIncludes.Chapters) + .AsSplitQuery(); } - return await query.ToListAsync(); + var volumes = await query.ToListAsync(); + + foreach (var volume in volumes) + { + volume.Chapters = volume.Chapters.OrderBy(c => c.SortOrder).ToList(); + } + + return volumes; } /// @@ -126,11 +158,11 @@ public class VolumeRepository : IVolumeRepository { var volume = await _context.Volume .Where(vol => vol.Id == volumeId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) + .Includes(VolumeIncludes.Chapters | VolumeIncludes.Files) .AsSplitQuery() + .OrderBy(v => v.MinNumber) .ProjectTo(_mapper.ConfigurationProvider) - .SingleOrDefaultAsync(vol => vol.Id == volumeId); + .FirstOrDefaultAsync(vol => vol.Id == volumeId); if (volume == null) return null; @@ -149,8 +181,16 @@ public class VolumeRepository : IVolumeRepository { return await _context.Volume .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) + .Includes(VolumeIncludes.Chapters | VolumeIncludes.Files) + .AsSplitQuery() + .OrderBy(vol => vol.MinNumber) + .ToListAsync(); + } + public async Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None) + { + return await _context.Volume + .Where(vol => volumeIds.Contains(vol.Id)) + .Includes(includes) .AsSplitQuery() .OrderBy(vol => vol.MinNumber) .ToListAsync(); @@ -161,11 +201,10 @@ public class VolumeRepository : IVolumeRepository /// /// /// - public async Task GetVolumeAsync(int volumeId) + public async Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files) { return await _context.Volume - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) + .Includes(includes) .AsSplitQuery() .SingleOrDefaultAsync(vol => vol.Id == volumeId); } @@ -177,51 +216,37 @@ public class VolumeRepository : IVolumeRepository /// /// /// - public async Task> GetVolumesDtoAsync(int seriesId, int userId) + public async Task> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters) { var volumes = await _context.Volume .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.People) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Tags) + .Includes(includes) .OrderBy(volume => volume.MinNumber) .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() .AsSplitQuery() .ToListAsync(); await AddVolumeModifiers(userId, volumes); - SortSpecialChapters(volumes); return volumes; } public async Task GetVolumeByIdAsync(int volumeId) { - return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); + return await _context.Volume.FirstOrDefaultAsync(x => x.Id == volumeId); } public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); return await _context.Volume - .Include(v => v.Chapters) + .Includes(VolumeIncludes.Chapters) .Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension)) .AsSplitQuery() .ToListAsync(); } - private static void SortSpecialChapters(IEnumerable volumes) - { - foreach (var v in volumes.Where(vDto => vDto.MinNumber == 0)) - { - v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList(); - } - } - - private async Task AddVolumeModifiers(int userId, IReadOnlyCollection volumes) { var volIds = volumes.Select(s => s.Id); @@ -246,4 +271,17 @@ public class VolumeRepository : IVolumeRepository .Sum(p => p.PagesRead); } } + + /// + /// Returns cover images for locked chapters + /// + /// + public async Task> GetCoverImagesForLockedVolumesAsync() + { + return (await _context.Volume + .Where(c => c.CoverImageLocked) + .Select(c => c.CoverImage) + .Where(t => !string.IsNullOrEmpty(t)) + .ToListAsync())!; + } } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ac1cfb1f1..74bfbb296 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -9,6 +11,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Entities.Enums.Theme; +using API.Entities.MetadataMatching; using API.Extensions; using API.Services; using Kavita.Common; @@ -25,8 +28,8 @@ public static class Seed /// public static ImmutableArray DefaultSettings; - public static readonly ImmutableArray DefaultThemes = ImmutableArray.Create( - new List + public static readonly ImmutableArray DefaultThemes = [ + ..new List { new() { @@ -35,8 +38,10 @@ public static class Seed Provider = ThemeProvider.System, FileName = "dark.scss", IsDefault = true, + Description = "Default theme shipped with Kavita" } - }.ToArray()); + }.ToArray() + ]; public static readonly ImmutableArray DefaultStreams = ImmutableArray.Create( new List @@ -111,6 +116,14 @@ public static class Seed Order = 5, IsProvided = true, Visible = true + }, + new AppUserSideNavStream() + { + Name = "browse-authors", + StreamType = SideNavStreamType.BrowseAuthors, + Order = 6, + IsProvided = true, + Visible = true }); @@ -180,10 +193,10 @@ public static class Seed var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams); foreach (var user in allUsers) { - if (user.SideNavStreams.Count != 0) continue; user.SideNavStreams ??= new List(); foreach (var defaultStream in DefaultSideNavStreams) { + if (user.SideNavStreams.Any(s => s.Name == defaultStream.Name && s.StreamType == defaultStream.StreamType)) continue; var newStream = new AppUserSideNavStream() { Name = defaultStream.Name, @@ -249,11 +262,13 @@ public static class Seed new() {Key = ServerSettingKey.EmailEnableSsl, Value = "true"}, new() {Key = ServerSettingKey.EmailSizeLimit, Value = 26_214_400 + string.Empty}, new() {Key = ServerSettingKey.EmailCustomizedTemplates, Value = "false"}, + new() {Key = ServerSettingKey.FirstInstallVersion, Value = BuildInfo.Version.ToString()}, + new() {Key = ServerSettingKey.FirstInstallDate, Value = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) { - var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + var existing = await context.ServerSetting.FirstOrDefaultAsync(s => s.Key == defaultSetting.Key); if (existing == null) { await context.ServerSetting.AddAsync(defaultSetting); @@ -263,16 +278,51 @@ public static class Seed await context.SaveChangesAsync(); // Port, IpAddresses and LoggingLevel are managed in appSettings.json. Update the DB values to match - context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.Port)).Value = Configuration.Port + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.IpAddresses).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.IpAddresses)).Value = Configuration.IpAddresses; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheDirectory)).Value = directoryService.CacheDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.BackupDirectory).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.BackupDirectory)).Value = DirectoryService.BackupDirectory + string.Empty; - context.ServerSetting.First(s => s.Key == ServerSettingKey.CacheSize).Value = + (await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value = Configuration.CacheSize + string.Empty; + + await context.SaveChangesAsync(); + } + + public static async Task SeedMetadataSettings(DataContext context) + { + await context.Database.EnsureCreatedAsync(); + + var existing = await context.MetadataSettings.FirstOrDefaultAsync(); + if (existing == null) + { + existing = new MetadataSettings() + { + Enabled = true, + EnablePeople = true, + EnableRelationships = true, + EnableSummary = true, + EnablePublicationStatus = true, + EnableStartDate = true, + EnableTags = false, + EnableGenres = true, + EnableLocalizedName = false, + FirstLastPeopleNaming = true, + EnableCoverImage = true, + EnableChapterTitle = false, + EnableChapterSummary = true, + EnableChapterPublisher = true, + EnableChapterCoverImage = false, + EnableChapterReleaseDate = true, + PersonRoles = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character] + }; + await context.MetadataSettings.AddAsync(existing); + } + + await context.SaveChangesAsync(); } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 97ef3e07b..c4a07dee7 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -9,6 +9,7 @@ namespace API.Data; public interface IUnitOfWork { + DataContext DataContext { get; } ISeriesRepository SeriesRepository { get; } IUserRepository UserRepository { get; } ILibraryRepository LibraryRepository { get; } @@ -31,11 +32,13 @@ public interface IUnitOfWork IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + IEmailHistoryRepository EmailHistoryRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); Task RollbackAsync(); } + public class UnitOfWork : IUnitOfWork { private readonly DataContext _context; @@ -47,33 +50,59 @@ public class UnitOfWork : IUnitOfWork _context = context; _mapper = mapper; _userManager = userManager; + + SeriesRepository = new SeriesRepository(_context, _mapper, _userManager); + UserRepository = new UserRepository(_context, _userManager, _mapper); + LibraryRepository = new LibraryRepository(_context, _mapper); + VolumeRepository = new VolumeRepository(_context, _mapper); + SettingsRepository = new SettingsRepository(_context, _mapper); + AppUserProgressRepository = new AppUserProgressRepository(_context, _mapper); + CollectionTagRepository = new CollectionTagRepository(_context, _mapper); + ChapterRepository = new ChapterRepository(_context, _mapper); + ReadingListRepository = new ReadingListRepository(_context, _mapper); + SeriesMetadataRepository = new SeriesMetadataRepository(_context); + PersonRepository = new PersonRepository(_context, _mapper); + GenreRepository = new GenreRepository(_context, _mapper); + TagRepository = new TagRepository(_context, _mapper); + SiteThemeRepository = new SiteThemeRepository(_context, _mapper); + MangaFileRepository = new MangaFileRepository(_context); + DeviceRepository = new DeviceRepository(_context, _mapper); + MediaErrorRepository = new MediaErrorRepository(_context, _mapper); + ScrobbleRepository = new ScrobbleRepository(_context, _mapper); + UserTableOfContentRepository = new UserTableOfContentRepository(_context, _mapper); + AppUserSmartFilterRepository = new AppUserSmartFilterRepository(_context, _mapper); + AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper); + ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); + EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper); } - public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper, _userManager); - public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper); - public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); - - public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); - - public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); - - public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context, _mapper); - public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); - public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper); - public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); - public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context); - public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper); - public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper); - public ITagRepository TagRepository => new TagRepository(_context, _mapper); - public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); - public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context); - public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); - public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper); - public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper); - public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper); - public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper); - public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper); - public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper); + /// + /// This is here for Scanner only. Don't use otherwise. + /// + public DataContext DataContext => _context; + public ISeriesRepository SeriesRepository { get; } + public IUserRepository UserRepository { get; } + public ILibraryRepository LibraryRepository { get; } + public IVolumeRepository VolumeRepository { get; } + public ISettingsRepository SettingsRepository { get; } + public IAppUserProgressRepository AppUserProgressRepository { get; } + public ICollectionTagRepository CollectionTagRepository { get; } + public IChapterRepository ChapterRepository { get; } + public IReadingListRepository ReadingListRepository { get; } + public ISeriesMetadataRepository SeriesMetadataRepository { get; } + public IPersonRepository PersonRepository { get; } + public IGenreRepository GenreRepository { get; } + public ITagRepository TagRepository { get; } + public ISiteThemeRepository SiteThemeRepository { get; } + public IMangaFileRepository MangaFileRepository { get; } + public IDeviceRepository DeviceRepository { get; } + public IMediaErrorRepository MediaErrorRepository { get; } + public IScrobbleRepository ScrobbleRepository { get; } + public IUserTableOfContentRepository UserTableOfContentRepository { get; } + public IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } + public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } + public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } + public IEmailHistoryRepository EmailHistoryRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/EmailTemplates/KavitaPlusDebug.html b/API/EmailTemplates/KavitaPlusDebug.html new file mode 100644 index 000000000..e165dfb98 --- /dev/null +++ b/API/EmailTemplates/KavitaPlusDebug.html @@ -0,0 +1,20 @@ + + +

A User needs manual registration

+ + + +
+ + + + + + +
+ + diff --git a/API/EmailTemplates/TokenExpiration.html b/API/EmailTemplates/TokenExpiration.html new file mode 100644 index 000000000..1162dc75b --- /dev/null +++ b/API/EmailTemplates/TokenExpiration.html @@ -0,0 +1,28 @@ + + +

Your {{Provider}} Token is Expired!

+ + + +

Kavita will stop syncing with {{Provider}} until you renew your token.

+ + + + + +
+ + + + + +
+ + + +

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

+ + diff --git a/API/EmailTemplates/TokenExpiringSoon.html b/API/EmailTemplates/TokenExpiringSoon.html new file mode 100644 index 000000000..960b9b6e5 --- /dev/null +++ b/API/EmailTemplates/TokenExpiringSoon.html @@ -0,0 +1,28 @@ + + +

Your {{Provider}} Token will Expire soon!

+ + + +

Kavita will stop syncing with {{Provider}} until you renew your token.

+ + + + + +
+ + + + + +
+ + + +

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

+ + diff --git a/API/EmailTemplates/base.html b/API/EmailTemplates/base.html index 198a1b211..e06386cc7 100644 --- a/API/EmailTemplates/base.html +++ b/API/EmailTemplates/base.html @@ -504,7 +504,7 @@ - + Wiki diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index f87531e8a..b95cfd260 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,27 @@ 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; } + + /// + /// Has the user ran Scrobble Event Generation + /// + /// Only applicable for Kavita+ and when a Token is present + public bool HasRunScrobbleEventGeneration { get; set; } + /// + /// The timestamp of when Scrobble Event Generation ran (Utc) + /// + /// Kavita+ only + public DateTime ScrobbleEventGenerationRan { 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..2a6d8faff --- /dev/null +++ b/API/Entities/AppUserCollection.cs @@ -0,0 +1,71 @@ +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, IHasCoverImage +{ + 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; } + public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { 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; } + /// + /// Total number of items as of the last sync. Not applicable for Kavita managed collections. + /// + public int TotalSourceCount { get; set; } + /// + /// A
separated string of all missing series + ///
+ public string? MissingSeriesFromSource { get; set; } + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } + + // 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..b728e84e5 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,15 @@ public class AppUserPreferences /// Manga Reader Option: Should swiping trigger pagination ///
public bool SwipeToPaginate { get; set; } + /// + /// Manga Reader Option: Allow Automatic Webtoon detection + /// + public bool AllowAutomaticWebtoonReaderDetection { get; set; } + + #endregion + + #region EpubReader + /// /// Book Reader Option: Override extra Margin /// @@ -75,17 +87,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 +107,33 @@ 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: 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 /// @@ -131,6 +164,18 @@ public class AppUserPreferences /// UI Site Global Setting: The language locale that should be used for the user ///
public string Locale { get; set; } + #endregion + + #region KavitaPlus + /// + /// Should this account have Scrobbling enabled for AniList + /// + public bool AniListScrobblingEnabled { get; set; } + /// + /// Should this account have Want to Read Sync enabled + /// + public bool WantToReadSync { 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..beaf07220 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -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/AppUserRating.cs b/API/Entities/AppUserRating.cs index 91734b445..5d66a06e4 100644 --- a/API/Entities/AppUserRating.cs +++ b/API/Entities/AppUserRating.cs @@ -1,4 +1,6 @@  +using System; + namespace API.Entities; #nullable enable public class AppUserRating @@ -9,7 +11,7 @@ public class AppUserRating ///
public float Rating { get; set; } /// - /// If the rating has been explicitly set. Otherwise the 0.0 rating should be ignored as it's not rated + /// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated /// public bool HasBeenRated { get; set; } /// @@ -19,6 +21,7 @@ public class AppUserRating /// /// An optional tagline for the review /// + [Obsolete("No longer used")] public string? Tagline { get; set; } public int SeriesId { get; set; } public Series Series { get; set; } = null!; diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index ffb88cafb..83a547fd7 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -1,23 +1,43 @@ using System; using System.Collections.Generic; +using System.Globalization; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Person; +using API.Extensions; using API.Services.Tasks.Scanner.Parser; namespace API.Entities; -public class Chapter : IEntityDate, IHasReadTimeEstimate +public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// - /// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If the chapter is a special, will return the Special Name /// public required string Range { get; set; } /// /// Smallest number of the Range. Can be a partial like Chapter 4.5 /// + [Obsolete("Use MinNumber and MaxNumber instead")] public required string Number { get; set; } /// + /// Minimum Chapter Number. + /// + public float MinNumber { get; set; } + /// + /// Maximum Chapter Number + /// + public float MaxNumber { get; set; } + /// + /// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden. + /// + public float SortOrder { get; set; } + /// + /// Can the sort order be updated on scan or is it locked from UI + /// + public bool SortOrderLocked { get; set; } + /// /// The files that represent this Chapter /// public ICollection Files { get; set; } = null!; @@ -26,11 +46,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } - /// - /// Relative path to the (managed) image file representing the cover image - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } /// /// Total number of pages in all MangaFiles @@ -44,6 +62,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate /// Used for books/specials to display custom title. For non-specials/books, will be set to /// public string? Title { get; set; } + /// /// Age Rating for the issue/chapter /// @@ -99,17 +118,43 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate /// public int MaxHoursToRead { get; set; } /// - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } /// /// Comma-separated link of urls to external services that have some relation to the Chapter /// public string WebLinks { get; set; } = string.Empty; public string ISBN { get; set; } = string.Empty; + #region Locks + + public bool AgeRatingLocked { get; set; } + public bool TitleNameLocked { get; set; } + public bool GenresLocked { get; set; } + public bool TagsLocked { get; set; } + public bool WriterLocked { get; set; } + public bool CharacterLocked { get; set; } + public bool ColoristLocked { get; set; } + public bool EditorLocked { get; set; } + public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } + public bool LettererLocked { get; set; } + public bool PencillerLocked { get; set; } + public bool PublisherLocked { get; set; } + public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } + public bool CoverArtistLocked { get; set; } + public bool LanguageLocked { get; set; } + public bool SummaryLocked { get; set; } + public bool ISBNLocked { get; set; } + public bool ReleaseDateLocked { get; set; } + + #endregion + /// /// All people attached at a Chapter level. Usually Comics will have different people per issue. /// - public ICollection People { get; set; } = new List(); + public ICollection People { get; set; } = new List(); /// /// Genres for the Chapter /// @@ -129,11 +174,85 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate IsSpecial = info.IsSpecialInfo(); if (IsSpecial) { - Number = "0"; + Number = Parser.DefaultChapter; + MinNumber = Parser.DefaultChapterNumber; + MaxNumber = Parser.DefaultChapterNumber; } - Title = (IsSpecial && info.Format == MangaFormat.Epub) + Title = (IsSpecial && info.Format is MangaFormat.Epub or MangaFormat.Pdf) ? info.Title - : Range; + : Parser.RemoveExtensionIfSupported(Range); + var specialTreatment = info.IsSpecialInfo(); + Range = specialTreatment ? info.Filename : info.Chapters; + } + + /// + /// Returns the Chapter Number. If the chapter is a range, returns that, formatted. + /// + /// + public string GetNumberTitle() + { + // BUG: TODO: On non-english locales, for floats, the range will be 20,5 but the NumberTitle will return 20.5 + // Have I fixed this with TryParse CultureInvariant + try + { + if (MinNumber.Is(MaxNumber)) + { + if (MinNumber.Is(Parser.DefaultChapterNumber) && IsSpecial) + { + return Parser.RemoveExtensionIfSupported(Title); + } + + if (MinNumber.Is(0f) && !float.TryParse(Range, CultureInfo.InvariantCulture, out _)) + { + return $"{Range.ToString(CultureInfo.InvariantCulture)}"; + } + + return $"{MinNumber.ToString(CultureInfo.InvariantCulture)}"; + + } + + return $"{MinNumber.ToString(CultureInfo.InvariantCulture)}-{MaxNumber.ToString(CultureInfo.InvariantCulture)}"; + } + catch (Exception) + { + return MinNumber.ToString(CultureInfo.InvariantCulture); + } + } + + /// + /// Is the Chapter representing a single Volume (volume 1.cbz). If so, Min/Max will be Default and will not be special + /// + /// + public bool IsSingleVolumeChapter() + { + return MinNumber.Is(Parser.DefaultChapterNumber) && !IsSpecial; + } + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } + + public bool IsPersonRoleLocked(PersonRole role) + { + return role switch + { + PersonRole.Character => CharacterLocked, + PersonRole.Writer => WriterLocked, + PersonRole.Penciller => PencillerLocked, + PersonRole.Inker => InkerLocked, + PersonRole.Colorist => ColoristLocked, + PersonRole.Letterer => LettererLocked, + PersonRole.CoverArtist => CoverArtistLocked, + PersonRole.Editor => EditorLocked, + PersonRole.Publisher => PublisherLocked, + PersonRole.Translator => TranslatorLocked, + PersonRole.Imprint => ImprintLocked, + PersonRole.Team => TeamLocked, + PersonRole.Location => LocationLocked, + _ => throw new ArgumentOutOfRangeException(nameof(role), role, null) + }; } } 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/EmailHistory.cs b/API/Entities/EmailHistory.cs new file mode 100644 index 000000000..f1ab95ca5 --- /dev/null +++ b/API/Entities/EmailHistory.cs @@ -0,0 +1,31 @@ +using System; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities; + +/// +/// Records all emails that are sent from Kavita +/// +[Index("Sent", "AppUserId", "EmailTemplate", "SendDate")] +public class EmailHistory : IEntityDate +{ + public long Id { get; set; } + public bool Sent { get; set; } + public DateTime SendDate { get; set; } = DateTime.UtcNow; + public string EmailTemplate { get; set; } + public string Subject { get; set; } + public string Body { get; set; } + + public string DeliveryStatus { get; set; } + public string ErrorMessage { get; set; } + + public int AppUserId { get; set; } + public virtual AppUser AppUser { get; set; } + + + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModified { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/Entities/Enums/FileTypeGroup.cs b/API/Entities/Enums/FileTypeGroup.cs index 3d33aa37c..eda039fc9 100644 --- a/API/Entities/Enums/FileTypeGroup.cs +++ b/API/Entities/Enums/FileTypeGroup.cs @@ -15,5 +15,4 @@ public enum FileTypeGroup Pdf = 3, [Description("Images")] Images = 4 - } diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index 2be2f9559..b79315803 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -12,7 +12,7 @@ public enum LibraryType /// /// Uses Comic regex for filename parsing /// - [Description("Comic")] + [Description("Comic (Legacy)")] Comic = 1, /// /// Uses Manga regex for filename parsing also uses epub metadata @@ -30,8 +30,13 @@ public enum LibraryType [Description("Light Novel")] LightNovel = 4, /// + /// Uses Comic regex for filename parsing, uses Comic Vine type of Parsing + /// + [Description("Comic")] + ComicVine = 5, + /// /// Uses Magazine regex and is restricted to PDF and Archive by default /// [Description("Magazine")] - Magazine = 5 + Magazine = 6 } diff --git a/API/Entities/Enums/MetadataFieldType.cs b/API/Entities/Enums/MetadataFieldType.cs new file mode 100644 index 000000000..0052b6599 --- /dev/null +++ b/API/Entities/Enums/MetadataFieldType.cs @@ -0,0 +1,7 @@ +namespace API.Entities.Enums; + +public enum MetadataFieldType +{ + Genre = 0, + Tag = 1, +} diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs index bd84985c0..f7ad45021 100644 --- a/API/Entities/Enums/PersonRole.cs +++ b/API/Entities/Enums/PersonRole.cs @@ -24,7 +24,11 @@ public enum PersonRole /// /// The Translator /// - Translator = 12 - - + Translator = 12, + /// + /// The publisher before another Publisher bought + /// + Imprint = 13, + Team = 14, + Location = 15 } diff --git a/API/Entities/Enums/RelationKind.cs b/API/Entities/Enums/RelationKind.cs index aa10e6816..61516ec0d 100644 --- a/API/Entities/Enums/RelationKind.cs +++ b/API/Entities/Enums/RelationKind.cs @@ -71,6 +71,11 @@ public enum RelationKind /// Same story, could be translation, colorization... Different edition of the series /// [Description("Edition")] - Edition = 13 + Edition = 13, + /// + /// The target series is an annual of the Series + /// + [Description("Annual")] + Annual = 14 } diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index be53a105d..b1050d553 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -186,5 +186,15 @@ public enum ServerSettingKey /// When the cleanup task should run - Critical to keeping Kavita working /// [Description("TaskCleanup")] - TaskCleanup = 37 + TaskCleanup = 37, + /// + /// The Date Kavita was first installed + /// + [Description("FirstInstallDate")] + FirstInstallDate = 38, + /// + /// The Version of Kavita on the first run + /// + [Description("FirstInstallVersion")] + FirstInstallVersion = 39, } diff --git a/API/Entities/Enums/Theme/ThemeProvider.cs b/API/Entities/Enums/Theme/ThemeProvider.cs index 45af2d94b..cc12a552e 100644 --- a/API/Entities/Enums/Theme/ThemeProvider.cs +++ b/API/Entities/Enums/Theme/ThemeProvider.cs @@ -10,8 +10,8 @@ public enum ThemeProvider [Description("System")] System = 1, /// - /// Theme is provided by the User (ie it's custom) + /// Theme is provided by the User (ie it's custom) or Downloaded via Themes Repo /// - [Description("User")] - User = 2 + [Description("Custom")] + Custom = 2, } 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/History/KavitaPlusHistory.cs b/API/Entities/History/KavitaPlusHistory.cs new file mode 100644 index 000000000..81b7e5e40 --- /dev/null +++ b/API/Entities/History/KavitaPlusHistory.cs @@ -0,0 +1,9 @@ +namespace API.Entities.History; + +/// +/// Records history of actions Kavita+ takes +/// +// public class KavitaPlusHistory +// { +// +// } diff --git a/API/Entities/ManualMigrationHistory.cs b/API/Entities/History/ManualMigrationHistory.cs similarity index 91% rename from API/Entities/ManualMigrationHistory.cs rename to API/Entities/History/ManualMigrationHistory.cs index e65e07b2c..2f407ca1d 100644 --- a/API/Entities/ManualMigrationHistory.cs +++ b/API/Entities/History/ManualMigrationHistory.cs @@ -1,6 +1,6 @@ using System; -namespace API.Entities; +namespace API.Entities.History; /// /// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed diff --git a/API/Entities/Interfaces/IHasCoverImage.cs b/API/Entities/Interfaces/IHasCoverImage.cs new file mode 100644 index 000000000..5570e37eb --- /dev/null +++ b/API/Entities/Interfaces/IHasCoverImage.cs @@ -0,0 +1,26 @@ +namespace API.Entities.Interfaces; + +#nullable enable + +public interface IHasCoverImage +{ + /// + /// Absolute path to the (managed) image file + /// + /// The file is managed internally to Kavita's APPDIR + public string? CoverImage { get; set; } + + /// + /// Primary color derived from the Cover Image + /// + public string? PrimaryColor { get; set; } + /// + /// Secondary color derived from the Cover Image + /// + public string? SecondaryColor { get; set; } + + /// + /// Nulls out the ColorScape properties + /// + void ResetColorScape(); +} diff --git a/API/Entities/Interfaces/IHasReadTimeEstimate.cs b/API/Entities/Interfaces/IHasReadTimeEstimate.cs index a13c43277..aeb6f6f76 100644 --- a/API/Entities/Interfaces/IHasReadTimeEstimate.cs +++ b/API/Entities/Interfaces/IHasReadTimeEstimate.cs @@ -21,5 +21,5 @@ public interface IHasReadTimeEstimate /// Average hours to read the chapter /// /// Uses a fixed number to calculate from - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } } diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index aa53b6651..abab81378 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -5,11 +5,13 @@ using API.Entities.Interfaces; namespace API.Entities; -public class Library : IEntityDate +public class Library : IEntityDate, IHasCoverImage { public int Id { get; set; } public required string Name { get; set; } public string? CoverImage { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } public LibraryType Type { get; set; } /// /// If Folder Watching is enabled for this library @@ -38,11 +40,14 @@ public class Library : IEntityDate /// /// Should this library allow Scrobble events to emit from it /// - /// Scrobbling requires a valid LicenseKey + /// Requires a valid LicenseKey public bool AllowScrobbling { get; set; } = true; - - - + /// + /// Allow any series within this Library to download metadata. + /// + /// This does not exclude the library from being linked to wrt Series Relationships + /// Requires a valid LicenseKey + public bool AllowMetadataMatching { get; set; } = true; public DateTime Created { get; set; } @@ -78,4 +83,10 @@ public class Library : IEntityDate LastScanned = (DateTime) time; } } + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 14a64fc26..f104f4c72 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -13,6 +13,10 @@ public class MangaFile : IEntityDate { public int Id { get; set; } /// + /// The filename without extension + /// + public string FileName { get; set; } + /// /// Absolute path to the archive file /// public required string FilePath { get; set; } diff --git a/API/Entities/Metadata/ExternalSeriesMetadata.cs b/API/Entities/Metadata/ExternalSeriesMetadata.cs index 215a01585..1ab37ba3c 100644 --- a/API/Entities/Metadata/ExternalSeriesMetadata.cs +++ b/API/Entities/Metadata/ExternalSeriesMetadata.cs @@ -21,11 +21,12 @@ public class ExternalSeriesMetadata public ICollection ExternalRecommendations { get; set; } = null!; /// - /// Average External Rating. -1 means not set + /// Average External Rating. -1 means not set, 0 - 100 /// - public int AverageExternalRating { get; set; } = 0; + public int AverageExternalRating { get; set; } = -1; public int AniListId { get; set; } + public int CbrId { get; set; } public long MalId { get; set; } public string GoogleBooksId { get; set; } diff --git a/API/Entities/Metadata/SeriesBlacklist.cs b/API/Entities/Metadata/SeriesBlacklist.cs index 09ff06153..3d262eeb4 100644 --- a/API/Entities/Metadata/SeriesBlacklist.cs +++ b/API/Entities/Metadata/SeriesBlacklist.cs @@ -5,10 +5,12 @@ namespace API.Entities.Metadata; /// /// A blacklist of Series for Kavita+ /// +[Obsolete("Kavita v0.8.5 moved the implementation to Series.IsBlacklisted")] public class SeriesBlacklist { public int Id { get; set; } + public DateTime LastChecked { get; set; } = DateTime.UtcNow; + public int SeriesId { get; set; } public Series Series { get; set; } - public DateTime LastChecked { get; set; } = DateTime.UtcNow; } diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index f3ccebc93..46e7241f5 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Entities.Metadata; @@ -14,15 +16,6 @@ public class SeriesMetadata : IHasConcurrencyToken public string Summary { get; set; } = string.Empty; - public ICollection CollectionTags { get; set; } = new List(); - - public ICollection Genres { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); - /// - /// All people attached at a Series level. - /// - public ICollection People { get; set; } = new List(); - /// /// Highest Age Rating from all Chapters /// @@ -50,7 +43,8 @@ public class SeriesMetadata : IHasConcurrencyToken /// This is not populated from Chapters of the Series public string WebLinks { get; set; } = string.Empty; - // Locks + #region Locks + public bool LanguageLocked { get; set; } public bool SummaryLocked { get; set; } /// @@ -68,17 +62,36 @@ public class SeriesMetadata : IHasConcurrencyToken public bool ColoristLocked { get; set; } public bool EditorLocked { get; set; } public bool InkerLocked { get; set; } + public bool ImprintLocked { get; set; } public bool LettererLocked { get; set; } public bool PencillerLocked { get; set; } public bool PublisherLocked { get; set; } public bool TranslatorLocked { get; set; } + public bool TeamLocked { get; set; } + public bool LocationLocked { get; set; } public bool CoverArtistLocked { get; set; } public bool ReleaseYearLocked { get; set; } + #endregion + + #region Relationships + + [Obsolete("Use AppUserCollection instead")] + public ICollection CollectionTags { get; set; } = new List(); + + public ICollection Genres { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); + + /// + /// All people attached at a Series level. + /// + public ICollection People { get; set; } = new List(); - // Relationship - public Series Series { get; set; } = null!; public int SeriesId { get; set; } + public Series Series { get; set; } = null!; + + #endregion + /// [ConcurrencyCheck] @@ -90,4 +103,26 @@ public class SeriesMetadata : IHasConcurrencyToken { RowVersion++; } + + /// + /// Any People in this Role present + /// + /// + /// + public bool AnyOfRole(PersonRole role) + { + return People.Any(p => p.Role == role); + } + + /// + /// Are all instances of the role from Kavita+ + /// + /// + /// + public bool AllKavitaPlus(PersonRole role) + { + var people = People.Where(p => p.Role == role); + if (people.Any()) return people.All(p => p.KavitaPlusConnection); + return false; + } } diff --git a/API/Entities/MetadataMatching/MetadataFieldMapping.cs b/API/Entities/MetadataMatching/MetadataFieldMapping.cs new file mode 100644 index 000000000..e7dd88c03 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataFieldMapping.cs @@ -0,0 +1,26 @@ +using API.Entities.Enums; +using API.Entities.MetadataMatching; + +namespace API.Entities; + +public class MetadataFieldMapping +{ + public int Id { get; set; } + public MetadataFieldType SourceType { get; set; } + public MetadataFieldType DestinationType { get; set; } + /// + /// The string in the source + /// + public string SourceValue { get; set; } + /// + /// Write the string as this in the Destination (can also just be the Source) + /// + public string DestinationValue { get; set; } + /// + /// If true, the tag will be Moved over vs Copied over + /// + public bool ExcludeFromSource { get; set; } + + public int MetadataSettingsId { get; set; } + public virtual MetadataSettings MetadataSettings { get; set; } +} diff --git a/API/Entities/MetadataMatching/MetadataSettingField.cs b/API/Entities/MetadataMatching/MetadataSettingField.cs new file mode 100644 index 000000000..9333c269e --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettingField.cs @@ -0,0 +1,31 @@ +namespace API.Entities.MetadataMatching; + +/// +/// Represents which field that can be written to as an override when already locked +/// +public enum MetadataSettingField +{ + #region Series Metadata + Summary = 1, + PublicationStatus = 2, + StartDate = 3, + Genres = 4, + Tags = 5, + LocalizedName = 6, + Covers = 7, + AgeRating = 8, + People = 9, + #endregion + + #region Chapter Metadata + + ChapterTitle = 10, + ChapterSummary = 11, + ChapterReleaseDate = 12, + ChapterPublisher = 13, + ChapterCovers = 14, + + #endregion + + +} diff --git a/API/Entities/MetadataMatching/MetadataSettings.cs b/API/Entities/MetadataMatching/MetadataSettings.cs new file mode 100644 index 000000000..aeb44b619 --- /dev/null +++ b/API/Entities/MetadataMatching/MetadataSettings.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using API.Entities.Enums; + +namespace API.Entities.MetadataMatching; + +/// +/// Handles the metadata settings for Kavita+ +/// +public class MetadataSettings +{ + public int Id { get; set; } + /// + /// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed + /// + public bool Enabled { get; set; } + + #region Series Metadata + + /// + /// Allow the Summary to be written + /// + public bool EnableSummary { get; set; } + /// + /// Allow Publication status to be derived and updated + /// + public bool EnablePublicationStatus { get; set; } + /// + /// Allow Relationships between series to be set + /// + public bool EnableRelationships { get; set; } + /// + /// Allow People to be created (including downloading images) + /// + public bool EnablePeople { get; set; } + /// + /// Allow Start date to be set within the Series + /// + public bool EnableStartDate { get; set; } + /// + /// Allow setting the Localized name + /// + public bool EnableLocalizedName { get; set; } + /// + /// Allow setting the cover image + /// + public bool EnableCoverImage { get; set; } + #endregion + + #region Chapter Metadata + /// + /// Allow Summary to be set within Chapter/Issue + /// + public bool EnableChapterSummary { get; set; } + /// + /// Allow Release Date to be set within Chapter/Issue + /// + public bool EnableChapterReleaseDate { get; set; } + /// + /// Allow Title to be set within Chapter/Issue + /// + public bool EnableChapterTitle { get; set; } + /// + /// Allow Publisher to be set within Chapter/Issue + /// + public bool EnableChapterPublisher { get; set; } + /// + /// Allow setting the cover image for the Chapter/Issue + /// + public bool EnableChapterCoverImage { get; set; } + #endregion + + // Need to handle the Genre/tags stuff + public bool EnableGenres { get; set; } = true; + public bool EnableTags { get; set; } = true; + + /// + /// For Authors and Writers, how should names be stored (Exclusively applied for AniList). This does not affect Character names. + /// + public bool FirstLastPeopleNaming { get; set; } + + /// + /// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching. + /// + public Dictionary AgeRatingMappings { get; set; } + + /// + /// A list of rules that allow mapping a genre/tag to another genre/tag + /// + public List FieldMappings { get; set; } + + /// + /// A list of overrides that will enable writing to locked fields + /// + public List Overrides { get; set; } + + /// + /// Do not allow any Genre/Tag in this list to be written to Kavita + /// + public List Blacklist { get; set; } + + /// + /// Only allow these Tags to be written to Kavita + /// + public List Whitelist { get; set; } + + /// + /// Which Roles to allow metadata downloading for + /// + public List PersonRoles { get; set; } +} diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs deleted file mode 100644 index eeb21d6b1..000000000 --- a/API/Entities/Person.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using API.Entities.Enums; -using API.Entities.Metadata; - -namespace API.Entities; - -public class Person -{ - public int Id { get; set; } - public required string Name { get; set; } - public required string NormalizedName { get; set; } - public required PersonRole Role { get; set; } - - // Relationships - public ICollection SeriesMetadatas { get; set; } = null!; - public ICollection ChapterMetadatas { get; set; } = null!; -} diff --git a/API/Entities/Person/ChapterPeople.cs b/API/Entities/Person/ChapterPeople.cs new file mode 100644 index 000000000..c6a08a7dd --- /dev/null +++ b/API/Entities/Person/ChapterPeople.cs @@ -0,0 +1,23 @@ +using API.Entities.Enums; + +namespace API.Entities.Person; + +public class ChapterPeople +{ + public int ChapterId { get; set; } + public virtual Chapter Chapter { get; set; } + + public int PersonId { get; set; } + public virtual Person Person { get; set; } + + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + + public required PersonRole Role { get; set; } +} diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs new file mode 100644 index 000000000..8eed08f5c --- /dev/null +++ b/API/Entities/Person/Person.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using API.Entities.Interfaces; + +namespace API.Entities.Person; + +public class Person : IHasCoverImage +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string NormalizedName { get; set; } + + //public ICollection Aliases { get; set; } = default!; + + public string? CoverImage { get; set; } + public bool CoverImageLocked { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + + public string Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } + + /// + /// https://metron.cloud/creator/{slug}/ + /// + /// Kavita+ Only + //public long MetronId { get; set; } = 0; + + // Relationships + public ICollection ChapterPeople { get; set; } = new List(); + public ICollection SeriesMetadataPeople { get; set; } = new List(); + + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } +} diff --git a/API/Entities/Person/SeriesMetadataPeople.cs b/API/Entities/Person/SeriesMetadataPeople.cs new file mode 100644 index 000000000..caea10cd6 --- /dev/null +++ b/API/Entities/Person/SeriesMetadataPeople.cs @@ -0,0 +1,24 @@ +using API.Entities.Enums; +using API.Entities.Metadata; + +namespace API.Entities.Person; + +public class SeriesMetadataPeople +{ + public int SeriesMetadataId { get; set; } + public virtual SeriesMetadata SeriesMetadata { get; set; } + + public int PersonId { get; set; } + public virtual Person Person { get; set; } + + /// + /// The source of this connection. If not Kavita, this implies Metadata Download linked this and it can be removed between matches + /// + public bool KavitaPlusConnection { get; set; } = false; + /// + /// A weight that allows lower numbers to sort first + /// + public int OrderWeight { get; set; } + + public required PersonRole Role { get; set; } +} diff --git a/API/Entities/ReadingList.cs b/API/Entities/ReadingList.cs index 857d5bd42..4a11845af 100644 --- a/API/Entities/ReadingList.cs +++ b/API/Entities/ReadingList.cs @@ -10,7 +10,7 @@ namespace API.Entities; /// /// This is a collection of which represent individual chapters and an order. /// -public class ReadingList : IEntityDate +public class ReadingList : IEntityDate, IHasCoverImage { public int Id { get; init; } public required string Title { get; set; } @@ -23,11 +23,9 @@ public class ReadingList : IEntityDate /// Reading lists that are promoted are only done by admins /// public bool Promoted { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } /// @@ -61,4 +59,10 @@ public class ReadingList : IEntityDate // Relationships public int AppUserId { get; set; } public AppUser AppUser { get; set; } = null!; + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } } diff --git a/API/Entities/Scrobble/ScrobbleEvent.cs b/API/Entities/Scrobble/ScrobbleEvent.cs index a02363992..b8708c115 100644 --- a/API/Entities/Scrobble/ScrobbleEvent.cs +++ b/API/Entities/Scrobble/ScrobbleEvent.cs @@ -28,7 +28,7 @@ public class ScrobbleEvent : IEntityDate /// public string? ReviewBody { get; set; } public string? ReviewTitle { get; set; } - public required MediaFormat Format { get; set; } + public required PlusMediaFormat Format { get; set; } /// /// Depends on the ScrobbleEvent if filled in /// diff --git a/API/Entities/Scrobble/ScrobbleEventSortField.cs b/API/Entities/Scrobble/ScrobbleEventSortField.cs index 729ac7fbe..51b3a2146 100644 --- a/API/Entities/Scrobble/ScrobbleEventSortField.cs +++ b/API/Entities/Scrobble/ScrobbleEventSortField.cs @@ -7,5 +7,6 @@ public enum ScrobbleEventSortField LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEventFilter = 6 } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 311255aec..4f06ab0fc 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -6,7 +6,7 @@ using API.Entities.Metadata; namespace API.Entities; -public class Series : IEntityDate, IHasReadTimeEstimate +public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -38,7 +38,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// public DateTime Created { get; set; } /// - /// Whenever a modification occurs. Ie) New volumes, removed volumes, title update, etc + /// Whenever a modification occurs. ex: New volumes, removed volumes, title update, etc /// public DateTime LastModified { get; set; } @@ -64,6 +64,11 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// must be used before setting public string? FolderPath { get; set; } /// + /// Lowest path (that is under library root) that contains all files for the series. + /// + /// must be used before setting + public string? LowestFolderPath { get; set; } + /// /// Last time the folder was scanned /// public DateTime LastFolderScanned { get; set; } @@ -76,6 +81,9 @@ public class Series : IEntityDate, IHasReadTimeEstimate /// public MangaFormat Format { get; set; } = MangaFormat.Unknown; + public string PrimaryColor { get; set; } = string.Empty; + public string SecondaryColor { get; set; } = string.Empty; + public bool SortNameLocked { get; set; } public bool LocalizedNameLocked { get; set; } @@ -93,13 +101,25 @@ public class Series : IEntityDate, IHasReadTimeEstimate public int MinHoursToRead { get; set; } public int MaxHoursToRead { get; set; } - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } + + #region KavitaPlus + /// + /// Do not match the series with any external Metadata service. This will automatically opt it out of scrobbling. + /// + public bool DontMatch { get; set; } + /// + /// If the series was unable to match, it will be blacklisted until a manual metadata match overrides it + /// + public bool IsBlacklisted { get; set; } + #endregion public SeriesMetadata Metadata { get; set; } = null!; public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!; 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 @@ -109,6 +129,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!; @@ -126,4 +148,28 @@ 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; + } + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } + + /// + /// Is this Series capable of Scrobbling + /// + /// This includes if there is no Match/Manual Match needed, the series is blacklisted, or has a NoMatch + /// + public bool WillScrobble() + { + return !IsBlacklisted && !DontMatch; + } } diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs index 3150bf08e..545c630d8 100644 --- a/API/Entities/SideNavStreamType.cs +++ b/API/Entities/SideNavStreamType.cs @@ -10,4 +10,5 @@ public enum SideNavStreamType ExternalSource = 6, AllSeries = 7, WantToRead = 8, + BrowseAuthors = 9 } diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs index 09b348cb8..107dca556 100644 --- a/API/Entities/SiteTheme.cs +++ b/API/Entities/SiteTheme.cs @@ -37,4 +37,30 @@ public class SiteTheme : IEntityDate, ITheme public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } + + #region ThemeBrowser + + /// + /// The Url on the repo to download the file from + /// + public string? GitHubPath { get; set; } + /// + /// Hash of the Css File + /// + public string? ShaHash { get; set; } + /// + /// Pipe (|) separated urls of the images. Empty string if + /// + public string PreviewUrls { get; set; } + // /// + // /// A description about the theme + // /// + public string Description { get; set; } + // /// + // /// Author of the Theme + // /// + public string Author { get; set; } + public string CompatibleVersion { get; set; } + + #endregion } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 2a2e4b29a..5338494e6 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; +using System.Globalization; using API.Entities.Interfaces; +using API.Extensions; +using API.Services.Tasks.Scanner.Parser; namespace API.Entities; -public class Volume : IEntityDate, IHasReadTimeEstimate +public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage { public int Id { get; set; } /// @@ -13,6 +16,10 @@ public class Volume : IEntityDate, IHasReadTimeEstimate /// For Books with Series_index, this will map to the Series Index. public required string Name { get; set; } /// + /// This is just the original Parsed volume number for lookups + /// + public string LookupName { get; set; } + /// /// The minimum number in the Name field in Int form /// /// Removed in v0.7.13.8, this was an int and we need the ability to have 0.5 volumes render on the UI @@ -26,17 +33,16 @@ public class Volume : IEntityDate, IHasReadTimeEstimate /// The maximum number in the Name field (same as Minimum if Name isn't a range) /// public required float MaxNumber { get; set; } - public IList Chapters { get; set; } = null!; public DateTime Created { get; set; } public DateTime LastModified { get; set; } public DateTime CreatedUtc { get; set; } public DateTime LastModifiedUtc { get; set; } - /// - /// Absolute path to the (managed) image file - /// - /// The file is managed internally to Kavita's APPDIR public string? CoverImage { get; set; } + public bool CoverImageLocked { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + /// /// Total pages of all chapters in this volume /// @@ -48,11 +54,32 @@ public class Volume : IEntityDate, IHasReadTimeEstimate public long WordCount { get; set; } public int MinHoursToRead { get; set; } public int MaxHoursToRead { get; set; } - public int AvgHoursToRead { get; set; } + public float AvgHoursToRead { get; set; } // Relationships + public IList Chapters { get; set; } = null!; public Series Series { get; set; } = null!; public int SeriesId { get; set; } + /// + /// Returns the Chapter Number. If the chapter is a range, returns that, formatted. + /// + /// + public string GetNumberTitle() + { + if (MinNumber.Equals(MaxNumber)) + { + return MinNumber.ToString(CultureInfo.InvariantCulture); + } + + return $"{MinNumber.ToString(CultureInfo.InvariantCulture)}-{MaxNumber.ToString(CultureInfo.InvariantCulture)}"; + } + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } + } diff --git a/API/Extensions/AppUserExtensions.cs b/API/Extensions/AppUserExtensions.cs index 07b348c2d..be3d2c064 100644 --- a/API/Extensions/AppUserExtensions.cs +++ b/API/Extensions/AppUserExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Data.Misc; using API.Entities; using API.Helpers; @@ -44,4 +45,13 @@ public static class AppUserExtensions OrderableHelper.ReorderItems(user.SideNavStreams); } + + public static AgeRestriction GetAgeRestriction(this AppUser user) + { + return new AgeRestriction() + { + AgeRating = user.AgeRestriction, + IncludeUnknowns = user.AgeRestrictionIncludeUnknowns, + }; + } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 104bb4fe1..774413e8e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -12,6 +12,7 @@ using API.SignalR.Presence; using Kavita.Common; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -45,16 +46,15 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -66,13 +66,17 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSqLite(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); @@ -80,12 +84,15 @@ public static class ApplicationServiceExtensions services.AddEasyCaching(options => { options.UseInMemory(EasyCacheProfiles.Favicon); - options.UseInMemory(EasyCacheProfiles.License); options.UseInMemory(EasyCacheProfiles.Library); options.UseInMemory(EasyCacheProfiles.RevokedJwt); + options.UseInMemory(EasyCacheProfiles.LocaleOptions); // KavitaPlus stuff options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries); + options.UseInMemory(EasyCacheProfiles.License); + options.UseInMemory(EasyCacheProfiles.LicenseInfo); + options.UseInMemory(EasyCacheProfiles.KavitaPlusMatchSeries); }); services.AddMemoryCache(options => @@ -110,6 +117,8 @@ public static class ApplicationServiceExtensions }); options.EnableDetailedErrors(); options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); }); } } diff --git a/API/Extensions/ChapterListExtensions.cs b/API/Extensions/ChapterListExtensions.cs index 4210b01b6..5456a6e16 100644 --- a/API/Extensions/ChapterListExtensions.cs +++ b/API/Extensions/ChapterListExtensions.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; +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; @@ -23,15 +25,20 @@ 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 /// /// /// public static Chapter? GetChapterByRange(this IEnumerable chapters, ParserInfo info) { + var normalizedPath = Parser.NormalizePath(info.FullFilePath); var specialTreatment = info.IsSpecialInfo(); + // 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 == info.Filename || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath))) - : chapters.FirstOrDefault(c => c.Range == info.Chapters); + ? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath)) + : chapters.FirstOrDefault(c => c.Range == fakeChapter.GetNumberTitle()); } /// @@ -41,6 +48,6 @@ public static class ChapterListExtensions /// public static int MinimumReleaseYear(this IList chapters) { - return chapters.Select(v => v.ReleaseDate.Year).Where(y => NumberHelper.IsValidYear(y)).DefaultIfEmpty().Min(); + return chapters.Select(v => v.ReleaseDate.Year).Where(NumberHelper.IsValidYear).DefaultIfEmpty().Min(); } } diff --git a/API/Extensions/ClaimsPrincipalExtensions.cs b/API/Extensions/ClaimsPrincipalExtensions.cs index 3355a7586..2e86f8bbd 100644 --- a/API/Extensions/ClaimsPrincipalExtensions.cs +++ b/API/Extensions/ClaimsPrincipalExtensions.cs @@ -7,17 +7,23 @@ namespace API.Extensions; public static class ClaimsPrincipalExtensions { + private const string NotAuthenticatedMessage = "User is not authenticated"; + /// + /// Get's the authenticated user's username + /// + /// Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username + /// + /// + /// public static string GetUsername(this ClaimsPrincipal user) { - var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name); - if (userClaim == null) throw new KavitaException("User is not authenticated"); + var userClaim = user.FindFirst(JwtRegisteredClaimNames.Name) ?? throw new KavitaException(NotAuthenticatedMessage); return userClaim.Value; } public static int GetUserId(this ClaimsPrincipal user) { - var userClaim = user.FindFirst(ClaimTypes.NameIdentifier); - if (userClaim == null) throw new KavitaException("User is not authenticated"); + var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage); return int.Parse(userClaim.Value); } } diff --git a/API/Extensions/DoubleExtensions.cs b/API/Extensions/DoubleExtensions.cs new file mode 100644 index 000000000..3deb37ffb --- /dev/null +++ b/API/Extensions/DoubleExtensions.cs @@ -0,0 +1,26 @@ +using System; + +namespace API.Extensions; + +public static class DoubleExtensions +{ + private const float Tolerance = 0.001f; + + /// + /// Used to compare 2 floats together + /// + /// + /// + /// + public static bool Is(this double a, double? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) < Tolerance; + } + + public static bool IsNot(this double a, double? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) > Tolerance; + } +} diff --git a/API/Extensions/FloatExtensions.cs b/API/Extensions/FloatExtensions.cs new file mode 100644 index 000000000..6fa553239 --- /dev/null +++ b/API/Extensions/FloatExtensions.cs @@ -0,0 +1,26 @@ +using System; + +namespace API.Extensions; + +public static class FloatExtensions +{ + private const float Tolerance = 0.001f; + + /// + /// Used to compare 2 floats together + /// + /// + /// + /// + public static bool Is(this float a, float? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) < Tolerance; + } + + public static bool IsNot(this float a, float? b) + { + if (!b.HasValue) return false; + return Math.Abs((float) (a - b)) > Tolerance; + } +} diff --git a/API/Extensions/FlurlExtensions.cs b/API/Extensions/FlurlExtensions.cs new file mode 100644 index 000000000..62d8543b6 --- /dev/null +++ b/API/Extensions/FlurlExtensions.cs @@ -0,0 +1,35 @@ +using System; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; + +namespace API.Extensions; +#nullable enable + +public static class FlurlExtensions +{ + public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license, string? anilistToken = null) + { + return request + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-license-key", license) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-anilist-token", anilistToken ?? string.Empty) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); + } + + public static IFlurlRequest WithBasicHeaders(this string request, string apiKey) + { + return request + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", apiKey) + .WithHeader("x-installId", HashUtil.ServerToken()) + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)); + } +} diff --git a/API/Extensions/ImageExtensions.cs b/API/Extensions/ImageExtensions.cs new file mode 100644 index 000000000..720f572a9 --- /dev/null +++ b/API/Extensions/ImageExtensions.cs @@ -0,0 +1,122 @@ +using System; +using System.IO; +using NetVips; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Image = NetVips.Image; + +namespace API.Extensions; + +public static class ImageExtensions +{ + public static int GetResolution(this Image image) + { + return image.Width * image.Height; + } + + /// + /// Smaller is better + /// + /// + /// + /// + public static float GetMeanSquaredError(this Image img1, Image img2) + { + if (img1.Width != img2.Width || img1.Height != img2.Height) + { + img2.Mutate(x => x.Resize(img1.Width, img1.Height)); + } + + double totalDiff = 0; + for (var y = 0; y < img1.Height; y++) + { + for (var x = 0; x < img1.Width; x++) + { + var pixel1 = img1[x, y]; + var pixel2 = img2[x, y]; + + var diff = Math.Pow(pixel1.R - pixel2.R, 2) + + Math.Pow(pixel1.G - pixel2.G, 2) + + Math.Pow(pixel1.B - pixel2.B, 2); + totalDiff += diff; + } + } + + return (float)(totalDiff / (img1.Width * img1.Height)); + } + + public static float GetSimilarity(this string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Calculate similarity score + return CalculateSimilarity(imagePath1, imagePath2); + } + + /// + /// Determines which image is "better" based on similarity and resolution. + /// + /// Path to first image + /// Path to second image + /// Minimum similarity to consider images similar + /// The path of the better image + public static string GetBetterImage(this string imagePath1, string imagePath2, float similarityThreshold = 0.7f) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + throw new FileNotFoundException("One or both image files do not exist"); + } + + // Calculate similarity score + var similarity = CalculateSimilarity(imagePath1, imagePath2); + + using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); + using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + + var resolution1 = img1.Width * img1.Height; + var resolution2 = img2.Width * img2.Height; + + // If images are similar, choose the one with higher resolution + if (similarity >= similarityThreshold) + { + return resolution1 >= resolution2 ? imagePath1 : imagePath2; + } + + // If images are not similar, allow the new image + return imagePath2; + } + + /// + /// Calculate a similarity score (0-1f) based on resolution difference and MSE. + /// + /// + /// + /// + private static float CalculateSimilarity(string imagePath1, string imagePath2) + { + if (!File.Exists(imagePath1) || !File.Exists(imagePath2)) + { + return -1; + } + + using var img1 = Image.NewFromFile(imagePath1, access: Enums.Access.Sequential); + using var img2 = Image.NewFromFile(imagePath2, access: Enums.Access.Sequential); + + var res1 = img1.Width * img1.Height; + var res2 = img2.Width * img2.Height; + var resolutionDiff = Math.Abs(res1 - res2) / (float)Math.Max(res1, res2); + + using var imgSharp1 = SixLabors.ImageSharp.Image.Load(imagePath1); + using var imgSharp2 = SixLabors.ImageSharp.Image.Load(imagePath2); + + var mse = imgSharp1.GetMeanSquaredError(imgSharp2); + var normalizedMse = 1f - Math.Min(1f, mse / 65025f); // Normalize based on max color diff + + // Final similarity score (weighted) + return Math.Max(0f, 1f - (resolutionDiff * 0.5f) - (1f - normalizedMse) * 0.5f); + } +} diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 58fe6ba52..94eb1c769 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Linq; using API.Entities; using API.Services.Tasks.Scanner.Parser; @@ -27,7 +28,9 @@ public static class ParserInfoListExtensions /// public static bool HasInfo(this IList infos, Chapter chapter) { - return chapter.IsSpecial ? infos.Any(v => v.Filename == chapter.Range) - : infos.Any(v => v.Chapters == chapter.Range); + var chapterFiles = chapter.Files.Select(x => Parser.NormalizePath(x.FilePath)).ToList(); + var infoFiles = infos.Select(x => Parser.NormalizePath(x.FullFilePath)).ToList(); + return infoFiles.Intersect(chapterFiles).Any(); } + } diff --git a/API/Extensions/PlusMediaFormatExtensions.cs b/API/Extensions/PlusMediaFormatExtensions.cs new file mode 100644 index 000000000..a88b9c2f9 --- /dev/null +++ b/API/Extensions/PlusMediaFormatExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using API.DTOs.Scrobbling; +using API.Entities.Enums; + +namespace API.Extensions; + +public static class PlusMediaFormatExtensions +{ + public static PlusMediaFormat ConvertToPlusMediaFormat(this LibraryType libraryType, MangaFormat? seriesFormat = null) + { + + return libraryType switch + { + LibraryType.Manga => seriesFormat is MangaFormat.Epub ? PlusMediaFormat.LightNovel : PlusMediaFormat.Manga, + LibraryType.Comic => PlusMediaFormat.Comic, + LibraryType.LightNovel => PlusMediaFormat.LightNovel, + LibraryType.Book => PlusMediaFormat.LightNovel, + LibraryType.Image => PlusMediaFormat.Manga, + LibraryType.ComicVine => PlusMediaFormat.Comic, + _ => throw new ArgumentOutOfRangeException(nameof(libraryType), libraryType, null) + }; + } + + public static IEnumerable ConvertToLibraryTypes(this PlusMediaFormat plusMediaFormat) + { + return plusMediaFormat switch + { + PlusMediaFormat.Manga => [LibraryType.Manga, LibraryType.Image], + PlusMediaFormat.Comic => [LibraryType.Comic, LibraryType.ComicVine], + PlusMediaFormat.LightNovel => [LibraryType.LightNovel, LibraryType.Book, LibraryType.Manga], + PlusMediaFormat.Book => [LibraryType.LightNovel, LibraryType.Book], + _ => throw new ArgumentOutOfRangeException(nameof(plusMediaFormat), plusMediaFormat, null) + }; + } + + public static IList GetMangaFormats(this PlusMediaFormat? mediaFormat) + { + return mediaFormat.HasValue ? mediaFormat.Value.GetMangaFormats() : [MangaFormat.Archive]; + } + + public static IList GetMangaFormats(this PlusMediaFormat mediaFormat) + { + return mediaFormat switch + { + PlusMediaFormat.Manga => [MangaFormat.Archive, MangaFormat.Image], + PlusMediaFormat.Comic => [MangaFormat.Archive], + PlusMediaFormat.LightNovel => [MangaFormat.Epub, MangaFormat.Pdf], + PlusMediaFormat.Book => [MangaFormat.Epub, MangaFormat.Pdf], + _ => [MangaFormat.Archive] + }; + } + + +} diff --git a/API/Extensions/QueryExtensions/Filtering/BookmarkSort.cs b/API/Extensions/QueryExtensions/Filtering/BookmarkSort.cs index f3dbfef14..030517dbf 100644 --- a/API/Extensions/QueryExtensions/Filtering/BookmarkSort.cs +++ b/API/Extensions/QueryExtensions/Filtering/BookmarkSort.cs @@ -1,6 +1,7 @@ using System.Linq; using API.DTOs.Filtering; using API.Entities; +using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions.Filtering; #nullable enable @@ -39,6 +40,7 @@ public static class BookmarkSort SortField.ReadProgress => query.DoOrderBy(s => s.Series.Progress.Where(p => p.SeriesId == s.Series.Id).Select(p => p.LastModified).Max(), sortOptions), SortField.AverageRating => query.DoOrderBy(s => s.Series.ExternalSeriesMetadata.ExternalRatings .Where(p => p.SeriesId == s.Series.Id).Average(p => p.AverageScore), sortOptions), + SortField.Random => query.DoOrderBy(s => EF.Functions.Random(), sortOptions), _ => query }; diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs new file mode 100644 index 000000000..cc40491d0 --- /dev/null +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using API.Data.Misc; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Metadata; +using API.Entities.Person; +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) + { + // Get people from SeriesMetadata + var peopleFromSeriesMetadata = queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.People) + .Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) + .Select(p => p.Person); + + // Get people from ChapterPeople by navigating through Volume -> Series + var peopleFromChapterPeople = queryable + .Where(sm => seriesIds.Contains(sm.SeriesId)) + .SelectMany(sm => sm.Series.Volumes) + .SelectMany(v => v.Chapters) + .SelectMany(ch => ch.People) + .Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%")) + .Select(cp => cp.Person); + + // Combine both queries and ensure distinct results + return peopleFromSeriesMetadata + .Union(peopleFromChapterPeople) + .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..ad51a4a62 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -14,6 +14,7 @@ namespace API.Extensions.QueryExtensions.Filtering; public static class SeriesFilter { private const float FloatingPointTolerance = 0.001f; + public static IQueryable HasLanguage(this IQueryable queryable, bool condition, FilterComparison comparison, IList languages) { @@ -43,6 +44,7 @@ public static class SeriesFilter case FilterComparison.IsAfter: case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: + case FilterComparison.IsEmpty: default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } @@ -71,6 +73,8 @@ public static class SeriesFilter return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear); case FilterComparison.IsNotInLast: return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Metadata.ReleaseYear == 0); case FilterComparison.Matches: case FilterComparison.Contains: case FilterComparison.NotContains: @@ -86,14 +90,18 @@ public static class SeriesFilter public static IQueryable HasRating(this IQueryable queryable, bool condition, - FilterComparison comparison, int rating, int userId) + FilterComparison comparison, float rating, int userId) { if (rating < 0 || !condition || userId <= 0) return queryable; + // AppUserRating stores a 5-digit number. + rating = Math.Clamp(rating, 0f, 5f); + + switch (comparison) { case FilterComparison.Equal: - return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) < FloatingPointTolerance && r.AppUserId == userId)); + return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) <= FloatingPointTolerance && r.AppUserId == userId)); case FilterComparison.GreaterThan: return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId)); case FilterComparison.GreaterThanEqual: @@ -102,10 +110,13 @@ public static class SeriesFilter return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId)); case FilterComparison.LessThanEqual: return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId)); + case FilterComparison.NotEqual: + return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) >= FloatingPointTolerance && r.AppUserId == userId)); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Ratings.All(r => r.AppUserId != userId)); case FilterComparison.Contains: case FilterComparison.Matches: case FilterComparison.NotContains: - case FilterComparison.NotEqual: case FilterComparison.BeginsWith: case FilterComparison.EndsWith: case FilterComparison.IsBefore: @@ -124,7 +135,7 @@ public static class SeriesFilter { if (!condition || ratings.Count == 0) return queryable; - var firstRating = ratings.First(); + var firstRating = ratings[0]; switch (comparison) { case FilterComparison.Equal: @@ -151,11 +162,13 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.AgeRating"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } } + public static IQueryable HasAverageReadTime(this IQueryable queryable, bool condition, FilterComparison comparison, int avgReadTime) { @@ -164,17 +177,17 @@ public static class SeriesFilter switch (comparison) { case FilterComparison.NotEqual: - return queryable.Where(s => s.AvgHoursToRead != avgReadTime); + return queryable.WhereNotEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.Equal: - return queryable.Where(s => s.AvgHoursToRead == avgReadTime); + return queryable.WhereEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.GreaterThan: - return queryable.Where(s => s.AvgHoursToRead > avgReadTime); + return queryable.WhereGreaterThan(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.GreaterThanEqual: - return queryable.Where(s => s.AvgHoursToRead >= avgReadTime); + return queryable.WhereGreaterThanOrEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.LessThan: - return queryable.Where(s => s.AvgHoursToRead < avgReadTime); + return queryable.WhereLessThan(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.LessThanEqual: - return queryable.Where(s => s.AvgHoursToRead <= avgReadTime); + return queryable.WhereLessThanOrEqual(s => s.AvgHoursToRead, avgReadTime); case FilterComparison.Contains: case FilterComparison.Matches: case FilterComparison.NotContains: @@ -185,6 +198,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -196,7 +210,7 @@ public static class SeriesFilter { if (!condition || pubStatues.Count == 0) return queryable; - var firstStatus = pubStatues.First(); + var firstStatus = pubStatues[0]; switch (comparison) { case FilterComparison.Equal: @@ -219,6 +233,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.Matches: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -237,38 +252,37 @@ public static class SeriesFilter if (!condition) return queryable; var subQuery = queryable - .Include(s => s.Progress) - .Where(s => s.Progress != null) .Select(s => new { - Series = s, - Percentage = ((float) s.Progress + SeriesId = s.Id, + SeriesName = s.Name, + Percentage = s.Progress .Where(p => p != null && p.AppUserId == userId) - .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0) * 100) + .Sum(p => p != null ? (p.PagesRead * 1.0f / s.Pages) : 0f) * 100f }) - .AsSplitQuery() - .AsEnumerable(); + .AsSplitQuery(); switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) < FloatingPointTolerance); + subQuery = subQuery.WhereEqual(s => s.Percentage, readProgress); break; case FilterComparison.GreaterThan: - subQuery = subQuery.Where(s => s.Percentage > readProgress); + subQuery = subQuery.WhereGreaterThan(s => s.Percentage, readProgress); break; case FilterComparison.GreaterThanEqual: - subQuery = subQuery.Where(s => s.Percentage >= readProgress); + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.Percentage, readProgress); break; case FilterComparison.LessThan: - subQuery = subQuery.Where(s => s.Percentage < readProgress); + subQuery = subQuery.WhereLessThan(s => s.Percentage, readProgress); break; case FilterComparison.LessThanEqual: - subQuery = subQuery.Where(s => s.Percentage <= readProgress); + subQuery = subQuery.WhereLessThanOrEqual(s => s.Percentage, readProgress); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance); + subQuery = subQuery.WhereNotEqual(s => s.Percentage, readProgress); break; + case FilterComparison.IsEmpty: case FilterComparison.Matches: case FilterComparison.Contains: case FilterComparison.NotContains: @@ -284,7 +298,7 @@ public static class SeriesFilter throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } - var ids = subQuery.Select(s => s.Series.Id).ToList(); + var ids = subQuery.Select(s => s.SeriesId); return queryable.Where(s => ids.Contains(s.Id)); } @@ -298,31 +312,32 @@ public static class SeriesFilter .Include(s => s.ExternalSeriesMetadata) .Select(s => new { - Series = s, + SeriesId = s.Id, + SeriesName = s.Name, AverageRating = s.ExternalSeriesMetadata.AverageExternalRating }) .AsSplitQuery() - .AsEnumerable(); + .AsQueryable(); switch (comparison) { case FilterComparison.Equal: - subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) < FloatingPointTolerance); + subQuery = subQuery.WhereEqual(s => s.AverageRating, rating); break; case FilterComparison.GreaterThan: - subQuery = subQuery.Where(s => s.AverageRating > rating); + subQuery = subQuery.WhereGreaterThan(s => s.AverageRating, rating); break; case FilterComparison.GreaterThanEqual: - subQuery = subQuery.Where(s => s.AverageRating >= rating); + subQuery = subQuery.WhereGreaterThanOrEqual(s => s.AverageRating, rating); break; case FilterComparison.LessThan: - subQuery = subQuery.Where(s => s.AverageRating < rating); + subQuery = subQuery.WhereLessThan(s => s.AverageRating, rating); break; case FilterComparison.LessThanEqual: - subQuery = subQuery.Where(s => s.AverageRating <= rating); + subQuery = subQuery.WhereLessThanOrEqual(s => s.AverageRating, rating); break; case FilterComparison.NotEqual: - subQuery = subQuery.Where(s => Math.Abs(s.AverageRating - rating) > FloatingPointTolerance); + subQuery = subQuery.WhereNotEqual(s => s.AverageRating, rating); break; case FilterComparison.Matches: case FilterComparison.Contains: @@ -334,12 +349,80 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.AverageRating"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } - var ids = subQuery.Select(s => s.Series.Id).ToList(); + var ids = subQuery.Select(s => s.SeriesId); + return queryable.Where(s => ids.Contains(s.Id)); + } + + /// + /// HasReadingDate but used to filter where last reading point was TODAY() - timeDeltaDays. This allows the user + /// to build smart filters "Haven't read in a month" + /// + public static IQueryable HasReadLast(this IQueryable queryable, bool condition, + FilterComparison comparison, int timeDeltaDays, int userId) + { + if (!condition || timeDeltaDays == 0) return queryable; + + var subQuery = queryable + .Include(s => s.Progress) + .Where(s => s.Progress.Any()) + .Select(s => new + { + SeriesId = s.Id, + SeriesName = s.Name, + MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) + .Select(p => (DateTime?) p.LastModified) + .DefaultIfEmpty() + .Max() + }) + .Where(s => s.MaxDate != null) + .AsSplitQuery() + .AsEnumerable(); + + var date = DateTime.Now.AddDays(-timeDeltaDays); + + switch (comparison) + { + case FilterComparison.Equal: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate.Equals(date)); + break; + case FilterComparison.IsAfter: + case FilterComparison.GreaterThan: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate > date); + break; + case FilterComparison.GreaterThanEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate >= date); + break; + case FilterComparison.IsBefore: + case FilterComparison.LessThan: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate < date); + break; + case FilterComparison.LessThanEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && s.MaxDate <= date); + break; + case FilterComparison.NotEqual: + subQuery = subQuery.Where(s => s.MaxDate != null && !s.MaxDate.Equals(date)); + break; + case FilterComparison.Matches: + case FilterComparison.Contains: + case FilterComparison.NotContains: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.MustContains: + case FilterComparison.IsEmpty: + throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + + var ids = subQuery.Select(s => s.SeriesId); return queryable.Where(s => ids.Contains(s.Id)); } @@ -350,10 +433,11 @@ public static class SeriesFilter var subQuery = queryable .Include(s => s.Progress) - .Where(s => s.Progress != null) + .Where(s => s.Progress.Any()) .Select(s => new { - Series = s, + SeriesId = s.Id, + SeriesName = s.Name, MaxDate = s.Progress.Where(p => p != null && p.AppUserId == userId) .Select(p => (DateTime?) p.LastModified) .DefaultIfEmpty() @@ -393,19 +477,20 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.ReadProgress"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); } - var ids = subQuery.Select(s => s.Series.Id).ToList(); + var ids = subQuery.Select(s => s.SeriesId); return queryable.Where(s => ids.Contains(s.Id)); } public static IQueryable HasTags(this IQueryable queryable, bool condition, FilterComparison comparison, IList tags) { - if (!condition || tags.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && tags.Count == 0)) return queryable; switch (comparison) { @@ -424,6 +509,8 @@ public static class SeriesFilter queries.AddRange(tags.Select(gId => queryable.Where(s => s.Metadata.Tags.Any(p => p.Id == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Metadata.Tags.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -442,6 +529,48 @@ public static class SeriesFilter } public static IQueryable HasPeople(this IQueryable queryable, bool condition, + FilterComparison comparison, IList people, PersonRole role) + { + if (!condition || (comparison != FilterComparison.IsEmpty && people.Count == 0)) return queryable; + + switch (comparison) + { + case FilterComparison.Equal: + case FilterComparison.Contains: + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId) && p.Role == role)); + case FilterComparison.NotEqual: + case FilterComparison.NotContains: + return queryable.Where(s => s.Metadata.People.All(p => !people.Contains(p.PersonId) || p.Role != role)); + case FilterComparison.MustContains: + var queries = new List>() + { + queryable + }; + queries.AddRange(people.Select(personId => + queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == personId && p.Role == role)))); + + return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + // Ensure no person with the given role exists + return queryable.Where(s => s.Metadata.People.All(p => p.Role != role)); + case FilterComparison.GreaterThan: + case FilterComparison.GreaterThanEqual: + case FilterComparison.LessThan: + case FilterComparison.LessThanEqual: + case FilterComparison.BeginsWith: + case FilterComparison.EndsWith: + case FilterComparison.IsBefore: + case FilterComparison.IsAfter: + case FilterComparison.IsInLast: + case FilterComparison.IsNotInLast: + case FilterComparison.Matches: + throw new KavitaException($"{comparison} not applicable for Series.People"); + default: + throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); + } + } + + public static IQueryable HasPeopleLegacy(this IQueryable queryable, bool condition, FilterComparison comparison, IList people) { if (!condition || people.Count == 0) return queryable; @@ -450,19 +579,20 @@ public static class SeriesFilter { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id))); + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId))); case FilterComparison.NotEqual: case FilterComparison.NotContains: - return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.Id))); + return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.PersonId))); case FilterComparison.MustContains: // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId)))); + queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -483,7 +613,7 @@ public static class SeriesFilter public static IQueryable HasGenre(this IQueryable queryable, bool condition, FilterComparison comparison, IList genres) { - if (!condition || genres.Count == 0) return queryable; + if (!condition || (comparison != FilterComparison.IsEmpty && genres.Count == 0)) return queryable; switch (comparison) { @@ -502,6 +632,8 @@ public static class SeriesFilter queries.AddRange(genres.Select(gId => queryable.Where(s => s.Metadata.Genres.Any(p => p.Id == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); + case FilterComparison.IsEmpty: + return queryable.Where(s => s.Metadata.Genres.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -544,6 +676,7 @@ public static class SeriesFilter case FilterComparison.IsAfter: case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.Format"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null); @@ -551,27 +684,30 @@ 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; + if (!condition || (comparison != FilterComparison.IsEmpty && 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.IsEmpty: + return queryable.Where(s => s.Collections.Count == 0); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -632,6 +768,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.Name"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); @@ -655,6 +792,8 @@ public static class SeriesFilter return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%")); case FilterComparison.NotEqual: return queryable.Where(s => s.Metadata.Summary != queryString); + case FilterComparison.IsEmpty: + return queryable.Where(s => string.IsNullOrEmpty(s.Metadata.Summary)); case FilterComparison.NotContains: case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: @@ -702,6 +841,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.FolderPath"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); @@ -778,6 +918,7 @@ public static class SeriesFilter case FilterComparison.IsInLast: case FilterComparison.IsNotInLast: case FilterComparison.MustContains: + case FilterComparison.IsEmpty: throw new KavitaException($"{comparison} not applicable for Series.FolderPath"); default: throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported"); diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs index 4913c4059..d6c7ff77d 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesSort.cs @@ -1,6 +1,7 @@ using System.Linq; using API.DTOs.Filtering; using API.Entities; +using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions.Filtering; #nullable enable @@ -31,10 +32,11 @@ 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), + SortField.Random => query.DoOrderBy(s => EF.Functions.Random(), sortOptions), _ => query }; diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 1250adeae..983f6798e 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(); } @@ -36,6 +53,56 @@ public static class IncludesExtensions .Include(c => c.Files); } + if (includes.HasFlag(ChapterIncludes.People)) + { + queryable = queryable + .Include(c => c.People) + .ThenInclude(cp => cp.Person); + } + + if (includes.HasFlag(ChapterIncludes.Genres)) + { + queryable = queryable + .Include(c => c.Genres); + } + + if (includes.HasFlag(ChapterIncludes.Tags)) + { + queryable = queryable + .Include(c => c.Tags); + } + + return queryable.AsSplitQuery(); + } + + public static IQueryable Includes(this IQueryable queryable, + VolumeIncludes includes) + { + if (includes.HasFlag(VolumeIncludes.Files)) + { + queryable = queryable + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files); + } else if (includes.HasFlag(VolumeIncludes.Chapters)) + { + queryable = queryable + .Include(vol => vol.Chapters); + } + + if (includes.HasFlag(VolumeIncludes.People)) + { + queryable = queryable + .Include(vol => vol.Chapters) + .ThenInclude(c => c.People); + } + + if (includes.HasFlag(VolumeIncludes.Tags)) + { + queryable = queryable + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Tags); + } + return queryable.AsSplitQuery(); } @@ -56,7 +123,7 @@ public static class IncludesExtensions { query = query .Include(s => s.Volumes) - .ThenInclude(v => v.Chapters); + .ThenInclude(v => v.Chapters.OrderBy(c => c.SortOrder)); } if (includeFlags.HasFlag(SeriesIncludes.Related)) @@ -95,17 +162,16 @@ public static class IncludesExtensions if (includeFlags.HasFlag(SeriesIncludes.Metadata)) { - query = query.Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle)) + query = query .Include(s => s.Metadata) .ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(s => s.Metadata) .ThenInclude(m => m.People) + .ThenInclude(smp => smp.Person) .Include(s => s.Metadata) .ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle)); } - return query.AsSplitQuery(); } @@ -139,7 +205,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)) @@ -179,6 +247,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/QueryableExtensions.cs b/API/Extensions/QueryExtensions/QueryableExtensions.cs index 201c8dd28..a2db1dde7 100644 --- a/API/Extensions/QueryExtensions/QueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/QueryableExtensions.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using API.Data.Misc; using API.Data.Repositories; using API.DTOs.Filtering; +using API.DTOs.KavitaPlus.Manage; using API.Entities; using API.Entities.Enums; using API.Entities.Scrobble; @@ -16,6 +17,8 @@ namespace API.Extensions.QueryExtensions; public static class QueryableExtensions { + private const float DefaultTolerance = 0.001f; + public static Task GetUserAgeRestriction(this DbSet queryable, int userId) { if (userId < 1) @@ -79,7 +82,6 @@ public static class QueryableExtensions .Include(l => l.AppUsers) .Where(lib => lib.AppUsers.Any(user => user.Id == userId)) .IsRestricted(queryContext) - .AsNoTracking() .AsSplitQuery() .Select(lib => lib.Id); } @@ -112,18 +114,98 @@ public static class QueryableExtensions return condition ? queryable.Where(predicate) : queryable; } - public static IQueryable WhereLike(this IQueryable queryable, bool condition, Expression> propertySelector, string searchQuery) - where T : class + + public static IQueryable WhereGreaterThan(this IQueryable source, + Expression> selector, + float value) { - if (!condition || string.IsNullOrEmpty(searchQuery)) return queryable; + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; - var method = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[] { typeof(DbFunctions), typeof(string), typeof(string) }); - var dbFunctions = typeof(EF).GetMethod(nameof(EF.Functions))?.Invoke(null, null); - var searchExpression = Expression.Constant($"%{searchQuery}%"); - var likeExpression = Expression.Call(method, Expression.Constant(dbFunctions), propertySelector.Body, searchExpression); - var lambda = Expression.Lambda>(likeExpression, propertySelector.Parameters[0]); + var greaterThanExpression = Expression.GreaterThan(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(greaterThanExpression, parameter); - return queryable.Where(lambda); + return source.Where(lambda); + } + + public static IQueryable WhereGreaterThanOrEqual(this IQueryable source, + Expression> selector, + float value) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var greaterThanExpression = Expression.GreaterThanOrEqual(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(greaterThanExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereLessThan(this IQueryable source, + Expression> selector, + float value) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var lessThanExpression = Expression.LessThan(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(lessThanExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereLessThanOrEqual(this IQueryable source, + Expression> selector, + float value) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var lessThanOrEqualExpression = Expression.LessThanOrEqual(propertyAccess, Expression.Constant(value)); + var lambda = Expression.Lambda>(lessThanOrEqualExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + // Absolute difference comparison: Math.Abs(propertyAccess - value) < tolerance + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var toleranceExpression = Expression.LessThan(absoluteDifference, Expression.Constant(tolerance)); + var lambda = Expression.Lambda>(toleranceExpression, parameter); + + return source.Where(lambda); + } + + public static IQueryable WhereNotEqual(this IQueryable source, + Expression> selector, + float value, + float tolerance = DefaultTolerance) + { + var parameter = selector.Parameters[0]; + var propertyAccess = selector.Body; + + var difference = Expression.Subtract(propertyAccess, Expression.Constant(value)); + var absoluteDifference = Expression.Condition( + Expression.LessThan(difference, Expression.Constant(0f)), + Expression.Negate(difference), + difference); + + var toleranceExpression = Expression.GreaterThan(absoluteDifference, Expression.Constant(tolerance)); + var lambda = Expression.Lambda>(toleranceExpression, parameter); + + return source.Where(lambda); } /// @@ -173,6 +255,7 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderByDescending(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderByDescending(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderByDescending(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderByDescending(s => s.ScrobbleEventType), _ => query }; } @@ -185,6 +268,7 @@ public static class QueryableExtensions ScrobbleEventSortField.Type => query.OrderBy(s => s.ScrobbleEventType), ScrobbleEventSortField.Series => query.OrderBy(s => s.Series.NormalizedName), ScrobbleEventSortField.IsProcessed => query.OrderBy(s => s.IsProcessed), + ScrobbleEventSortField.ScrobbleEventFilter => query.OrderBy(s => s.ScrobbleEventType), _ => query }; } @@ -200,4 +284,21 @@ public static class QueryableExtensions { return sortOptions.IsAscending ? query.OrderBy(keySelector) : query.OrderByDescending(keySelector); } + + public static IQueryable FilterMatchState(this IQueryable query, MatchStateOption stateOption) + { + return stateOption switch + { + MatchStateOption.All => query, + MatchStateOption.Matched => query + .Include(s => s.ExternalSeriesMetadata) + .Where(s => s.ExternalSeriesMetadata != null && s.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue && !s.IsBlacklisted), + MatchStateOption.NotMatched => query. + Include(s => s.ExternalSeriesMetadata) + .Where(s => (s.ExternalSeriesMetadata == null || s.ExternalSeriesMetadata.ValidUntilUtc == DateTime.MinValue) && !s.IsBlacklisted && !s.DontMatch), + MatchStateOption.Error => query.Where(s => s.IsBlacklisted && !s.DontMatch), + MatchStateOption.DontMatch => query.Where(s => s.DontMatch), + _ => query + }; + } } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 8101c9d35..fc3314f58 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -1,7 +1,9 @@ -using System.Linq; +using System; +using System.Linq; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; namespace API.Extensions.QueryExtensions; #nullable enable @@ -24,6 +26,20 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(chapter => chapter.Volume.Series.Metadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.Volume.Series.Metadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + + [Obsolete] public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -38,6 +54,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; @@ -72,12 +102,15 @@ public static class RestrictByAgeExtensions if (restriction.IncludeUnknowns) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating) || + c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => + c.SeriesMetadataPeople.Any(sm => sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating != AgeRating.Unknown) || + c.ChapterPeople.Any(cp => cp.Chapter.AgeRating <= restriction.AgeRating && cp.Chapter.AgeRating != AgeRating.Unknown) + ); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) diff --git a/API/Extensions/SeriesExtensions.cs b/API/Extensions/SeriesExtensions.cs index 5db96a30c..01ae718c7 100644 --- a/API/Extensions/SeriesExtensions.cs +++ b/API/Extensions/SeriesExtensions.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Linq; +using System.Linq; using API.Comparators; using API.Entities; using API.Services.Tasks.Scanner.Parser; @@ -19,13 +17,25 @@ public static class SeriesExtensions public static string? GetCoverImage(this Series series) { var volumes = (series.Volumes ?? []) - .OrderBy(v => v.MinNumber, ChapterSortComparer.Default) + .OrderBy(v => v.MinNumber, ChapterSortComparerDefaultLast.Default) .ToList(); var firstVolume = volumes.GetCoverImage(series.Format); if (firstVolume == null) return null; + // If first volume here is specials, move to the next as specials should almost always be last. + if (firstVolume.MinNumber.Is(Parser.SpecialVolumeNumber) && volumes.Count > 1) + { + firstVolume = volumes[1]; + } + + // If the first volume is 0, then use Volume 1 + if (firstVolume.MinNumber.Is(0f) && volumes.Count > 1) + { + firstVolume = volumes[1]; + } + var chapters = firstVolume.Chapters - .OrderBy(c => c.Number.AsDouble(), ChapterSortComparerZeroFirst.Default) + .OrderBy(c => c.SortOrder) .ToList(); if (chapters.Count > 1 && chapters.Exists(c => c.IsSpecial)) @@ -34,32 +44,42 @@ public static class SeriesExtensions } // just volumes - if (volumes.TrueForAll(v => $"{v.MinNumber}" != Parser.DefaultVolume)) + if (volumes.TrueForAll(v => v.MinNumber.IsNot(Parser.LooseLeafVolumeNumber))) { return firstVolume.CoverImage; } // If we have loose leaf chapters // if loose leaf chapters AND volumes, just return first volume - if (volumes.Count >= 1 && $"{volumes[0].MinNumber}" != Parser.DefaultVolume) + if (volumes.Count >= 1 && volumes[0].MinNumber.IsNot(Parser.LooseLeafVolumeNumber)) { - var looseLeafChapters = volumes.Where(v => $"{v.MinNumber}" == Parser.DefaultVolume) - .SelectMany(c => c.Chapters.Where(c => !c.IsSpecial)) - .OrderBy(c => c.Number.AsDouble(), ChapterSortComparerZeroFirst.Default) + var looseLeafChapters = volumes.Where(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)) + .SelectMany(c => c.Chapters.Where(c2 => !c2.IsSpecial)) + .OrderBy(c => c.SortOrder) .ToList(); - if (looseLeafChapters.Count > 0 && (1.0f * volumes[0].MinNumber) > looseLeafChapters[0].Number.AsFloat()) + + if (looseLeafChapters.Count > 0 && volumes[0].MinNumber > looseLeafChapters[0].MinNumber) { + var first = looseLeafChapters.Find(c => c.SortOrder.Is(1f)); + if (first != null) return first.CoverImage; return looseLeafChapters[0].CoverImage; } return firstVolume.CoverImage; } - var firstLooseLeafChapter = volumes - .Where(v => $"{v.MinNumber}" == Parser.DefaultVolume) - .SelectMany(v => v.Chapters) - .OrderBy(c => c.Number.AsDouble(), ChapterSortComparerZeroFirst.Default) - .FirstOrDefault(c => !c.IsSpecial); + var chpts = volumes + .First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber)) + .Chapters + .Where(c => !c.IsSpecial) + .OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default) + .ToList(); - return firstLooseLeafChapter?.CoverImage ?? firstVolume.CoverImage; + var exactlyChapter1 = chpts.Find(c => c.MinNumber.Is(1f)); + if (exactlyChapter1 != null) + { + return exactlyChapter1.CoverImage; + } + + return chpts.FirstOrDefault()?.CoverImage ?? firstVolume.CoverImage; } } diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs index 802c4bca4..28419921a 100644 --- a/API/Extensions/StringExtensions.cs +++ b/API/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Text.RegularExpressions; namespace API.Extensions; @@ -10,6 +11,23 @@ public static class StringExtensions RegexOptions.ExplicitCapture | RegexOptions.Compiled, Services.Tasks.Scanner.Parser.Parser.RegexTimeout); + public static string Sanitize(this string input) + { + if (string.IsNullOrEmpty(input)) + return string.Empty; + + // Remove all newline and control characters + var sanitized = input + .Replace(Environment.NewLine, string.Empty) + .Replace("\n", string.Empty) + .Replace("\r", string.Empty); + + // Optionally remove other potentially unwanted characters + sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII + + return sanitized.Trim(); // Trim any leading/trailing whitespace + } + public static string SentenceCase(this string value) { return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper()); diff --git a/API/Extensions/VersionExtensions.cs b/API/Extensions/VersionExtensions.cs new file mode 100644 index 000000000..1877b48b1 --- /dev/null +++ b/API/Extensions/VersionExtensions.cs @@ -0,0 +1,17 @@ +using System; + +namespace API.Extensions; + +public static class VersionExtensions +{ + public static bool CompareWithoutRevision(this Version v1, Version v2) + { + if (v1.Major != v2.Major) + return v1.Major == v2.Major; + if (v1.Minor != v2.Minor) + return v1.Minor == v2.Minor; + if (v1.Build != v2.Build) + return v1.Build == v2.Build; + return true; + } +} diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index f1ef051b1..a5febb1ff 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -1,6 +1,10 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using API.Comparators; +using API.DTOs; using API.Entities; using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; @@ -21,18 +25,68 @@ public static class VolumeListExtensions { if (volumes == null) throw new ArgumentException("Volumes cannot be null"); - if (seriesFormat == MangaFormat.Epub || seriesFormat == MangaFormat.Pdf) + if (seriesFormat is MangaFormat.Epub or MangaFormat.Pdf) { return volumes.MinBy(x => x.MinNumber); } - - if (volumes.Any(x => x.MinNumber != 0f)) // TODO: Refactor this so we can avoid a magic number + if (volumes.HasAnyNonLooseLeafVolumes()) { - return volumes.OrderBy(x => x.MinNumber).FirstOrDefault(x => x.MinNumber != 0); + return volumes.FirstNonLooseLeafOrDefault(); } // We only have 1 volume of chapters, we need to be cautious if there are specials, as we don't want to order them first return volumes.MinBy(x => x.MinNumber); } + + /// + /// If the collection of volumes has any non-loose leaf volumes + /// + /// + /// + public static bool HasAnyNonLooseLeafVolumes(this IEnumerable volumes) + { + return volumes.Any(v => v.MinNumber.IsNot(Parser.DefaultChapterNumber)); + } + + /// + /// Returns first non-loose leaf volume + /// + /// + /// + public static Volume? FirstNonLooseLeafOrDefault(this IEnumerable volumes) + { + return volumes.OrderBy(x => x.MinNumber, ChapterSortComparerDefaultLast.Default) + .FirstOrDefault(v => v.MinNumber.IsNot(Parser.DefaultChapterNumber)); + } + + /// + /// Returns the first (and only) loose leaf volume or null if none + /// + /// + /// + public static Volume? GetLooseLeafVolumeOrDefault(this IEnumerable volumes) + { + return volumes.FirstOrDefault(v => v.MinNumber.Is(Parser.DefaultChapterNumber)); + } + + /// + /// Returns the first (and only) special volume or null if none + /// + /// + /// + public static Volume? GetSpecialVolumeOrDefault(this IEnumerable volumes) + { + return volumes.FirstOrDefault(v => v.MinNumber.Is(Parser.SpecialVolumeNumber)); + } + + public static IEnumerable WhereNotLooseLeaf(this IEnumerable volumes) + { + return volumes.Where(v => v.MinNumber.Is(Parser.DefaultChapterNumber)); + } + + public static IEnumerable WhereLooseLeaf(this IEnumerable volumes) + { + return volumes.Where(v => v.MinNumber.Is(Parser.DefaultChapterNumber)); + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 3f96fd344..69ed884fd 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,15 +1,21 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; 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; +using API.DTOs.Email; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; +using API.DTOs.KavitaPlus.Manage; +using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; @@ -18,16 +24,21 @@ using API.DTOs.Search; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.DTOs.SideNav; +using API.DTOs.Stats; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Entities.Scrobble; using API.Extensions.QueryExtensions.Filtering; using API.Helpers.Converters; using API.Services; using AutoMapper; using CollectionTag = API.Entities.CollectionTag; +using EmailHistory = API.Entities.EmailHistory; +using ExternalSeriesMetadata = API.Entities.Metadata.ExternalSeriesMetadata; using MediaError = API.Entities.MediaError; using PublicationStatus = API.Entities.Enums.PublicationStatus; using SiteTheme = API.Entities.SiteTheme; @@ -47,11 +58,16 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.Series, opt => opt.MapFrom(src => src.Series)); CreateMap(); CreateMap() - .ForMember(dest => dest.Number, opt => opt.MapFrom(src => src.MinNumber)); + .ForMember(dest => dest.Number, + opt => opt.MapFrom(src => (int) src.MinNumber)) + .ForMember(dest => dest.Chapters, + opt => opt.MapFrom(src => src.Chapters.OrderBy(c => c.SortOrder))); CreateMap(); - CreateMap(); CreateMap(); CreateMap(); + CreateMap() + .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); CreateMap(); CreateMap(); CreateMap(); @@ -87,91 +103,149 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom( src => src.PagesRead)); + CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom( - src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom( - src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))) + // Map Writers + .ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Writer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map CoverArtists + .ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.CoverArtist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Publishers + .ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Publisher) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Characters + .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Character) + .OrderBy(cp => cp.OrderWeight) + .Select(cp => cp.Person))) + // Map Pencillers + .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Penciller) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Inkers + .ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Inker) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Imprints + .ForMember(dest => dest.Imprints, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Imprint) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Colorists + .ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Colorist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Letterers + .ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Letterer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Editors + .ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Editor) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Translators + .ForMember(dest => dest.Translators, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Translator) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Teams + .ForMember(dest => dest.Teams, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Team) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Locations + .ForMember(dest => dest.Locations, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Location) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Genres, 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( src => src.Tags.OrderBy(p => p.NormalizedTitle))); - CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))); + CreateMap() + // Map Writers + .ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Writer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map CoverArtists + .ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.CoverArtist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Publishers + .ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Publisher) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Characters + .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Character) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Pencillers + .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Penciller) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Inkers + .ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Inker) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Imprints + .ForMember(dest => dest.Imprints, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Imprint) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Colorists + .ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Colorist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Letterers + .ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Letterer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Editors + .ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Editor) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Translators + .ForMember(dest => dest.Translators, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Translator) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Teams + .ForMember(dest => dest.Teams, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Team) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Locations + .ForMember(dest => dest.Locations, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Location) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))); + CreateMap() .ForMember(dest => dest.AgeRestriction, @@ -182,7 +256,10 @@ public class AutoMapperProfiles : Profile IncludeUnknowns = src.AgeRestrictionIncludeUnknowns })); - CreateMap(); + CreateMap() + .ForMember(dest => dest.PreviewUrls, + opt => + opt.MapFrom(src => (src.PreviewUrls ?? string.Empty).Split('|', StringSplitOptions.TrimEntries))); CreateMap() .ForMember(dest => dest.Theme, opt => @@ -197,9 +274,12 @@ public class AutoMapperProfiles : Profile CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); CreateMap(); CreateMap(); + CreateMap(); + CreateMap(); CreateMap() .ForMember(dest => dest.SeriesId, @@ -258,8 +338,43 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.BodyJustText, opt => - opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); + opt.MapFrom(src => ReviewHelper.GetCharacters(src.Body))); CreateMap(); + CreateMap() + .ForMember(dest => dest.Series, + opt => + opt.MapFrom(src => src)) + .ForMember(dest => dest.IsMatched, + opt => + opt.MapFrom(src => src.ExternalSeriesMetadata != null && src.ExternalSeriesMetadata.AniListId != 0 + && src.ExternalSeriesMetadata.ValidUntilUtc > DateTime.MinValue)) + .ForMember(dest => dest.ValidUntilUtc, + opt => opt.MapFrom(src => + src.ExternalSeriesMetadata != null + ? src.ExternalSeriesMetadata.ValidUntilUtc + : DateTime.MinValue)); + + + CreateMap(); + CreateMap() + .ForMember(dest => dest.ToUserName, opt => opt.MapFrom(src => src.AppUser.UserName)); + + CreateMap() + .ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Volume.SeriesId)) + .ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name)) + .ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId)) + .ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type)); + + CreateMap(); + + CreateMap() + .ForMember(dest => dest.Blacklist, opt => opt.MapFrom(src => src.Blacklist ?? new List())) + .ForMember(dest => dest.Whitelist, opt => opt.MapFrom(src => src.Whitelist ?? new List())) + .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List())) + .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary())); + + + } } diff --git a/API/Helpers/Builders/AppUserBuilder.cs b/API/Helpers/Builders/AppUserBuilder.cs index bc044c301..282361e41 100644 --- a/API/Helpers/Builders/AppUserBuilder.cs +++ b/API/Helpers/Builders/AppUserBuilder.cs @@ -61,4 +61,10 @@ public class AppUserBuilder : IEntityBuilder return this; } + public AppUserBuilder WithRole(string role) + { + _appUser.UserRoles ??= new List(); + _appUser.UserRoles.Add(new AppUserRole() {Role = new AppRole() {Name = role}}); + return this; + } } diff --git a/API/Helpers/Builders/AppUserCollectionBuilder.cs b/API/Helpers/Builders/AppUserCollectionBuilder.cs new file mode 100644 index 000000000..e9bdcf977 --- /dev/null +++ b/API/Helpers/Builders/AppUserCollectionBuilder.cs @@ -0,0 +1,78 @@ +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; + } + + public AppUserCollectionBuilder WithSourceUrl(string url) + { + _collection.SourceUrl = url; + return this; + } +} diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index b95fa21f0..f85c21595 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using API.Entities; using API.Entities.Enums; +using API.Entities.Person; using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -17,24 +19,29 @@ public class ChapterBuilder : IEntityBuilder { _chapter = new Chapter() { - Range = string.IsNullOrEmpty(range) ? number : range, + Range = string.IsNullOrEmpty(range) ? number : Parser.RemoveExtensionIfSupported(range), Title = string.IsNullOrEmpty(range) ? number : range, Number = Parser.MinNumberFromRange(number).ToString(CultureInfo.InvariantCulture), - Files = new List(), - Pages = 1 + MinNumber = Parser.MinNumberFromRange(number), + MaxNumber = Parser.MaxNumberFromRange(number), + SortOrder = Parser.MinNumberFromRange(number), + Files = [], + Pages = 1, + CreatedUtc = DateTime.UtcNow }; } public static ChapterBuilder FromParserInfo(ParserInfo info) { var specialTreatment = info.IsSpecialInfo(); - var specialTitle = specialTreatment ? info.Filename : info.Chapters; + var specialTitle = specialTreatment ? Parser.RemoveExtensionIfSupported(info.Filename) : info.Chapters; var builder = new ChapterBuilder(Parser.DefaultChapter); - return builder.WithNumber(specialTreatment ? Parser.DefaultChapter : Parser.MinNumberFromRange(info.Chapters) + string.Empty) + + return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)!) .WithRange(specialTreatment ? info.Filename : info.Chapters) - .WithTitle((specialTreatment && info.Format == MangaFormat.Epub) + .WithTitle(specialTreatment && info.Format is MangaFormat.Epub or MangaFormat.Pdf ? info.Title - : specialTitle) + : specialTitle ?? string.Empty) .WithIsSpecial(specialTreatment); } @@ -44,9 +51,18 @@ public class ChapterBuilder : IEntityBuilder return this; } - public ChapterBuilder WithNumber(string number) + + private ChapterBuilder WithNumber(string number) { _chapter.Number = number; + _chapter.MinNumber = Parser.MinNumberFromRange(number); + _chapter.MaxNumber = Parser.MaxNumberFromRange(number); + return this; + } + + public ChapterBuilder WithSortOrder(float order) + { + _chapter.SortOrder = order; return this; } @@ -62,9 +78,9 @@ public class ChapterBuilder : IEntityBuilder return this; } - private ChapterBuilder WithRange(string range) + public ChapterBuilder WithRange(string range) { - _chapter.Range = range; + _chapter.Range = Parser.RemoveExtensionIfSupported(range); return this; } @@ -127,4 +143,17 @@ public class ChapterBuilder : IEntityBuilder _chapter.CreatedUtc = created.ToUniversalTime(); return this; } + + public ChapterBuilder WithPerson(Person person, PersonRole role) + { + _chapter.People ??= new List(); + _chapter.People.Add(new ChapterPeople() + { + Person = person, + Role = role, + Chapter = _chapter, + }); + + return this; + } } 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/ExternalSeriesMetadataBuilder.cs b/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs new file mode 100644 index 000000000..e716f5927 --- /dev/null +++ b/API/Helpers/Builders/ExternalSeriesMetadataBuilder.cs @@ -0,0 +1,26 @@ +using System; +using API.Entities.Metadata; + +namespace API.Helpers.Builders; + +public class ExternalSeriesMetadataBuilder : IEntityBuilder +{ + private readonly ExternalSeriesMetadata _metadata; + public ExternalSeriesMetadata Build() => _metadata; + + public ExternalSeriesMetadataBuilder() + { + _metadata = new ExternalSeriesMetadata(); + } + + /// + /// -1 for not set, Range 0 - 100 + /// + /// + /// + public ExternalSeriesMetadataBuilder WithAverageExternalRating(int rating) + { + _metadata.AverageExternalRating = Math.Clamp(rating, -1, 100); + return this; + } +} diff --git a/API/Helpers/Builders/GenreBuilder.cs b/API/Helpers/Builders/GenreBuilder.cs index 69e68f6c1..9b2f1590e 100644 --- a/API/Helpers/Builders/GenreBuilder.cs +++ b/API/Helpers/Builders/GenreBuilder.cs @@ -16,14 +16,14 @@ public class GenreBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } public GenreBuilder WithSeriesMetadata(SeriesMetadata seriesMetadata) { - _genre.SeriesMetadatas ??= new List(); + _genre.SeriesMetadatas ??= []; _genre.SeriesMetadatas.Add(seriesMetadata); return this; } diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 1cfd529a1..30e6136a5 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -20,8 +20,26 @@ public class LibraryBuilder : IEntityBuilder Series = new List(), Folders = new List(), AppUsers = new List(), - AllowScrobbling = type is LibraryType.LightNovel or LibraryType.Manga + AllowScrobbling = type is LibraryType.LightNovel or LibraryType.Manga, + LibraryFileTypes = new List() }; + + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Archive + }); + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Epub + }); + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Images + }); + _library.LibraryFileTypes.Add(new LibraryFileTypeGroup() + { + FileTypeGroup = FileTypeGroup.Pdf + }); } public LibraryBuilder(Library library) @@ -86,7 +104,13 @@ public class LibraryBuilder : IEntityBuilder return this; } - public LibraryBuilder WIthAllowScrobbling(bool allowScrobbling) + public LibraryBuilder WithAllowMetadataMatching(bool allow) + { + _library.AllowMetadataMatching = allow; + return this; + } + + public LibraryBuilder WithAllowScrobbling(bool allowScrobbling) { _library.AllowScrobbling = allowScrobbling; return this; diff --git a/API/Helpers/Builders/MangaFileBuilder.cs b/API/Helpers/Builders/MangaFileBuilder.cs index f07dc4a37..5387a3349 100644 --- a/API/Helpers/Builders/MangaFileBuilder.cs +++ b/API/Helpers/Builders/MangaFileBuilder.cs @@ -2,6 +2,7 @@ using System.IO; using API.Entities; using API.Entities.Enums; +using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -14,11 +15,12 @@ public class MangaFileBuilder : IEntityBuilder { _mangaFile = new MangaFile() { - FilePath = filePath, + FilePath = Parser.NormalizePath(filePath), Format = format, Pages = pages, LastModified = File.GetLastWriteTime(filePath), LastModifiedUtc = File.GetLastWriteTimeUtc(filePath), + FileName = Parser.RemoveExtensionIfSupported(filePath) }; } diff --git a/API/Helpers/Builders/MediaErrorBuilder.cs b/API/Helpers/Builders/MediaErrorBuilder.cs index 56b19ba33..4d0f7f3a0 100644 --- a/API/Helpers/Builders/MediaErrorBuilder.cs +++ b/API/Helpers/Builders/MediaErrorBuilder.cs @@ -1,5 +1,6 @@ using System.IO; using API.Entities; +using API.Services.Tasks.Scanner.Parser; namespace API.Helpers.Builders; @@ -12,7 +13,7 @@ public class MediaErrorBuilder : IEntityBuilder { _mediaError = new MediaError() { - FilePath = filePath, + FilePath = Parser.NormalizePath(filePath), Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant() }; } diff --git a/API/Helpers/Builders/PersonBuilder.cs b/API/Helpers/Builders/PersonBuilder.cs index e7e1b573e..492d79e17 100644 --- a/API/Helpers/Builders/PersonBuilder.cs +++ b/API/Helpers/Builders/PersonBuilder.cs @@ -2,6 +2,7 @@ using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; namespace API.Helpers.Builders; @@ -11,15 +12,14 @@ public class PersonBuilder : IEntityBuilder private readonly Person _person; public Person Build() => _person; - public PersonBuilder(string name, PersonRole role) + public PersonBuilder(string name) { _person = new Person() { Name = name.Trim(), NormalizedName = name.ToNormalized(), - Role = role, - ChapterMetadatas = new List(), - SeriesMetadatas = new List() + SeriesMetadataPeople = new List(), + ChapterPeople = new List() }; } @@ -34,10 +34,10 @@ public class PersonBuilder : IEntityBuilder return this; } - public PersonBuilder WithSeriesMetadata(SeriesMetadata metadata) + public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) { - _person.SeriesMetadatas ??= new List(); - _person.SeriesMetadatas.Add(metadata); + _person.SeriesMetadataPeople.Add(seriesMetadataPeople); return this; } + } diff --git a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs index 6a8e70bde..3da217b9f 100644 --- a/API/Helpers/Builders/PlusSeriesDtoBuilder.cs +++ b/API/Helpers/Builders/PlusSeriesDtoBuilder.cs @@ -2,14 +2,15 @@ using API.DTOs; using API.DTOs.Scrobbling; using API.Entities; +using API.Extensions; using API.Services.Plus; namespace API.Helpers.Builders; -public class PlusSeriesDtoBuilder : IEntityBuilder +public class PlusSeriesDtoBuilder : IEntityBuilder { - private readonly PlusSeriesDto _seriesDto; - public PlusSeriesDto Build() => _seriesDto; + private readonly PlusSeriesRequestDto _seriesRequestDto; + public PlusSeriesRequestDto Build() => _seriesRequestDto; /// /// This must be a FULL Series @@ -17,9 +18,9 @@ public class PlusSeriesDtoBuilder : IEntityBuilder /// public PlusSeriesDtoBuilder(Series series) { - _seriesDto = new PlusSeriesDto() + _seriesRequestDto = new PlusSeriesRequestDto() { - MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type), + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), SeriesName = series.Name, AltSeriesName = series.LocalizedName, AniListId = ScrobblingService.ExtractId(series.Metadata.WebLinks, diff --git a/API/Helpers/Builders/SeriesBuilder.cs b/API/Helpers/Builders/SeriesBuilder.cs index 5d5ce9f51..96e820659 100644 --- a/API/Helpers/Builders/SeriesBuilder.cs +++ b/API/Helpers/Builders/SeriesBuilder.cs @@ -21,12 +21,16 @@ public class SeriesBuilder : IEntityBuilder _series = new Series() { Name = name, + LocalizedName = name.ToNormalized(), + NormalizedLocalizedName = name.ToNormalized(), + OriginalName = name, SortName = name, NormalizedName = name.ToNormalized(), - NormalizedLocalizedName = name.ToNormalized(), - Metadata = new SeriesMetadataBuilder().Build(), + Metadata = new SeriesMetadataBuilder() + .WithPublicationStatus(PublicationStatus.OnGoing) + .Build(), Volumes = new List(), ExternalSeriesMetadata = new ExternalSeriesMetadata() }; @@ -37,14 +41,25 @@ public class SeriesBuilder : IEntityBuilder /// /// /// - public SeriesBuilder WithLocalizedName(string localizedName) + public SeriesBuilder WithLocalizedName(string localizedName, bool lockStatus = false) { + // Why is this here? if (string.IsNullOrEmpty(localizedName)) { localizedName = _series.Name; } + _series.LocalizedName = localizedName; _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; + return this; + } + + public SeriesBuilder WithLocalizedNameAllowEmpty(string localizedName, bool lockStatus = false) + { + _series.LocalizedName = localizedName; + _series.NormalizedLocalizedName = localizedName.ToNormalized(); + _series.LocalizedNameLocked = lockStatus; return this; } @@ -90,4 +105,29 @@ public class SeriesBuilder : IEntityBuilder _series.LibraryId = id; return this; } + + public SeriesBuilder WithPublicationStatus(PublicationStatus status) + { + _series.Metadata.PublicationStatus = status; + return this; + } + + public SeriesBuilder WithExternalMetadata(ExternalSeriesMetadata metadata) + { + _series.ExternalSeriesMetadata = metadata; + return this; + } + + + public SeriesBuilder WithRelationship(int targetSeriesId, RelationKind kind) + { + _series.Relations ??= []; + _series.Relations.Add(new SeriesRelation() + { + RelationKind = kind, + TargetSeriesId = targetSeriesId + }); + + return this; + } } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index d90e896ef..8ceb16d95 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; namespace API.Helpers.Builders; @@ -17,16 +19,19 @@ public class SeriesMetadataBuilder : IEntityBuilder CollectionTags = new List(), Genres = new List(), Tags = new List(), - People = new List() + People = new List() }; } + [Obsolete] public SeriesMetadataBuilder WithCollectionTag(CollectionTag tag) { _seriesMetadata.CollectionTags ??= new List(); _seriesMetadata.CollectionTags.Add(tag); return this; } + + [Obsolete] public SeriesMetadataBuilder WithCollectionTags(IList tags) { if (tags == null) return this; @@ -34,15 +39,73 @@ public class SeriesMetadataBuilder : IEntityBuilder _seriesMetadata.CollectionTags = tags; return this; } - public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status) + + public SeriesMetadataBuilder WithPublicationStatus(PublicationStatus status, bool lockState = false) { _seriesMetadata.PublicationStatus = status; + _seriesMetadata.PublicationStatusLocked = lockState; return this; } - public SeriesMetadataBuilder WithAgeRating(AgeRating rating) + public SeriesMetadataBuilder WithAgeRating(AgeRating rating, bool lockState = false) { _seriesMetadata.AgeRating = rating; + _seriesMetadata.AgeRatingLocked = lockState; + return this; + } + + public SeriesMetadataBuilder WithPerson(Person person, PersonRole role) + { + _seriesMetadata.People ??= new List(); + _seriesMetadata.People.Add(new SeriesMetadataPeople() + { + Role = role, + Person = person, + SeriesMetadata = _seriesMetadata, + }); + return this; + } + + public SeriesMetadataBuilder WithLanguage(string languageCode) + { + _seriesMetadata.Language = languageCode; + return this; + } + + public SeriesMetadataBuilder WithReleaseYear(int year, bool lockStatus = false) + { + _seriesMetadata.ReleaseYear = year; + _seriesMetadata.ReleaseYearLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithSummary(string summary, bool lockStatus = false) + { + _seriesMetadata.Summary = summary; + _seriesMetadata.SummaryLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenre(Genre genre, bool lockStatus = false) + { + _seriesMetadata.Genres ??= []; + _seriesMetadata.Genres.Add(genre); + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithGenres(List genres, bool lockStatus = false) + { + _seriesMetadata.Genres = genres; + _seriesMetadata.GenresLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithTag(Tag tag, bool lockStatus = false) + { + _seriesMetadata.Tags ??= []; + _seriesMetadata.Tags.Add(tag); + _seriesMetadata.TagsLocked = lockStatus; return this; } } diff --git a/API/Helpers/Builders/TagBuilder.cs b/API/Helpers/Builders/TagBuilder.cs index 084171f54..623587fd1 100644 --- a/API/Helpers/Builders/TagBuilder.cs +++ b/API/Helpers/Builders/TagBuilder.cs @@ -16,8 +16,8 @@ public class TagBuilder : IEntityBuilder { Title = name.Trim().SentenceCase(), NormalizedTitle = name.ToNormalized(), - Chapters = new List(), - SeriesMetadatas = new List() + Chapters = [], + SeriesMetadatas = [] }; } diff --git a/API/Helpers/Builders/VolumeBuilder.cs b/API/Helpers/Builders/VolumeBuilder.cs index 158a84bfa..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; @@ -15,6 +16,7 @@ public class VolumeBuilder : IEntityBuilder _volume = new Volume() { Name = volumeNumber, + LookupName = volumeNumber, MinNumber = Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber), MaxNumber = Services.Tasks.Scanner.Parser.Parser.MaxNumberFromRange(volumeNumber), Chapters = new List() @@ -49,7 +51,7 @@ public class VolumeBuilder : IEntityBuilder return this; } - public VolumeBuilder WithChapters(List chapters) + public VolumeBuilder WithChapters(IList chapters) { _volume.Chapters = chapters; return this; @@ -74,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/CacheHelper.cs b/API/Helpers/CacheHelper.cs index 510ff5409..ede5caaef 100644 --- a/API/Helpers/CacheHelper.cs +++ b/API/Helpers/CacheHelper.cs @@ -14,8 +14,8 @@ public interface ICacheHelper bool CoverImageExists(string path); - bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile); - bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile); + bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile? firstFile); + bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile? firstFile); } @@ -56,7 +56,7 @@ public class CacheHelper : ICacheHelper /// /// /// - public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile) + public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile? firstFile) { return firstFile != null && (!forceUpdate && @@ -71,7 +71,7 @@ public class CacheHelper : ICacheHelper /// Should we ignore any logic and force this to return true /// The file in question /// - public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile) + public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile? firstFile) { if (firstFile == null) return false; if (forceUpdate) return true; diff --git a/API/Helpers/Converters/FilterFieldValueConverter.cs b/API/Helpers/Converters/FilterFieldValueConverter.cs index 09e1421ab..631332f5f 100644 --- a/API/Helpers/Converters/FilterFieldValueConverter.cs +++ b/API/Helpers/Converters/FilterFieldValueConverter.cs @@ -18,66 +18,95 @@ public static class FilterFieldValueConverter FilterField.SeriesName => value, FilterField.Path => value, FilterField.FilePath => value, - FilterField.ReleaseYear => int.Parse(value), + FilterField.ReleaseYear => string.IsNullOrEmpty(value) ? 0 : int.Parse(value), FilterField.Languages => value.Split(',').ToList(), FilterField.PublicationStatus => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x)) .ToList(), FilterField.Summary => value, FilterField.AgeRating => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x)) .ToList(), - FilterField.UserRating => int.Parse(value), + FilterField.UserRating => string.IsNullOrEmpty(value) ? 0 : float.Parse(value), FilterField.Tags => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.CollectionTags => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Translators => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Characters => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Publisher => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Editor => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.CoverArtist => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Letterer => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Colorist => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Inker => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) + .Select(int.Parse) + .ToList(), + FilterField.Imprint => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) + .Select(int.Parse) + .ToList(), + FilterField.Team => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) + .Select(int.Parse) + .ToList(), + FilterField.Location => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Penciller => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Writers => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Genres => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.Libraries => value.Split(',') + .Where(s => !string.IsNullOrEmpty(s)) .Select(int.Parse) .ToList(), FilterField.WantToRead => bool.Parse(value), - FilterField.ReadProgress => value.AsFloat(), - FilterField.ReadingDate => DateTime.Parse(value), + FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), + FilterField.ReadingDate => DateTime.Parse(value, CultureInfo.InvariantCulture), + FilterField.ReadLast => int.Parse(value), FilterField.Formats => value.Split(',') .Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x)) .ToList(), - FilterField.ReadTime => int.Parse(value), - FilterField.AverageRating => float.Parse(value), + FilterField.ReadTime => string.IsNullOrEmpty(value) ? 0 : int.Parse(value), + FilterField.AverageRating => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(), _ => throw new ArgumentException("Invalid field type") }; } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index c356bb907..7adb5228f 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -33,7 +34,7 @@ public class ServerSettingConverter : ITypeConverter, destination.LoggingLevel = row.Value; break; case ServerSettingKey.Port: - destination.Port = int.Parse(row.Value); + destination.Port = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.IpAddresses: destination.IpAddresses = row.Value; @@ -53,11 +54,8 @@ public class ServerSettingConverter : ITypeConverter, case ServerSettingKey.InstallVersion: destination.InstallVersion = row.Value; break; - case ServerSettingKey.EncodeMediaAs: - destination.EncodeMediaAs = Enum.Parse(row.Value); - break; case ServerSettingKey.TotalBackups: - destination.TotalBackups = int.Parse(row.Value); + destination.TotalBackups = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.InstallId: destination.InstallId = row.Value; @@ -66,33 +64,36 @@ public class ServerSettingConverter : ITypeConverter, destination.EnableFolderWatching = bool.Parse(row.Value); break; case ServerSettingKey.TotalLogs: - destination.TotalLogs = int.Parse(row.Value); + destination.TotalLogs = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.HostName: destination.HostName = row.Value; break; case ServerSettingKey.CacheSize: - destination.CacheSize = long.Parse(row.Value); + destination.CacheSize = long.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.OnDeckProgressDays: - destination.OnDeckProgressDays = int.Parse(row.Value); + destination.OnDeckProgressDays = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.OnDeckUpdateDays: - destination.OnDeckUpdateDays = int.Parse(row.Value); + destination.OnDeckUpdateDays = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.CoverImageSize: destination.CoverImageSize = Enum.Parse(row.Value); break; + case ServerSettingKey.EncodeMediaAs: + destination.EncodeMediaAs = Enum.Parse(row.Value); + break; case ServerSettingKey.BackupDirectory: destination.BookmarksDirectory = row.Value; break; case ServerSettingKey.EmailHost: destination.SmtpConfig ??= new SmtpConfigDto(); - destination.SmtpConfig.Host = row.Value; + destination.SmtpConfig.Host = row.Value ?? string.Empty; break; case ServerSettingKey.EmailPort: destination.SmtpConfig ??= new SmtpConfigDto(); - destination.SmtpConfig.Port = string.IsNullOrEmpty(row.Value) ? 0 : int.Parse(row.Value); + destination.SmtpConfig.Port = string.IsNullOrEmpty(row.Value) ? 0 : int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.EmailAuthPassword: destination.SmtpConfig ??= new SmtpConfigDto(); @@ -116,12 +117,25 @@ public class ServerSettingConverter : ITypeConverter, break; case ServerSettingKey.EmailSizeLimit: destination.SmtpConfig ??= new SmtpConfigDto(); - destination.SmtpConfig.SizeLimit = int.Parse(row.Value); + destination.SmtpConfig.SizeLimit = int.Parse(row.Value, CultureInfo.InvariantCulture); break; case ServerSettingKey.EmailCustomizedTemplates: destination.SmtpConfig ??= new SmtpConfigDto(); destination.SmtpConfig.CustomizedTemplates = bool.Parse(row.Value); break; + case ServerSettingKey.FirstInstallDate: + destination.FirstInstallDate = DateTime.Parse(row.Value, CultureInfo.InvariantCulture); + break; + case ServerSettingKey.FirstInstallVersion: + destination.FirstInstallVersion = row.Value; + break; + case ServerSettingKey.LicenseKey: + case ServerSettingKey.EnableAuthentication: + case ServerSettingKey.EmailServiceUrl: + case ServerSettingKey.ConvertBookmarkToWebP: + case ServerSettingKey.ConvertCoverToWebP: + default: + break; } } diff --git a/API/Helpers/DayOfWeekHelper.cs b/API/Helpers/DayOfWeekHelper.cs new file mode 100644 index 000000000..10cdb4170 --- /dev/null +++ b/API/Helpers/DayOfWeekHelper.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.Helpers; + +public static class DayOfWeekHelper +{ + private static readonly Random Rnd = new(); + + /// + /// Returns a random DayOfWeek value. + /// + /// A randomly selected DayOfWeek. + public static DayOfWeek Random() + { + var values = Enum.GetValues(); + return (DayOfWeek)values.GetValue(Rnd.Next(values.Length))!; + } +} diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index 721981054..8580178d9 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -1,104 +1,131 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using API.Data; using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Helpers.Builders; +using Microsoft.EntityFrameworkCore; namespace API.Helpers; #nullable enable + public static class GenreHelper { - public static void UpdateGenre(ICollection allGenres, IEnumerable names, Action action) - { - foreach (var name in names) - { - if (string.IsNullOrEmpty(name.Trim())) continue; - var normalizedName = name.ToNormalized(); - var genre = allGenres.FirstOrDefault(p => p.NormalizedTitle != null && p.NormalizedTitle.Equals(normalizedName)); - if (genre == null) + public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames, IUnitOfWork unitOfWork) + { + // Normalize genre names once and store them in a hash set for quick lookups + var normalizedToOriginal = genreNames + .Select(g => new { Original = g, Normalized = g.ToNormalized() }) + .GroupBy(x => x.Normalized) + .ToDictionary(g => g.Key, g => g.First().Original); + + var normalizedGenresToAdd = new HashSet(normalizedToOriginal.Keys); + + // Remove genres that are no longer in the new list + var genresToRemove = chapter.Genres + .Where(g => !normalizedGenresToAdd.Contains(g.NormalizedTitle)) + .ToList(); + + if (genresToRemove.Count > 0) + { + foreach (var genreToRemove in genresToRemove) { - genre = new GenreBuilder(name).Build(); - allGenres.Add(genre); + chapter.Genres.Remove(genreToRemove); } - - action(genre); } - } + // Get all normalized titles to query the database for existing genres + var existingGenreTitles = await unitOfWork.DataContext.Genre + .Where(g => normalizedGenresToAdd.Contains(g.NormalizedTitle)) + .ToDictionaryAsync(g => g.NormalizedTitle); - public static void KeepOnlySameGenreBetweenLists(ICollection existingGenres, ICollection removeAllExcept, Action? action = null) - { - var existing = existingGenres.ToList(); - foreach (var genre in existing) + // Find missing genres that are not in the database + var missingGenres = normalizedGenresToAdd + .Where(nt => !existingGenreTitles.ContainsKey(nt)) + .Select(nt => new GenreBuilder(normalizedToOriginal[nt]).Build()) + .ToList(); + + // Add missing genres to the database + if (missingGenres.Count > 0) { - var existingPerson = removeAllExcept.FirstOrDefault(g => genre.NormalizedTitle != null && genre.NormalizedTitle.Equals(g.NormalizedTitle)); - if (existingPerson != null) continue; - existingGenres.Remove(genre); - action?.Invoke(genre); + unitOfWork.DataContext.Genre.AddRange(missingGenres); + await unitOfWork.CommitAsync(); + + // Add newly inserted genres to existing genres dictionary for easier lookup + foreach (var genre in missingGenres) + { + existingGenreTitles[genre.NormalizedTitle] = genre; + } } - } - - /// - /// Adds the genre to the list if it's not already in there. - /// - /// - /// - public static void AddGenreIfNotExists(ICollection metadataGenres, Genre genre) - { - var existingGenre = metadataGenres.FirstOrDefault(p => - p.NormalizedTitle.Equals(genre.Title?.ToNormalized())); - if (existingGenre == null) + // Add genres that are either existing or newly added to the chapter + foreach (var normalizedTitle in normalizedGenresToAdd) { - metadataGenres.Add(genre); + var genre = existingGenreTitles[normalizedTitle]; + + if (!chapter.Genres.Contains(genre)) + { + chapter.Genres.Add(genre); + } } } - - public static void UpdateGenreList(ICollection? tags, Series series, - IReadOnlyCollection allTags, Action handleAdd, Action onModified) + public static void UpdateGenreList(ICollection? existingGenres, Series series, + IReadOnlyCollection newGenres, Action handleAdd, Action onModified) { - if (tags == null) return; + UpdateGenreList(existingGenres.DefaultIfEmpty().Select(t => t.Title).ToList(), series, newGenres, handleAdd, onModified); + } + + public static void UpdateGenreList(ICollection? existingGenres, Series series, + IReadOnlyCollection newGenres, Action handleAdd, Action onModified) + { + if (existingGenres == null) return; + var isModified = false; - // 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.Genres.ToList(); + + // Convert tags and existing genres to hash sets for quick lookups by normalized title + var tagSet = new HashSet(existingGenres.Select(t => t.ToNormalized())); + var genreSet = new HashSet(series.Metadata.Genres.Select(g => g.NormalizedTitle)); + + // Remove tags that are no longer present in the input tags + var existingTags = series.Metadata.Genres.ToList(); // Copy to avoid modifying collection while iterating foreach (var existing in existingTags) { - if (tags.SingleOrDefault(t => t.Title.ToNormalized().Equals(existing.NormalizedTitle)) == null) + if (!tagSet.Contains(existing.NormalizedTitle)) // This correctly ensures removal of non-present tags { - // Remove tag series.Metadata.Genres.Remove(existing); isModified = true; } } - // At this point, all tags that aren't in dto have been removed. - foreach (var tagTitle in tags.Select(t => t.Title)) + // Prepare a dictionary for quick lookup of genres from the `newGenres` collection by normalized title + var allTagsDict = newGenres.ToDictionary(t => t.NormalizedTitle); + + // Add new tags from the input list + foreach (var tagDto in existingGenres) { - var normalizedTitle = tagTitle.ToNormalized(); - var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle)); - if (existingTag != null) + var normalizedTitle = tagDto.ToNormalized(); + + if (genreSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres + + if (allTagsDict.TryGetValue(normalizedTitle, out var existingTag)) { - if (series.Metadata.Genres.All(t => !t.NormalizedTitle.Equals(normalizedTitle))) - { - handleAdd(existingTag); - isModified = true; - } + handleAdd(existingTag); // Add existing tag from allTagsDict } else { - // Add new tag - handleAdd(new GenreBuilder(tagTitle).Build()); - isModified = true; + handleAdd(new GenreBuilder(tagDto).Build()); // Add new genre if not found } + isModified = true; } + // Call onModified if any changes were made if (isModified) { onModified(); diff --git a/API/Helpers/JwtHelper.cs b/API/Helpers/JwtHelper.cs new file mode 100644 index 000000000..0f9219804 --- /dev/null +++ b/API/Helpers/JwtHelper.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; + +namespace API.Helpers; + +public static class JwtHelper +{ + /// + /// Extracts the expiration date from a JWT token. + /// + public static DateTime GetTokenExpiry(string jwtToken) + { + if (string.IsNullOrEmpty(jwtToken)) + return DateTime.MinValue; + + // Parse the JWT and extract the expiry claim + var jwtHandler = new JwtSecurityTokenHandler(); + var token = jwtHandler.ReadJwtToken(jwtToken); + return token.ValidTo; + + // var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value; + // + // if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds)) + // { + // return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime; + // } + // + // + // + // return DateTime.MinValue; + } + + /// + /// Checks if a JWT token is valid based on its expiry date. + /// + public static bool IsTokenValid(string jwtToken) + { + if (string.IsNullOrEmpty(jwtToken)) return false; + + var expiry = GetTokenExpiry(jwtToken); + return expiry > DateTime.UtcNow; + } +} diff --git a/API/Helpers/LibraryTypeHelper.cs b/API/Helpers/LibraryTypeHelper.cs deleted file mode 100644 index b65c31512..000000000 --- a/API/Helpers/LibraryTypeHelper.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using API.DTOs.Scrobbling; -using API.Entities.Enums; - -namespace API.Helpers; -#nullable enable - -public static class LibraryTypeHelper -{ - public static MediaFormat GetFormat(LibraryType libraryType) - { - return libraryType switch - { - LibraryType.Manga => MediaFormat.Manga, - LibraryType.Comic => MediaFormat.Comic, - LibraryType.LightNovel => MediaFormat.LightNovel, - }; - } -} diff --git a/API/Helpers/OrderableHelper.cs b/API/Helpers/OrderableHelper.cs index d936eb588..d4ff89573 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; @@ -8,6 +9,8 @@ public static class OrderableHelper { public static void ReorderItems(List items, int itemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == itemId); if (item != null) { @@ -23,6 +26,8 @@ public static class OrderableHelper public static void ReorderItems(List items, int itemId, int toPosition) { + if (toPosition < 0) throw new ArgumentException("toPosition cannot be less than 0"); + var item = items.Find(r => r.Id == itemId); if (item != null && toPosition < items.Count) { @@ -46,10 +51,16 @@ 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) { items.Remove(item); + + // Ensure toPosition is within the new list bounds + toPosition = Math.Min(toPosition, items.Count); + items.Insert(toPosition, item); } diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs new file mode 100644 index 000000000..ce74ae97d --- /dev/null +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -0,0 +1,146 @@ +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ +using System; +using API.Data.Metadata; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using Nager.ArticleNumber; +using System.Collections.Generic; +using System.Globalization; + +namespace API.Helpers; +#nullable enable + +public interface IPdfComicInfoExtractor +{ + ComicInfo? GetComicInfo(string filePath); +} + +/// +/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. +/// +public class PdfComicInfoExtractor : IPdfComicInfoExtractor +{ + private readonly ILogger _logger; + private readonly IMediaErrorService _mediaErrorService; + private readonly string[] _pdfDateFormats = [ // PDF Spec 7.9.4 + "D:yyyyMMddHHmmsszzz:", "D:yyyyMMddHHmmss+", "D:yyyyMMddHHmmss", + "D:yyyyMMddHHmmzzz:", "D:yyyyMMddHHmm+", "D:yyyyMMddHHmm", + "D:yyyyMMddHHzzz:", "D:yyyyMMddHH+", "D:yyyyMMddHH", + "D:yyyyMMdd", "D:yyyyMM", "D:yyyy" + ]; + + public PdfComicInfoExtractor(ILogger logger, IMediaErrorService mediaErrorService) + { + _logger = logger; + _mediaErrorService = mediaErrorService; + } + + private static float? GetFloatFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) return value; + + return null; + } + + private DateTime? GetDateTimeFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + // Dates stored in the XMP metadata stream (PDF Spec 14.3.2) + // are stored in ISO 8601 format, which is handled by C# out of the box + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, out var date)) return date; + + // Dates stored in the document information directory (PDF Spec 14.3.3) + // are stored in a proprietary format (PDF Spec 7.9.4) that needs to be + // massaged slightly to be expressible by a DateTime format. + if (text[0] != 'D') { + text = "D:" + text; + } + text = text.Replace("'", ":"); + text = text.Replace("Z", "+"); + + foreach(var format in _pdfDateFormats) + { + if (DateTime.TryParseExact(text, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var pdfDate)) return pdfDate; + } + + return null; + } + + private static string? MaybeGetMetadata(Dictionary metadata, string key) + { + return metadata.TryGetValue(key, out var value) ? value : null; + } + + private ComicInfo? GetComicInfoFromMetadata(Dictionary metadata, string filePath) + { + var info = new ComicInfo(); + + var publicationDate = GetDateTimeFromText(MaybeGetMetadata(metadata, "CreationDate")); + + if (publicationDate != null) + { + info.Year = publicationDate.Value.Year; + info.Month = publicationDate.Value.Month; + info.Day = publicationDate.Value.Day; + } + + info.Summary = MaybeGetMetadata(metadata, "Summary") ?? string.Empty; + info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty; + info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty; + info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty; + info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; + info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty; + info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language")); + info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty; + + if (info.Isbn != string.Empty && !ArticleNumberHelper.IsValidIsbn10(info.Isbn) && !ArticleNumberHelper.IsValidIsbn13(info.Isbn)) + { + _logger.LogDebug("[BookService] {File} has an invalid ISBN number", filePath); + info.Isbn = string.Empty; + } + + info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f; + info.Series = MaybeGetMetadata(metadata, "Series") ?? info.Title; + info.SeriesSort = info.Series; + info.Volume = MaybeGetMetadata(metadata, "Volume") ?? string.Empty; + + // If this is a single book and not a collection, set publication status to Completed + if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + { + info.Count = 1; + } + + ComicInfo.CleanComicInfo(info); + + return info; + } + + public ComicInfo? GetComicInfo(string filePath) + { + try + { + var extractor = new PdfMetadataExtractor(_logger, filePath); + + return GetComicInfoFromMetadata(extractor.GetMetadata(), filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing PDF metadata for {File}", filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing PDF metadata", ex); + } + + return null; + } +} diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs new file mode 100644 index 000000000..44327672b --- /dev/null +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -0,0 +1,1637 @@ +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ + +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.Text; +using System.Xml; +using System.IO; +using Microsoft.Extensions.Logging; +using API.Services; + +namespace API.Helpers; +#nullable enable + +/// +/// Parse PDF file and try to extract as much metadata as possible. +/// Supports both text based XRef tables and compressed XRef streams (Deflate only). +/// Supports both UTF-16 and PDFDocEncoding for strings. +/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. +/// +public class PdfMetadataExtractorException : Exception +{ + public PdfMetadataExtractorException() + { + } + + public PdfMetadataExtractorException(string message) + : base(message) + { + } + + public PdfMetadataExtractorException(string message, Exception inner) + : base(message, inner) + { + } +} + +public interface IPdfMetadataExtractor +{ + Dictionary GetMetadata(); +} + +class PdfStringBuilder +{ + private readonly StringBuilder _builder = new(); + private bool _secondByte = false; + private byte _prevByte = 0; + private bool _isUnicode = false; + + // PDFDocEncoding defined in PDF Spec D.1 + + private readonly char[] _pdfDocMappingLow = + [ + '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC' + ]; + + private readonly char[] _pdfDocMappingHigh = + [ + '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044', + '\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018', + '\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160', + '\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ', + '\u20AC' + ]; + + private void AppendPdfDocByte(byte b) + { + if (b >= 0x18 && b < 0x20) + { + _builder.Append(_pdfDocMappingLow[b - 0x18]); + } + else if (b >= 0x80 && b < 0xA1) + { + _builder.Append(_pdfDocMappingHigh[b - 0x80]); + } + else + { + _builder.Append((char)b); + } + } + + public void Append(char c) + { + _builder.Append(c); + } + + public void AppendByte(byte b) + { + // PDF Spec 7.9.2.1: Strings are either UTF-16BE or PDFDocEncoded + if (_builder.Length == 0 && !_isUnicode) + { + // Unicode strings are prefixed by a big endian BOM \uFEFF + if (_secondByte) + { + if (b == 0xFF) + { + _isUnicode = true; + _secondByte = false; + } + else + { + AppendPdfDocByte(_prevByte); + AppendPdfDocByte(b); + } + } + else if (!_secondByte && b == 0xFE) + { + _secondByte = true; + _prevByte = b; + } + else + { + AppendPdfDocByte(b); + } + } + else if (_isUnicode) + { + if (_secondByte) + { + _builder.Append((char)(((char)_prevByte) << 8 | (char)b)); + _secondByte = false; + } + else + { + _prevByte = b; + _secondByte = true; + } + } + else + { + AppendPdfDocByte(b); + } + } + + override public string ToString() + { + if (_builder.Length == 0 && _secondByte) + { + AppendPdfDocByte(_prevByte); + } + + return _builder.ToString(); + } +} + +internal class PdfLexer(Stream stream) +{ + private const int BufferSize = 1024; + private readonly byte[] _buffer = new byte[BufferSize]; + private int _pos = 0; + private int _valid = 0; + + public enum TokenType + { + None, + Bool, + Int, + Double, + Name, + String, + ArrayStart, + ArrayEnd, + DictionaryStart, + DictionaryEnd, + StreamStart, + StreamEnd, + ObjectStart, + ObjectEnd, + ObjectRef, + Keyword, + Newline, + } + + public struct Token(TokenType type, object value) + { + public TokenType Type = type; + public object Value = value; + } + + public Token NextToken(bool reportNewlines = false) + { + while (true) + { + switch ((char)NextByte()) + { + case '\n' when reportNewlines: + return new Token(TokenType.Newline, true); + + case '\r' when reportNewlines: + if (NextByte() != '\n') + { + PutBack(); + } + return new Token(TokenType.Newline, true); + + case ' ': + case '\x00': + case '\t': + case '\n': + case '\f': + case '\r': + continue; // Skip whitespace + + case '%': + SkipComment(); + continue; + + case '+': + case '-': + case '.': + case >= '0' and <= '9': + return ScanNumber(); + + case '/': + return ScanName(); + + case '(': + return ScanString(); + + case '[': + return new Token(TokenType.ArrayStart, true); + + case ']': + return new Token(TokenType.ArrayEnd, true); + + case '<': + if (NextByte() == '<') + { + return new Token(TokenType.DictionaryStart, true); + } + else + { + PutBack(); + return ScanHexString(); + } + case '>': + ExpectByte((byte)'>'); + + return new Token(TokenType.DictionaryEnd, true); + + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + return ScanKeyword(); + + default: + throw new PdfMetadataExtractorException("Unexpected byte, got {LastByte()}"); + } + } + } + + public void ResetBuffer() + { + _pos = 0; + _valid = 0; + } + + public bool TestByte(byte expected) + { + var result = NextByte() == expected; + + PutBack(); + + return result; + } + + public void ExpectNewline() + { + while (true) + { + var b = NextByte(); + switch ((char)b) + { + case ' ': + case '\t': + case '\f': + continue; // Skip whitespace + + case '\n': + return; + + case '\r': + if (NextByte() != '\n') + { + PutBack(); + } + + return; + + default: + throw new PdfMetadataExtractorException("Unexpected character, expected newline, got {b}"); + } + } + } + + public long GetXRefStart() + { + // Look for the startxref element as per PDF Spec 7.5.5 + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case '\r': + b = NextByte(); + + if (b != '\n') + { + PutBack(); + } + + goto case '\n'; + + case '\n': + // Handle consecutive newlines + while (true) + { + b = NextByte(); + + if (b == '\r') + { + goto case '\r'; + } + else if (b == '\n') + { + goto case '\n'; + } + else if (b == ' ' || b == '\t' || b == '\f') + { + continue; + } + else + { + PutBack(); + + break; + } + } + + var token = NextToken(true); + + if (token.Type == TokenType.Keyword && (string)token.Value == "startxref") + { + token = NextToken(); + + if (token.Type == TokenType.Int) + { + return (long)token.Value; + } + else + { + throw new PdfMetadataExtractorException("Expected integer after startxref keyword"); + } + } + + continue; + + default: + continue; + } + } + } + + public bool NextXRefEntry(ref long obj, ref int generation) + { + // Cross-reference table entry as per PDF Spec 7.5.4 + + WantLookahead(20); + + if (_valid - _pos < 20) + { + throw new PdfMetadataExtractorException("End of stream"); + } + + var inUse = true; + + if (obj == 0) + { + obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10)); + generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); + inUse = _buffer[_pos + 17] == 'n'; + } + + _pos += 20; + + return inUse; + } + + public Stream StreamObject(int length, bool deflate) + { + // Read a stream object as per PDF Spec 7.3.8 + // At the moment, we only accept uncompressed streams or the FlateDecode (PDF Spec 7.4.1) filter + // with no parameters. These cover the vast majority of streams we're interested in. + + var rawData = new MemoryStream(); + + ExpectNewline(); + + if (_pos < _valid) + { + var buffered = Math.Min(_valid - _pos, length); + rawData.Write(_buffer, _pos, buffered); + length -= buffered; + _pos += buffered; + } + + while (length > 0) + { + var buffered = Math.Min(length, BufferSize); + stream.ReadExactly(_buffer, 0, buffered); + rawData.Write(_buffer, 0, buffered); + _pos = 0; + _valid = 0; + length -= buffered; + } + + rawData.Seek(0, SeekOrigin.Begin); + + if (deflate) + { + return new ZLibStream(rawData, CompressionMode.Decompress, false); + } + else + { + return rawData; + } + } + + private byte NextByte() + { + if (_pos >= _valid) + { + _pos = 0; + _valid = stream.Read(_buffer, 0, BufferSize); + + if (_valid <= 0) + { + throw new PdfMetadataExtractorException("End of stream"); + } + } + + return _buffer[_pos++]; + } + + private byte LastByte() + { + return _buffer[_pos - 1]; + } + + private void PutBack() + { + --_pos; + } + + private void ExpectByte(byte expected) + { + if (NextByte() != expected) + { + throw new PdfMetadataExtractorException($"Unexpected character, expected {expected}"); + } + } + + private void WantLookahead(int length) + { + if (_pos + length > _valid) + { + Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos); + _valid -= _pos; + _pos = 0; + _valid += stream.Read(_buffer, _valid, BufferSize - _valid); + } + } + + private void SkipComment() + { + while (true) + { + var b = NextByte(); + + if (b == '\n') + { + break; + } + else if (b == '\r') + { + if (NextByte() != '\n') + { + PutBack(); + } + + break; + } + } + } + + private Token ScanNumber() + { + StringBuilder sb = new(); + var hasDot = LastByte() == '.'; + var followedBySpace = false; + + sb.Append((char)LastByte()); + + while (true) + { + var b = NextByte(); + + if (b == '.' || b >= '0' && b <= '9') + { + sb.Append((char)b); + + if (b == '.') + { + hasDot = true; + } + } + else + { + followedBySpace = (b == ' ' || b == '\t'); + PutBack(); + + break; + } + } + + if (hasDot) + { + return new Token(TokenType.Double, double.Parse(sb.ToString())); + } + + if (followedBySpace) + { + // Look ahead to see if it's an object reference (PDF Spec 7.3.10) + WantLookahead(32); + + var savedPos = _pos; + var b = NextByte(); + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + // Generation number (ignored) + while (b >= '0' && b <= '9') + { + b = NextByte(); + } + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + if (b == 'R') + { + return new Token(TokenType.ObjectRef, long.Parse(sb.ToString())); + } + else if (b == 'o' && NextByte() == 'b' && NextByte() == 'j') + { + return new Token(TokenType.ObjectStart, long.Parse(sb.ToString())); + } + else + { + _pos = savedPos; + } + } + + return new Token(TokenType.Int, long.Parse(sb.ToString())); + } + + private static int HexDigit(byte b) + { + return (char) b switch + { + >= '0' and <= '9' => b - (byte) '0', + >= 'a' and <= 'f' => b - (byte) 'a' + 10, + >= 'A' and <= 'F' => b - (byte) 'A' + 10, + _ => throw new PdfMetadataExtractorException("Invalid hex digit, got {b}") + }; + } + + private Token ScanName() + { + // PDF Spec 7.3.5 + + var sb = new StringBuilder(); + while (true) + { + var b = NextByte(); + switch ((char)b) + { + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '<': + case '>': + case '/': + case '%': + PutBack(); + + goto case ' '; + + case ' ': + case '\t': + case '\n': + case '\f': + case '\r': + return new Token(TokenType.Name, sb.ToString()); + + case '#': + var b1 = NextByte(); + var b2 = NextByte(); + b = (byte)((HexDigit(b1) << 4) | HexDigit(b2)); + + goto default; + + default: + sb.Append((char)b); + break; + } + } + } + + private Token ScanString() + { + // PDF Spec 7.3.4.2 + + PdfStringBuilder sb = new(); + var parenLevel = 1; + + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case '(': + parenLevel++; + + goto default; + + case ')': + if (--parenLevel == 0) + { + return new Token(TokenType.String, sb.ToString()); + } + + goto default; + + case '\\': + b = NextByte(); + + switch ((char)b) + { + case 'b': + sb.Append('\b'); + + break; + + case 'f': + sb.Append('\f'); + + break; + + case 'n': + sb.Append('\n'); + + break; + + case 'r': + sb.Append('\r'); + + break; + + case 't': + sb.Append('\t'); + + break; + + case >= '0' and <= '7': + var b1 = b; + var b2 = NextByte(); + var b3 = NextByte(); + + if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7') + { + throw new PdfMetadataExtractorException("Invalid octal escape, got {b1}{b2}{b3}"); + } + + sb.AppendByte((byte)((b1 - '0') << 6 | (b2 - '0') << 3 | (b3 - '0'))); + + break; + } + break; + + default: + sb.AppendByte(b); + break; + } + } + } + + private Token ScanHexString() + { + // PDF Spec 7.3.4.3 + + PdfStringBuilder sb = new(); + + while (true) + { + var b = NextByte(); + + switch ((char)b) + { + case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'): + var b1 = NextByte(); + if (b1 == '>') + { + PutBack(); + b1 = (byte)'0'; + } + sb.AppendByte((byte)(HexDigit(b) << 4 | HexDigit(b1))); + + break; + + case '>': + return new Token(TokenType.String, sb.ToString()); + + default: + throw new PdfMetadataExtractorException("Invalid hex string, got {b}"); + } + } + } + + private Token ScanKeyword() + { + StringBuilder sb = new(); + + sb.Append((char)LastByte()); + + while (true) + { + var b = NextByte(); + if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) + { + sb.Append((char)b); + } + else + { + PutBack(); + + break; + } + } + + switch (sb.ToString()) + { + case "true": + return new Token(TokenType.Bool, true); + + case "false": + return new Token(TokenType.Bool, false); + + case "stream": + return new Token(TokenType.StreamStart, true); + + case "endstream": + return new Token(TokenType.StreamEnd, true); + + case "endobj": + return new Token(TokenType.ObjectEnd, true); + + default: + return new Token(TokenType.Keyword, sb.ToString()); + } + } +} + +internal class PdfMetadataExtractor : IPdfMetadataExtractor +{ + private readonly ILogger _logger; + private readonly PdfLexer _lexer; + private readonly FileStream _stream; + private long[] _objectOffsets = new long[0]; + private readonly Dictionary _metadata = []; + private readonly Stack _metadataRef = new(); + + private struct MetadataRef(long root, long info) + { + public long Root = root; + public long Info = info; + } + + private struct XRefSection(long first, long count) + { + public readonly long First = first; + public readonly long Count = count; + } + + public PdfMetadataExtractor(ILogger logger, string filename) + { + _logger = logger; + _stream = File.OpenRead(filename); + _lexer = new PdfLexer(_stream); + + ReadObjectOffsets(); + ReadMetadata(filename); + } + + public Dictionary GetMetadata() + { + return _metadata; + } + + private void LogMetadata(string filename) + { + _logger.LogTrace("Metadata for {Path}:", filename); + + foreach (var entry in _metadata) + { + _logger.LogTrace(" {Key:0,-5} : {Value:1}", entry.Key, entry.Value); + } + } + + private void ReadObjectOffsets() + { + // Look for file trailer (PDF Spec 7.5.5) + // Spec says trailer must be strictly at end of file. + // Adobe software accepts trailer within last 1K of EOF, + // but in practice, virtually all PDFs have trailer at end. + + _stream.Seek(-32, SeekOrigin.End); + + var xrefOffset = _lexer.GetXRefStart(); + + ReadXRefAndTrailer(xrefOffset); + } + + private void ReadXRefAndTrailer(long xrefOffset) + { + _stream.Seek(xrefOffset, SeekOrigin.Begin); + _lexer.ResetBuffer(); + + if (!_lexer.TestByte((byte)'x')) + { + // Cross-reference stream (PDF Spec 7.5.8) + + ReadXRefStream(); + + return; + } + + // Cross-reference table (PDF Spec 7.5.4) + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Keyword || (string)token.Value != "xref") + { + throw new PdfMetadataExtractorException("Expected xref keyword"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.Int) + { + var startObj = (long)token.Value; + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected number of objects in xref subsection"); + } + + var numObj = (long)token.Value; + + if (_objectOffsets.Length < startObj + numObj) + { + Array.Resize(ref _objectOffsets, (int)(startObj + numObj)); + } + + _lexer.ExpectNewline(); + + var generation = 0; + + for (var obj = startObj; obj < startObj + numObj; ++obj) + { + var inUse = _lexer.NextXRefEntry(ref _objectOffsets[obj], ref generation); + + if (!inUse) + { + _objectOffsets[obj] = 0; + } + } + } + else if (token.Type == PdfLexer.TokenType.Keyword && (string)token.Value == "trailer") + { + break; + } + else + { + throw new PdfMetadataExtractorException("Unexpected token in xref"); + } + } + + ReadTrailerDictionary(); + } + + private void ReadXRefStream() + { + // Cross-reference stream (PDF Spec 7.5.8) + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected obj keyword"); + } + + long length = -1; + long size = -1; + var deflate = false; + long prev = -1; + long typeWidth = -1; + long offsetWidth = -1; + long generationWidth = -1; + Queue sections = new(); + var meta = new MetadataRef(-1, -1); + + // Cross-reference stream dictionary (PDF Spec 7.5.8.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) { + switch (key) + { + case "Type": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XRef") + { + throw new PdfMetadataExtractorException("Expected /Type to be /XRef"); + } + + return true; + + case "Length": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.Value; + + return true; + + case "Size": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Size"); + } + + size = (long)value.Value; + + return true; + + case "Prev": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.Value; + + return true; + + case "Index": + if (value.Type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /Index"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + else if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /Index array"); + } + + var first = (long)token.Value; + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer pair in /Index array"); + } + + var count = (long)token.Value; + sections.Enqueue(new XRefSection(first, count)); + } + + return true; + + case "W": + if (value.Type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /W"); + } + + var widths = new long[3]; + + for (var i = 0; i < 3; ++i) + { + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /W array"); + } + + widths[i] = (long)token.Value; + } + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ArrayEnd) + { + throw new PdfMetadataExtractorException("Unclosed array after /W"); + } + + typeWidth = widths[0]; + offsetWidth = widths[1]; + generationWidth = widths[2]; + + return true; + + case "Filter": + if (value.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.Value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + case "Root": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.Root = (long)value.Value; + + return true; + + case "Info": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.Info = (long)value.Value; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var stream = _lexer.StreamObject((int)length, deflate); + + if (sections.Count == 0) + { + sections.Enqueue(new XRefSection(0, size)); + } + + while (sections.Count > 0) + { + var section = sections.Dequeue(); + + if (_objectOffsets.Length < size) + { + Array.Resize(ref _objectOffsets, (int)size); + } + + for (var i = section.First; i < section.First + section.Count; ++i) + { + long type = 0; + long offset = 0; + long generation = 0; + + if (typeWidth == 0) + { + type = 1; + } + + for (var j = 0; j < typeWidth; ++j) + { + type = (type << 8) | (ushort)stream.ReadByte(); + } + + for (var j = 0; j < offsetWidth; ++j) + { + offset = (offset << 8) | (ushort)stream.ReadByte(); + } + + for (var j = 0; j < generationWidth; ++j) + { + generation = (generation << 8) | (ushort)stream.ReadByte(); + } + + if (type == 1 && _objectOffsets[i] == 0) + { + _objectOffsets[i] = offset; + } + } + } + + if (prev > -1) + { + ReadXRefAndTrailer(prev); + } + + PushMetadataRef(meta); + } + + private void PushMetadataRef(MetadataRef meta) + { + if (_metadataRef.Count > 0) + { + if (meta.Root == _metadataRef.Peek().Root) + { + meta.Root = -1; + } + + if (meta.Info == _metadataRef.Peek().Info) + { + meta.Info = -1; + } + } + + if (meta.Root != -1 || meta.Info != -1) + { + _metadataRef.Push(meta); + } + } + + private void ReadTrailerDictionary() + { + // Read trailer directory (PDF Spec 7.5.5) + + long prev = -1; + long xrefStm = -1; + + MetadataRef meta = new(-1, -1); + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Root": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.Root = (long)value.Value; + + return true; + case "Prev": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.Value; + + return true; + case "Info": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.Info = (long)value.Value; + + return true; + case "XRefStm": + // Prefer encoded xref stream over xref table + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /XRefStm"); + } + + xrefStm = (long)value.Value; + + return true; + + case "Encrypt": + throw new PdfMetadataExtractorException("Encryption not supported"); + + default: + return false; + } + }); + + PushMetadataRef(meta); + + if (xrefStm != -1) + { + ReadXRefAndTrailer(xrefStm); + } + + if (prev != -1) + { + ReadXRefAndTrailer(prev); + } + } + + private void ReadMetadata(string filename) + { + // We read potential metadata sources in backwards historical order, so + // we can overwrite to our heart's content + + while (_metadataRef.Count > 0) + { + var meta = _metadataRef.Pop(); + + //_logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + + ReadMetadataFromInfo(meta.Info); + ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root)); + } + } + + private void ReadMetadataFromInfo(long infoObj) + { + // Document information dictionary (PDF Spec 14.3.3) + // We treat this as less authoritative than the Metadata stream. + + if (infoObj < 1 || infoObj >= _objectOffsets.Length || _objectOffsets[infoObj] == 0) + { + return; + } + + _stream.Seek(_objectOffsets[infoObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + Dictionary indirectObjects = []; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Title": + case "Author": + case "Subject": + case "Keywords": + case "Creator": + case "Producer": + case "CreationDate": + case "ModDate": + if (value.Type == PdfLexer.TokenType.ObjectRef) { + indirectObjects[key] = (long)value.Value; + } + else if (value.Type != PdfLexer.TokenType.String) + { + throw new PdfMetadataExtractorException("Expected string value"); + } + else + { + _metadata[key] = (string)value.Value; + } + + return true; + + default: + return false; + } + }); + + // Resolve indirectly referenced values + foreach(var key in indirectObjects.Keys) { + _stream.Seek(_objectOffsets[indirectObjects[key]], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) { + throw new PdfMetadataExtractorException("Expected object here"); + } + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.String) { + throw new PdfMetadataExtractorException("Expected string"); + } + + _metadata[key] = (string) token.Value; + } + } + + private long MetadataObjInObjectCatalog(long rootObj) + { + // Look for /Metadata entry in document catalog (PDF Spec 7.7.2) + + if (rootObj < 1 || rootObj >= _objectOffsets.Length || _objectOffsets[rootObj] == 0) + { + return -1; + } + + _stream.Seek(_objectOffsets[rootObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long meta = -1; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Metadata": + if (value.Type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object number after /Metadata"); + } + + meta = (long)value.Value; + + return true; + + default: + return false; + } + }); + + return meta; + } + + // Obtain metadata from XMP stream object + // See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/ + // and Dublin Core: https://www.dublincore.org/specifications/dublin-core/ + + private static string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns) + ?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText; + } + + private static string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns); + + if (nodes == null) return null; + + var list = new StringBuilder(); + + foreach (XmlNode n in nodes) + { + if (list.Length > 0) + { + list.Append(','); + } + + list.Append(n.InnerText); + } + + return list.Length > 0 ? list.ToString() : null; + } + + private void SetMetadata(string key, string? value) + { + if (value == null) return; + + _metadata[key] = value; + } + + private void ReadMetadataFromXml(long meta) + { + if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; + + _stream.Seek(_objectOffsets[meta], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long length = -1; + var deflate = false; + + // Metadata stream dictionary (PDF Spec 14.3.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Type": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "Metadata") + { + throw new PdfMetadataExtractorException("Expected /Type to be /Metadata"); + } + + return true; + + case "Subtype": + if (value.Type != PdfLexer.TokenType.Name || (string)value.Value != "XML") + { + throw new PdfMetadataExtractorException("Expected /Subtype to be /XML"); + } + + return true; + + case "Length": + if (value.Type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.Value; + + return true; + + case "Filter": + if (value.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.Value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var xmlStream = _lexer.StreamObject((int)length, deflate); + + // Skip XMP header + while (true) { + var b = xmlStream.ReadByte(); + + if (b < 0) { + throw new PdfMetadataExtractorException("Reached EOF in XMP header"); + } + + if (b == '?') { + while (b == '?') { + b = xmlStream.ReadByte(); + } + + if (b == '>') { + break; + } + } + } + + var metaDoc = new XmlDocument(); + metaDoc.Load(xmlStream); + + var ns = new XmlNamespaceManager(metaDoc.NameTable); + ns.AddNamespace("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"); + ns.AddNamespace("dc", "http://purl.org/dc/elements/1.1/"); + ns.AddNamespace("calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index"); + ns.AddNamespace("calibre", "http://calibre-ebook.com/xmp-namespace"); + ns.AddNamespace("pdfx", "http://ns.adobe.com/pdfx/1.3/"); + ns.AddNamespace("prism", "http://prismstandard.org/namespaces/basic/2.0/"); + ns.AddNamespace("xmp", "http://ns.adobe.com/xap/1.0/"); + + SetMetadata("CreationDate", + GetTextFromXmlNode(metaDoc, ns, "//dc:date") + ?? GetTextFromXmlNode(metaDoc, ns, "//xmp:CreateDate")); + SetMetadata("Summary", GetTextFromXmlNode(metaDoc, ns, "//dc:description")); + SetMetadata("Publisher", GetTextFromXmlNode(metaDoc, ns, "//dc:publisher")); + SetMetadata("Author", GetListFromXmlNode(metaDoc, ns, "//dc:creator")); + SetMetadata("Title", GetTextFromXmlNode(metaDoc, ns, "//dc:title")); + SetMetadata("Subject", GetListFromXmlNode(metaDoc, ns, "//dc:subject")); + SetMetadata("Language", GetTextFromXmlNode(metaDoc, ns, "//dc:language")); + SetMetadata("ISBN", GetTextFromXmlNode(metaDoc, ns, "//pdfx:isbn") ?? GetTextFromXmlNode(metaDoc, ns, "//prism:isbn")); + SetMetadata("UserRating", GetTextFromXmlNode(metaDoc, ns, "//calibre:rating")); + SetMetadata("TitleSort", GetTextFromXmlNode(metaDoc, ns, "//calibre:title_sort")); + SetMetadata("Series", GetTextFromXmlNode(metaDoc, ns, "//calibre:series/rdf:value")); + SetMetadata("Volume", GetTextFromXmlNode(metaDoc, ns, "//calibreSI:series_index")); + } + + private delegate bool DictionaryHandler(string key, PdfLexer.Token value); + + private void ParseDictionary(DictionaryHandler handler) + { + var token = _lexer.NextToken(); + + if (token.Type != PdfLexer.TokenType.DictionaryStart) + { + throw new PdfMetadataExtractorException("Expected dictionary"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.DictionaryEnd) + { + return; + } + + if (token.Type == PdfLexer.TokenType.Name) + { + var value = _lexer.NextToken(); + + if (!handler((string)token.Value, value)) { + SkipValue(value); + } + } + else + { + throw new PdfMetadataExtractorException("Improper token in dictionary"); + } + } + } + + private void SkipValue(PdfLexer.Token? existingToken = null) + { + var token = existingToken ?? _lexer.NextToken(); + + switch (token.Type) + { + case PdfLexer.TokenType.Bool: + case PdfLexer.TokenType.Int: + case PdfLexer.TokenType.Double: + case PdfLexer.TokenType.Name: + case PdfLexer.TokenType.String: + case PdfLexer.TokenType.ObjectRef: + break; + case PdfLexer.TokenType.ArrayStart: + { + SkipArray(); + break; + } + case PdfLexer.TokenType.DictionaryStart: + { + SkipDictionary(); + break; + } + default: + throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); + } + } + + private void SkipArray() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + + SkipValue(token); + } + } + + private void SkipDictionary() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.Type == PdfLexer.TokenType.DictionaryEnd) + { + break; + } + if (token.Type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name in dictionary"); + } + + SkipValue(); + } + } +} diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index fcfb1f984..07161e418 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -1,182 +1,232 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using API.Data; using API.DTOs; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers.Builders; namespace API.Helpers; #nullable enable +// This isn't needed in the new person architecture public static class PersonHelper { - /// - /// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and - /// add an entry. For each person in name, the callback will be executed. - /// - /// This does not remove people if an empty list is passed into names - /// This is used to add new people to a list without worrying about duplicating rows in the DB - /// - /// - /// - /// - public static void UpdatePeople(ICollection allPeople, IEnumerable names, PersonRole role, Action action) + + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, + IEnumerable chapterPeople, PersonRole role, IUnitOfWork unitOfWork) { - // TODO: Validate if we need this, not used - var allPeopleTypeRole = allPeople.Where(p => p.Role == role).ToList(); + var modification = false; - foreach (var name in names) + // Get all people with the specified role from chapterPeople + var peopleToAdd = chapterPeople + .Where(cp => cp.Role == role) + .Select(cp => new { cp.Person.Name, cp.Person.NormalizedName }) // Store both real and normalized names + .ToList(); + + // Prepare a HashSet for quick lookup of normalized names of people to add + var peopleToAddSet = new HashSet(peopleToAdd.Select(p => p.NormalizedName)); + + // Get all existing people from metadataPeople with the specified role + var existingMetadataPeople = metadataPeople + .Where(mp => mp.Role == role) + .ToList(); + + // Identify people to remove from metadataPeople + var peopleToRemove = existingMetadataPeople + .Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName)) + .ToList(); + + // Remove identified people from metadataPeople + foreach (var personToRemove in peopleToRemove) { - var normalizedName = name.ToNormalized(); - // BUG: Doesn't this create a duplicate entry because allPeopleTypeRoles is a different instance? - var person = allPeopleTypeRole.Find(p => - p.NormalizedName != null && p.NormalizedName.Equals(normalizedName)); - if (person == null) - { - person = new PersonBuilder(name, role).Build(); - allPeople.Add(person); - } + metadataPeople.Remove(personToRemove); + modification = true; + } - action(person); + // Bulk fetch existing people from the repository based on normalized names + var existingPeopleInDb = await unitOfWork.PersonRepository + .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); + + // Prepare a dictionary for quick lookup of existing people by normalized name + var existingPeopleDict = new Dictionary(); + foreach (var person in existingPeopleInDb) + { + existingPeopleDict.TryAdd(person.NormalizedName, person); + } + + // Track the people to attach (newly created people) + var peopleToAttach = new List(); + + // Identify new people (not already in metadataPeople) to add + foreach (var personData in peopleToAdd) + { + var personName = personData.Name; + var normalizedPersonName = personData.NormalizedName; + + // Check if the person already exists in metadataPeople with the specific role + var personAlreadyInMetadata = metadataPeople + .Any(mp => mp.Person.NormalizedName == normalizedPersonName && mp.Role == role); + + if (!personAlreadyInMetadata) + { + // Check if the person exists in the database + if (!existingPeopleDict.TryGetValue(normalizedPersonName, out var dbPerson)) + { + // If not, create a new Person entity using the real name + dbPerson = new PersonBuilder(personName).Build(); + peopleToAttach.Add(dbPerson); // Add new person to the list to be attached + } + + // Add the person to the SeriesMetadataPeople collection + metadataPeople.Add(new SeriesMetadataPeople + { + PersonId = dbPerson.Id, // EF Core will automatically update this after attach + Person = dbPerson, + SeriesMetadataId = metadata.Id, + SeriesMetadata = metadata, + Role = role + }); + modification = true; + } + } + + // Attach all new people in one go (EF Core will assign IDs after commit) + if (peopleToAttach.Count != 0) + { + await unitOfWork.DataContext.Person.AddRangeAsync(peopleToAttach); + } + + // Commit the changes if any modifications were made + if (modification) + { + await unitOfWork.CommitAsync(); } } - /// - /// Remove people on a list for a given role - /// - /// Used to remove before we update/add new people - /// Existing people on Entity - /// People from metadata - /// Role to filter on - /// Callback which will be executed for each person removed - public static void RemovePeople(ICollection existingPeople, IEnumerable people, PersonRole role, Action? action = null) + + + public static async Task UpdateChapterPeopleAsync(Chapter chapter, IList people, PersonRole role, IUnitOfWork unitOfWork) { - var normalizedPeople = people.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList(); - if (normalizedPeople.Count == 0) - { - var peopleToRemove = existingPeople.Where(p => p.Role == role).ToList(); - foreach (var existingRoleToRemove in peopleToRemove) - { - existingPeople.Remove(existingRoleToRemove); - action?.Invoke(existingRoleToRemove); - } - return; - } + var modification = false; - foreach (var person in normalizedPeople) - { - var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName)); - if (existingPerson == null) continue; + // Normalize the input names for comparison + var normalizedPeople = people.Select(p => p.ToNormalized()).Distinct().ToList(); // Ensure distinct people - existingPeople.Remove(existingPerson); - action?.Invoke(existingPerson); - } + // Get all existing ChapterPeople for the role + var existingChapterPeople = chapter.People + .Where(cp => cp.Role == role) + .ToList(); - } + // Prepare a hash set for quick lookup of existing people by normalized name + var existingPeopleNames = new HashSet(existingChapterPeople.Select(cp => cp.Person.NormalizedName)); - /// - /// Removes all people that are not present in the removeAllExcept list. - /// - /// - /// - /// Callback for all entities that should be removed - public static void KeepOnlySamePeopleBetweenLists(IEnumerable existingPeople, ICollection removeAllExcept, Action? action = null) - { + // Bulk select all people from the repository whose normalized names are in the provided list + var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); + + // Prepare a dictionary for quick lookup by normalized name + var existingPeopleDict = new Dictionary(); foreach (var person in existingPeople) { - var existingPerson = removeAllExcept - .FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); - if (existingPerson == null) - { - action?.Invoke(person); - } - } - } - - /// - /// Adds the person to the list if it's not already in there - /// - /// - /// - public static void AddPersonIfNotExists(ICollection metadataPeople, Person person) - { - if (string.IsNullOrEmpty(person.Name)) return; - var existingPerson = metadataPeople.FirstOrDefault(p => - p.NormalizedName == person.Name.ToNormalized() && p.Role == person.Role); - - if (existingPerson == null) - { - metadataPeople.Add(person); - } - } - - - /// - /// For a given role and people dtos, update a series - /// - /// - /// - /// - /// - /// This will call with an existing or new tag, but the method does not update the series Metadata - /// - public static void UpdatePeopleList(PersonRole role, ICollection? people, Series series, IReadOnlyCollection allPeople, - Action handleAdd, Action onModified) - { - if (people == null) return; - var isModified = false; - // 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.People.Where(p => p.Role == role).ToList(); - foreach (var existing in existingTags) - { - if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role - { - // Remove tag - series.Metadata.People.Remove(existing); - isModified = true; - } + existingPeopleDict.TryAdd(person.NormalizedName, person); } - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in people) + // Identify people to remove (those present in ChapterPeople but not in the new list) + foreach (var existingChapterPerson in existingChapterPeople + .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName))) { - var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); - if (existingTag != null) - { - if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) + chapter.People.Remove(existingChapterPerson); + unitOfWork.PersonRepository.Remove(existingChapterPerson); + modification = true; + } + + // Identify new people to add + var newPeopleNames = normalizedPeople + .Where(p => !existingPeopleNames.Contains(p)) + .ToList(); + + if (newPeopleNames.Count > 0) + { + // Bulk insert new people (if they don't already exist in the database) + var newPeople = newPeopleNames + .Where(name => !existingPeopleDict.ContainsKey(name)) // Avoid adding duplicates + .Select(name => { - handleAdd(existingTag); - isModified = true; - } - } - else + var realName = people.First(p => p.ToNormalized() == name); // Get the original name + return new PersonBuilder(realName).Build(); // Use the real name for the Person entity + }) + .ToList(); + + foreach (var newPerson in newPeople) { - // Add new tag - handleAdd(new PersonBuilder(tag.Name, role).Build()); - isModified = true; + unitOfWork.DataContext.Person.Attach(newPerson); + existingPeopleDict[newPerson.NormalizedName] = newPerson; } + + await unitOfWork.CommitAsync(); + modification = true; } - if (isModified) + // Add all people (both existing and newly created) to the ChapterPeople + foreach (var personName in normalizedPeople) { - onModified(); + var person = existingPeopleDict[personName]; + + // Check if the person with the specific role is already added to the chapter's People collection + if (chapter.People.Any(cp => cp.PersonId == person.Id && cp.Role == role)) continue; + + chapter.People.Add(new ChapterPeople + { + PersonId = person.Id, + ChapterId = chapter.Id, + Role = role + }); + modification = true; + } + + // Commit the changes to remove and add people + if (modification) + { + await unitOfWork.CommitAsync(); } } - public static bool HasAnyPeople(SeriesMetadataDto? seriesMetadata) + + public static bool HasAnyPeople(SeriesMetadataDto? dto) { - if (seriesMetadata == null) return false; - return seriesMetadata.Writers.Any() || - seriesMetadata.CoverArtists.Any() || - seriesMetadata.Publishers.Any() || - seriesMetadata.Characters.Any() || - seriesMetadata.Pencillers.Any() || - seriesMetadata.Inkers.Any() || - seriesMetadata.Colorists.Any() || - seriesMetadata.Letterers.Any() || - seriesMetadata.Editors.Any() || - seriesMetadata.Translators.Any(); + if (dto == null) return false; + return dto.Writers.Count != 0 || + dto.CoverArtists.Count != 0 || + dto.Publishers.Count != 0 || + dto.Characters.Count != 0 || + dto.Pencillers.Count != 0 || + dto.Inkers.Count != 0 || + dto.Colorists.Count != 0 || + dto.Letterers.Count != 0 || + dto.Editors.Count != 0 || + dto.Translators.Count != 0 || + dto.Teams.Count != 0 || + dto.Locations.Count != 0; + } + + public static bool HasAnyPeople(UpdateChapterDto? dto) + { + if (dto == null) return false; + return dto.Writers.Count != 0 || + dto.CoverArtists.Count != 0 || + dto.Publishers.Count != 0 || + dto.Characters.Count != 0 || + dto.Pencillers.Count != 0 || + dto.Inkers.Count != 0 || + dto.Colorists.Count != 0 || + dto.Letterers.Count != 0 || + dto.Editors.Count != 0 || + dto.Translators.Count != 0 || + dto.Teams.Count != 0 || + dto.Locations.Count != 0; } } diff --git a/API/Services/ReviewService.cs b/API/Helpers/ReviewHelper.cs similarity index 83% rename from API/Services/ReviewService.cs rename to API/Helpers/ReviewHelper.cs index 69ab784ae..03c50a4cf 100644 --- a/API/Services/ReviewService.cs +++ b/API/Helpers/ReviewHelper.cs @@ -5,11 +5,11 @@ using System.Text.RegularExpressions; using API.DTOs.SeriesDetail; using HtmlAgilityPack; +namespace API.Helpers; -namespace API.Services; - -public static class ReviewService +public static class ReviewHelper { + private const int BodyTextLimit = 175; public static IEnumerable SelectSpectrumOfReviews(IList reviews) { IList externalReviews; @@ -59,6 +59,9 @@ public static class ReviewService .Where(s => !s.Equals("\n"))); // Clean any leftover markdown out + plainText = Regex.Replace(plainText, @"\*\*(.*?)\*\*", "$1"); // Bold with ** + plainText = Regex.Replace(plainText, @"_(.*?)_", "$1"); // Italic with _ + plainText = Regex.Replace(plainText, @"\[(.*?)\]\((.*?)\)", "$1"); // Links [text](url) plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); plainText = Regex.Replace(plainText, @"~~~(.*?)~~~", "$1"); @@ -67,6 +70,7 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"__(.*?)__", "$1"); plainText = Regex.Replace(plainText, @"#\s(.*?)", "$1"); + // Just strip symbols plainText = Regex.Replace(plainText, @"[_*\[\]~]", string.Empty); plainText = Regex.Replace(plainText, @"img\d*\((.*?)\)", string.Empty); @@ -75,8 +79,8 @@ public static class ReviewService plainText = Regex.Replace(plainText, @"~~", string.Empty); plainText = Regex.Replace(plainText, @"__", string.Empty); - // Take the first 100 characters - plainText = plainText.Length > 100 ? plainText.Substring(0, 100) : plainText; + // Take the first BodyTextLimit characters + plainText = plainText.Length > BodyTextLimit ? plainText.Substring(0, BodyTextLimit) : plainText; return plainText + "…"; } diff --git a/API/Helpers/StringHelper.cs b/API/Helpers/StringHelper.cs new file mode 100644 index 000000000..0a20910c5 --- /dev/null +++ b/API/Helpers/StringHelper.cs @@ -0,0 +1,69 @@ +using System.Text.RegularExpressions; + +namespace API.Helpers; +#nullable enable + +public static partial class StringHelper +{ + #region Regex Source Generators + [GeneratedRegex(@"\s?\(Source:\s*[^)]+\)")] + private static partial Regex SourceRegex(); + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BrStandardizeRegex(); + [GeneratedRegex(@"(?:
\s*)+", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex BrMultipleRegex(); + [GeneratedRegex(@"\s+")] + private static partial Regex WhiteSpaceRegex(); + [GeneratedRegex("&#64;")] + private static partial Regex HtmlEncodedAtSymbolRegex(); + #endregion + + /// + /// Used to squash duplicate break and new lines with a single new line. + /// + /// Test br br Test -> Test br Test + /// + /// + public static string? SquashBreaklines(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) + { + return null; + } + + // First standardize all br tags to
format + summary = BrStandardizeRegex().Replace(summary, "
"); + + // Replace multiple consecutive br tags with a single br tag + summary = BrMultipleRegex().Replace(summary, "
"); + + // Normalize remaining whitespace (replace multiple spaces with a single space) + summary = WhiteSpaceRegex().Replace(summary, " ").Trim(); + + return summary.Trim(); + } + + /// + /// Removes the (Source: MangaDex) type of tags at the end of descriptions from AL + /// + /// + /// + public static string? RemoveSourceInDescription(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return SourceRegex().Replace(description, string.Empty).Trim(); + } + + /// + /// Replaces some HTML encoded characters in urls with the proper symbol. This is common in People Description's + /// + /// + /// + public static string? CorrectUrls(string? description) + { + if (string.IsNullOrEmpty(description)) return description; + + return HtmlEncodedAtSymbolRegex().Replace(description, "@"); + } +} diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index a69ed3c97..c00d6ee8f 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -1,146 +1,162 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; using API.Data; using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Helpers.Builders; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.EntityFrameworkCore; namespace API.Helpers; #nullable enable public static class TagHelper { - /// - /// - /// - /// - /// - /// Callback for every item. Will give said item back and a bool if item was added - public static void UpdateTag(ICollection allTags, IEnumerable names, Action action) + + public static async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames, IUnitOfWork unitOfWork) { - foreach (var name in names) - { - if (string.IsNullOrEmpty(name.Trim())) continue; + // Normalize tag names once and store them in a hash set for quick lookups + // Create a dictionary: normalized => original + var normalizedToOriginal = tagNames + .Select(t => new { Original = t, Normalized = t.ToNormalized() }) + .GroupBy(x => x.Normalized) // in case of duplicates + .ToDictionary(g => g.Key, g => g.First().Original); - var added = false; - var normalizedName = name.ToNormalized(); - - var genre = allTags.FirstOrDefault(p => - p.NormalizedTitle.Equals(normalizedName)); - if (genre == null) - { - added = true; - genre = new TagBuilder(name).Build(); - allTags.Add(genre); - } - - action(genre, added); - } - } - - public static void KeepOnlySameTagBetweenLists(ICollection existingTags, ICollection removeAllExcept, Action? action = null) - { - var existing = existingTags.ToList(); - foreach (var genre in existing) - { - var existingPerson = removeAllExcept.FirstOrDefault(g => genre.NormalizedTitle.Equals(g.NormalizedTitle)); - if (existingPerson != null) continue; - existingTags.Remove(genre); - action?.Invoke(genre); - } - - } - - /// - /// Adds the tag to the list if it's not already in there. This will ignore the ExternalTag. - /// - /// - /// - public static void AddTagIfNotExists(ICollection metadataTags, Tag tag) - { - var existingGenre = metadataTags.FirstOrDefault(p => - p.NormalizedTitle == tag.Title.ToNormalized()); - if (existingGenre == null) - { - metadataTags.Add(tag); - } - } - - public static void AddTagIfNotExists(BlockingCollection metadataTags, Tag tag) - { - var existingGenre = metadataTags.FirstOrDefault(p => - p.NormalizedTitle == tag.Title.ToNormalized()); - if (existingGenre == null) - { - metadataTags.Add(tag); - } - } - - /// - /// Remove tags on a list - /// - /// Used to remove before we update/add new tags - /// Existing tags on Entity - /// Tags from metadata - /// Callback which will be executed for each tag removed - public static void RemoveTags(ICollection existingTags, IEnumerable tags, Action? action = null) - { - var normalizedTags = tags.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList(); - foreach (var person in normalizedTags) - { - var existingTag = existingTags.FirstOrDefault(p => person.Equals(p.NormalizedTitle)); - if (existingTag == null) continue; - - existingTags.Remove(existingTag); - action?.Invoke(existingTag); - } - - } - - public static void UpdateTagList(ICollection? tags, Series series, IReadOnlyCollection allTags, Action handleAdd, Action onModified) - { - if (tags == null) return; + var normalizedTagsToAdd = new HashSet(normalizedToOriginal.Keys); + var existingTagsSet = new HashSet(chapter.Tags.Select(t => t.NormalizedTitle)); var isModified = false; - // 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.Tags.ToList(); - foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null)) + + // Remove tags that are no longer present in the new list + var tagsToRemove = chapter.Tags + .Where(t => !normalizedTagsToAdd.Contains(t.NormalizedTitle)) + .ToList(); + + if (tagsToRemove.Count != 0) { - // Remove tag - series.Metadata.Tags.Remove(existing); + foreach (var tagToRemove in tagsToRemove) + { + chapter.Tags.Remove(tagToRemove); + } isModified = true; } - // At this point, all tags that aren't in dto have been removed. - foreach (var tagTitle in tags.Select(t => t.Title)) - { - var normalizedTitle = tagTitle.ToNormalized(); - var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle)); - if (existingTag != null) - { - if (series.Metadata.Tags.All(t => t.NormalizedTitle != normalizedTitle)) - { + // Get all normalized titles for bulk lookup from the database + var existingTagTitles = await unitOfWork.DataContext.Tag + .Where(t => normalizedTagsToAdd.Contains(t.NormalizedTitle)) + .ToDictionaryAsync(t => t.NormalizedTitle); - handleAdd(existingTag); - isModified = true; - } - } - else + // Find missing tags that are not already in the database + var missingTags = normalizedTagsToAdd + .Where(nt => !existingTagTitles.ContainsKey(nt)) + .Select(nt => new TagBuilder(normalizedToOriginal[nt]).Build()) + .ToList(); + + // Add missing tags to the database if any + if (missingTags.Count != 0) + { + unitOfWork.DataContext.Tag.AddRange(missingTags); + await unitOfWork.CommitAsync(); // Commit once after adding missing tags to avoid multiple DB calls + isModified = true; + + // Update the dictionary with newly inserted tags for easier lookup + foreach (var tag in missingTags) { - // Add new tag - handleAdd(new TagBuilder(tagTitle).Build()); + existingTagTitles[tag.NormalizedTitle] = tag; + } + } + + // Add the new or existing tags to the chapter + foreach (var normalizedTitle in normalizedTagsToAdd) + { + if (existingTagsSet.Contains(normalizedTitle)) continue; + + var tag = existingTagTitles[normalizedTitle]; + chapter.Tags.Add(tag); + isModified = true; + } + + // Commit changes if modifications were made to the chapter's tags + if (isModified) + { + await unitOfWork.CommitAsync(); + } + } + + /// + /// Returns a list of strings separated by ',', distinct by normalized names, already trimmed and empty entries removed. + /// + /// + /// + public static IList GetTagValues(string comicInfoTagSeparatedByComma) + { + // TODO: Refactor this into an Extension + if (string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) + { + return ImmutableList.Empty; + } + + return comicInfoTagSeparatedByComma.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .DistinctBy(Parser.Normalize) + .ToList(); + } + + + public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) + { + UpdateTagList((existingDbTags ?? []).Select(t => t.Title).ToList(), series, newTags, handleAdd, onModified); + } + + public static void UpdateTagList(ICollection? existingDbTags, Series series, IReadOnlyCollection newTags, Action handleAdd, Action onModified) + { + if (existingDbTags == null) return; + + var isModified = false; + + // Convert tags and existing genres to hash sets for quick lookups by normalized title + var existingTagSet = new HashSet(existingDbTags.Select(t => t.ToNormalized())); + var dbTagSet = new HashSet(series.Metadata.Tags.Select(g => g.NormalizedTitle)); + + // Remove tags that are no longer present in the input tags + var existingTagsCopy = series.Metadata.Tags.ToList(); // Copy to avoid modifying collection while iterating + foreach (var existing in existingTagsCopy) + { + if (!existingTagSet.Contains(existing.NormalizedTitle)) // This correctly ensures removal of non-present tags + { + series.Metadata.Tags.Remove(existing); isModified = true; } } + // Prepare a dictionary for quick lookup of genres from the `newTags` collection by normalized title + var allTagsDict = newTags.ToDictionary(t => t.NormalizedTitle); + + // Add new tags from the input list + foreach (var tagDto in existingDbTags) + { + var normalizedTitle = tagDto.ToNormalized(); + + if (dbTagSet.Contains(normalizedTitle)) continue; // This prevents re-adding existing genres + + if (allTagsDict.TryGetValue(normalizedTitle, out var existingTag)) + { + handleAdd(existingTag); // Add existing tag from allTagsDict + } + else + { + handleAdd(new TagBuilder(tagDto).Build()); // Add new genre if not found + } + isModified = true; + } + + // Call onModified if any changes were made if (isModified) { onModified(); } } } - -#nullable disable diff --git a/API/I18N/ca.json b/API/I18N/ca.json new file mode 100644 index 000000000..b314a9374 --- /dev/null +++ b/API/I18N/ca.json @@ -0,0 +1,65 @@ +{ + "confirm-email": "Heu de confirmar l'adreça electrònica primer", + "invalid-password": "La contrasenya no és vàlida", + "nothing-to-do": "Res a fer", + "no-user": "El compte no existeix", + "invalid-token": "El testimoni no és vàlid", + "volume-num": "Volum {0}", + "book-num": "Llibre {0}", + "issue-num": "Número {0}{1}", + "chapter-num": "Capítol {0}", + "check-updates": "Comprova si hi ha actualitzacions", + "invalid-username": "El nom d'usuari no és vàlid", + "chapter-doesnt-exist": "El capítol no existeix", + "collection-updated": "S'ha actualitzat la col·lecció correctament", + "collection-deleted": "S'ha suprimit la col·lecció", + "collection-doesnt-exist": "La col·lecció no existeix", + "collection-already-exists": "La col·lecció ja existeix", + "person-doesnt-exist": "La persona no existeix", + "device-doesnt-exist": "El dispositiu no existeix", + "volume-doesnt-exist": "El volum no existeix", + "series-doesnt-exist": "La sèrie no existeix", + "no-cover-image": "No hi ha cap imatge de coberta", + "library-name-exists": "El nom de la biblioteca ja existeix. Trieu un nom únic per al servidor.", + "generic-library": "S'ha produït un error greu. Torneu-ho a provar.", + "invalid-filename": "El nom de fitxer no és vàlid", + "file-doesnt-exist": "El fitxer no existeix", + "user-doesnt-exist": "El compte no existeix", + "no-library-access": "El compte no té accés a aquesta biblioteca", + "library-doesnt-exist": "La biblioteca no existeix", + "invalid-path": "El camí no és vàlid", + "libraries": "Totes les biblioteques", + "browse-libraries": "Explora per biblioteques", + "collections": "Totes les col·leccions", + "browse-collections": "Explora per col·leccions", + "smart-filters": "Filtres intel·ligents", + "external-sources": "Fonts externes", + "browse-external-sources": "Explora les fonts externes", + "search": "Cerca", + "search-description": "Cerca sèries, col·leccions o llistes de lectura", + "external-source-required": "Cal la clau de l'API i l'amfitrió", + "smart-filter-doesnt-exist": "El filtre intel·ligent no existeix", + "collection-tag-duplicate": "Ja existeix una col·lecció amb aquest nom", + "device-duplicate": "Ja existeix un dispositiu amb aquest nom", + "send-to-permission": "No és possible enviar fitxers que no siguin EPUB o PDF perquè el Kindle no els admet", + "browse-smart-filters": "Explora per filtres intel·ligents", + "external-source-already-exists": "La font externa ja existeix", + "device-not-created": "Aquest dispositiu no existeix encara. Creeu-lo primer", + "external-source-doesnt-exist": "La font externa no existeix", + "backup": "Còpia de seguretat", + "file-missing": "No s'ha trobat el fitxer al llibre", + "reading-list-deleted": "S'ha suprimit la llista de lectura", + "generic-device-delete": "S'ha produït un error en suprimir el dispositiu", + "reading-list-position": "No s'ha pogut actualitzar la posició", + "generic-reading-list-delete": "S'ha produït un problema en suprimir la llista de lectura", + "generic-device-create": "S'ha produït un error en crear el dispositiu", + "generic-device-update": "S'ha produït un error en actualitzar el dispositiu", + "reading-list-doesnt-exist": "La llista de lectura no existeix", + "update-metadata-fail": "No s'han pogut actualitzar les metadades", + "reading-list-name-exists": "Ja existeix una llista de lectura amb aquest nom", + "ip-address-invalid": "L'adreça IP «{0}» no és vàlida", + "reading-list-item-delete": "No s'han pogut suprimir els elements", + "generic-reading-list-create": "S'ha produït un problema en crear la llista de lectura", + "generic-reading-list-update": "S'ha produït un problema en actualitzar la llista de lectura", + "generic-create-temp-archive": "S'ha produït un problema en crear l'arxiu temporal" +} diff --git a/API/I18N/cs.json b/API/I18N/cs.json index 043994c23..4b9774218 100644 --- a/API/I18N/cs.json +++ b/API/I18N/cs.json @@ -18,7 +18,6 @@ "age-restriction-update": "Při aktualizaci věkového omezení došlo k chybě", "no-user": "Uživatel neexistuje", "username-taken": "Uživatelské jméno je již používáno", - "bad-credentials": "Vaše přihlašovací údaje nejsou správné", "invalid-password": "Neplatné heslo", "invalid-token": "Neplatný token", "user-already-confirmed": "Uživatel je již potvrzen", @@ -50,7 +49,7 @@ "generic-device-update": "Při aktualizaci zařízení došlo k chybě", "generic-device-delete": "Při mazání zařízení došlo k chybě", "greater-0": "{0} musí být větší než 0", - "send-to-kavita-email": "Odeslat do zařízení nelze použít s e-mailovou službou Kavita. Nakonfigurujte si prosím vlastní.", + "send-to-kavita-email": "Odeslat do zařízení nelze použít s e-mailovou službou Kavita. Nakonfigurujte si prosím vlastní", "send-to-device-status": "Přenos souborů do vašeho zařízení", "generic-send-to": "Při odesílání souborů do zařízení došlo k chybě", "admin-already-exists": "Správce již existuje", @@ -177,5 +176,36 @@ "unable-to-reset-k+": "Licenci Kavita+ nelze resetovat kvůli chybě. Obraťte se na podporu Kavita+", "browse-more-in-genre": "Procházet další v {0}", "recently-updated": "Nedávno aktualizované", - "invalid-email": "E-mail v záznamech pro uživatele není platný e-mail. Všechny odkazy najdete v protokolech." + "invalid-email": "E-mail v záznamech pro uživatele není platný e-mail. Všechny odkazy najdete v protokolech.", + "email-not-enabled": "Email není na tomto serveru aktivní. Tuto akci nelze provést.", + "license-check": "Kontrola licence", + "process-scrobbling-events": "Zpracovat Scrobbling události", + "report-stats": "Statistiky hlášení", + "check-scrobbling-tokens": "Kontrola Scrobbling tokenů", + "cleanup": "Čistka", + "process-processed-scrobbling-events": "Zpracovat zpracované Scrobbling události", + "account-email-invalid": "Email v souboru účtu správce není platný email. Nelze poslat testovací email.", + "email-settings-invalid": "Chybí informace o nastavení emailu. Ujistěte se, zda-li jsou uložena všechna nastavení.", + "send-to-unallowed": "Nemůžete odeslat do zařízení které není vaše", + "send-to-size-limit": "Soubor(y) které se snažíte poslat jsou příliš velké pro vašeho poskytovatele emailu", + "error-import-stack": "Při importu zásobníku MAL došlo k chybě", + "collection-already-exists": "Kolekce již existuje", + "generic-cover-person-save": "Nebylo možné uložit cover obrázek pro Osobu", + "generic-cover-volume-save": "Nebylo možné uložit cover obrázek pro Volume", + "check-updates": "Zkontrolovat aktualizace", + "kavita+-data-refresh": "Obnovení dat Kavita+", + "scan-libraries": "Skenovat Knihovny", + "backup": "Záloha", + "update-yearly-stats": "Aktualizovat roční statistiky", + "remove-from-want-to-read": "Vyčištění Chci si přečíst", + "person-doesnt-exist": "Osoba neexistuje", + "person-name-required": "Jméno osoby je povinné a nesmí být prázdné", + "person-name-unique": "Jméno osoby musí být unikátní", + "person-image-doesnt-exist": "Osoba neexistuje v databázi CoversDB", + "email-taken": "Email je již používán", + "kavitaplus-restricted": "Toto je omezeno pouze na Kavita+", + "dashboard-stream-only-delete-smart-filter": "Z ovládacího panelu lze odstranit pouze streamy chytrých filtrů", + "smart-filter-name-required": "Vyžaduje se název chytrého filtru", + "smart-filter-system-name": "Nelze použít název streamu poskytovaného systémem", + "sidenav-stream-only-delete-smart-filter": "Z postranní navigace lze odstranit pouze streamy chytrých filtrů" } diff --git a/API/I18N/da.json b/API/I18N/da.json new file mode 100644 index 000000000..645e70303 --- /dev/null +++ b/API/I18N/da.json @@ -0,0 +1,103 @@ +{ + "locked-out": "Du er låst ude, grundet for mange forsøg. Vent venligt 10 minutter.", + "disabled-account": "Din konto er deaktiveret. Kontakt server administratoren.", + "register-user": "Der opstod en fejl i forbindelse med brugerregistreringen", + "validate-email": "Der opstod en fejl i forbindelse med validering af emailen: {0}", + "confirm-email": "Du skal bekræfte din email først", + "confirm-token-gen": "Der opstod en fejl i forbindelse med oprettelsen af en bekræftigelsestoken", + "invalid-password": "Ugyldigt kodeord", + "username-taken": "Brugernavenet er allerede taget", + "generate-token": "Der opstod en fejl under generering af bekræftigelsesemailtoken. Se logbeskederne", + "generic-user-update": "Der opstod en fejl ved opdatering af brugeren", + "user-already-registered": "Brugeren er allerede registreret som {0}", + "manual-setup-fail": "Manuel opsætning kunne ikke færdiggøres. Venligst annuller, og genopret invitationen", + "not-accessible-password": "Din server er ikke tilgængelig. Et link til nulstilling af kodeord kan findes i loggen", + "user-migration-needed": "Denne bruger skal migreres. Få dem til at logge ud og ind igen for at starte migreringsflowet", + "invalid-username": "Ugyldigt brugernavn", + "generic-invite-email": "Der opstod en fejl under et forsøg på at sende invitationmail'en igen", + "check-updates": "Tjek for opdateringer", + "update-yearly-stats": "Opdater årlige statistikker", + "no-user": "Brugeren eksistere ikke", + "user-already-confirmed": "Brugeren er allerede bekræftet", + "admin-already-exists": "Administrator eksistere allerede", + "chapter-doesnt-exist": "Kapitel findes ikke", + "not-accessible": "Din server er ikke eksternt tilgængelig", + "email-sent": "Email sendt", + "book-num": "Bog {0}", + "issue-num": "Nummer {0}{1}", + "chapter-num": "Kapitel {0}", + "backup": "Backup", + "age-restriction-update": "Der opstod en fejl ved opdateringen af aldersbegrænsningen", + "user-already-invited": "Brugeren er allerede inviteret med denne mail, og har ikke accepteret invitationen endnu.", + "invalid-email-confirmation": "Ugyldig mailbekræftigelse", + "password-updated": "Kodeord opdateret", + "denied": "Ikke tilladt", + "permission-denied": "Du har ikke tilladelse til at udføre denne operation", + "nothing-to-do": "Intet at lave", + "password-required": "Du skal skrive dit kodeord for at ændre din konto, medmindre du er en administrator", + "invalid-payload": "Ugyldig payload", + "invalid-token": "Ugyldig token", + "unable-to-reset-key": "Noget gik galt, det var ikke muligt at nulstille nøglen", + "share-multiple-emails": "Du kan ikke dele emails henover flere kontoer", + "generic-password-update": "Der opstod en uventet fejl i forbindelse med bekræftigelsen af new kodeord", + "generic-invite-user": "Der opstod et problem i forbindelse med at invitere brugeren. Tjek venligst loggen.", + "generic-user-email-update": "Det var ikke muligt at opdatere brugerens email. Tjek loggen.", + "forgot-password-generic": "En email vil blive sendt til emailen, hvis den eksistere i vores database", + "critical-email-migration": "Der opstod en fejl i forbindelse med email migration. Kontakt support", + "email-not-enabled": "Email er ikke slået til på denne server. Du har ikke mulighed for at udføre denne handling.", + "file-missing": "Filen blev ikke fundet i bogen", + "collection-updated": "Samlingen er opdateret", + "collection-deleted": "Samlingen er slettet", + "collection-doesnt-exist": "Samlingen eksistere ikke", + "generic-error": "Noget gik galt, prøv igen", + "collection-already-exists": "Samlingen eksistere allerede", + "error-import-stack": "Der opstod en fejl i forbindelse med import af MAL stack", + "device-doesnt-exist": "Enheden eksistere ikke", + "generic-device-create": "Der opstod en fejl i forbindelse med oprettelsen af enheden", + "generic-device-update": "Der opstod en fejl i forbindelse med opdatering af enheden", + "generic-device-delete": "Der opstod en fejl i forbindelse med sletningen af enheden", + "greater-0": "{0} skal være større end 0", + "generic-send-to": "Der opstod en fejl i forbindelse med at sende filerne til enheden", + "send-to-unallowed": "Du kan ikke sende til en enhed der ikke er din", + "send-to-device-status": "Overfører filer til din enhed", + "series-doesnt-exist": "Serien eksistere ikke", + "volume-doesnt-exist": "Bindet eksistere ikke", + "bookmarks-empty": "Bogmærker kan ikke være tomme", + "no-cover-image": "Intet omslagsbillede", + "file-doesnt-exist": "Filen eksistere ikke", + "library-name-exists": "Biblioteksnavnet eksistere allerede. Vælg venligst et unikt navn til serveren.", + "generic-library": "Der opstod en kritisk fejl. Prøv igen.", + "no-library-access": "Brugeren har ikke adgang til dette bibliotek", + "generic-library-update": "Der opstod en kritisk fejl i forbindelse med opdatering af biblioteket.", + "bookmark-permission": "Du har ikke tilladelse til at oprette eller fjerne bogmærker", + "name-required": "Navnet må ikke være tomt", + "valid-number": "Skal være et gyldigt sidetal", + "reading-list-permission": "Enten har du ikke tilladelse til denne læseliste, eller også eksistere listen ikke", + "reading-list-position": "Kunne ikke opdatere position", + "reading-list-item-delete": "Kunne ikke slette elementerne", + "reading-list-deleted": "Læselisten er opdateret", + "generic-reading-list-delete": "Der opstod en fejl i forbindelse med sletningen af læselisten", + "generic-reading-list-update": "Der opstod en fejl i forbindelse med opdatering af læselisten", + "generic-reading-list-create": "Der opstod en fejl i forbindelse med oprettelsen af læselisten", + "reading-list-doesnt-exist": "Læselisten eksistere ikke", + "libraries-restricted": "Brugeren har ikke adgang til nogen biblioteker", + "update-metadata-fail": "Kunne ikke opdatere metadata", + "age-restriction-not-applicable": "Ingen Restriktioner", + "job-already-running": "Opgaven kører allerede", + "ip-address-invalid": "IP-adressen '{0}' er ugyldig", + "bookmark-doesnt-exist": "Bogmærket eksistere ikke", + "must-be-defined": "{0} skal være defineret", + "library-doesnt-exist": "Biblioteket eksistere ikke", + "invalid-filename": "ugyldigt filnavn", + "invalid-path": "Ugyldig sti", + "invalid-access": "Ugyldig adgang", + "user-doesnt-exist": "Brugeren eksistere ikke", + "pdf-doesnt-exist": "PDF'en burde eksistere, men gør ikke", + "perform-scan": "Kør venligst en skanning af denne serie eller bibliotek, og prøv igen", + "bookmark-save": "Bogmærket kunne ikke gemmes", + "reading-list-updated": "Opdateret", + "series-restricted": "Brugeren har ikke adgang til denne serie", + "generic-series-delete": "Der opstod en fejl i forbindelse med sletningen af serien", + "generic-series-update": "Der opstod en fejl i forbindelse med opdateringen af serien", + "series-updated": "Succesfuldt opdateret" +} diff --git a/API/I18N/de.json b/API/I18N/de.json index 50a7557d4..d91cc8f25 100644 --- a/API/I18N/de.json +++ b/API/I18N/de.json @@ -29,7 +29,6 @@ "user-already-invited": "Der Benutzer ist bereits unter dieser E-Mail eingeladen und hat die Einladung noch nicht angenommen.", "invalid-email-confirmation": "Ungültige E-Mail Bestätigung", "not-accessible": "Es kann von außen nicht auf Ihren Server zugegriffen werden", - "bad-credentials": "Ihre Anmeldedaten sind nicht korrekt", "unable-to-reset-key": "Etwas ist schief gelaufen, Schlüssel kann nicht zurückgesetzt werden", "invalid-token": "Ungültiger Token", "email-sent": "E-Mail versendet", @@ -39,10 +38,10 @@ "generic-error": "Es ist ein Fehler ist aufgetreten, bitte versuchen Sie es erneut", "device-doesnt-exist": "Das Gerät existiert nicht", "generic-device-create": "Beim Erstellen des Geräts ist ein Fehler aufgetreten", - "send-to-kavita-email": "Das Senden an Gerät kann nicht mit dem E-Mail-Dienst von Kavita durchgeführt werden. Bitte konfigurieren Sie Ihren eigenen.", + "send-to-kavita-email": "Senden an Gerät kann ohne E-Mail-Einrichtung nicht verwendet werden", "send-to-device-status": "Übertrage Dateien auf Ihr Gerät", "series-doesnt-exist": "Die Serie existiert nicht", - "volume-doesnt-exist": "Das Band existiert nicht", + "volume-doesnt-exist": "Der Band existiert nicht", "no-cover-image": "Kein Coverbild", "bookmark-doesnt-exist": "Lesezeichen ist nicht vorhanden", "must-be-defined": "{0} muss definiert sein", @@ -112,7 +111,7 @@ "user-no-access-library-from-series": "Der Benutzer hat keinen Zugang zu der Bibliothek, der zu dieser Serie gehört", "series-restricted-age-restriction": "Benutzer darf diese Serie aufgrund von Altersbeschränkungen nicht sehen", "book-num": "Buch {0}", - "issue-num": "Fehler {0}{1}", + "issue-num": "Ausgabe {0}{1}", "chapter-num": "Kapitel {0}", "reading-list-position": "Position konnte nicht aktualisiert werden", "libraries-restricted": "Benutzer hat keinen Zugriff auf jegliche Bibliothek", @@ -176,5 +175,37 @@ "browse-more-in-genre": "Mehr in {0} stöbern", "recently-updated": "Zuletzt aktualisiert", "browse-recently-updated": "Zuletzt aktualisiert durchsuchen", - "unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support" + "unable-to-reset-k+": "Aufgrund eines Fehlers konnte die Kavita+ Lizenz nicht zurückgesetzt werden. Kontaktieren Sie den Kavita+ Support", + "email-not-enabled": "Der Mailversand ist auf diesem Server nicht aktiviert. Sie können diese Aktion nicht durchführen.", + "invalid-email": "Die für den Benutzer hinterlegte E-Mail ist ungültig. Links finden Sie in den Logs.", + "send-to-unallowed": "Sie können nicht an ein Gerät senden, das nicht Ihnen gehört", + "send-to-size-limit": "Die Datei(en), die Sie zu senden versuchen, sind zu groß für Ihren E-Mail-Anbieter", + "check-updates": "Updates überprüfen", + "email-settings-invalid": "E-Mail-Einstellungen fehlen Informationen. Stellen Sie sicher, dass alle E-Mail-Einstellungen gespeichert sind.", + "account-email-invalid": "Die für das Admin-Konto gespeicherte E-Mail ist nicht gültig. Test-E-Mail kann nicht gesendet werden.", + "license-check": "Lizenzprüfung", + "process-scrobbling-events": "Verarbeite Scrobble-Events", + "report-stats": "Statistiken melden", + "check-scrobbling-tokens": "Überprüfe die Scrobbling-Tokens", + "cleanup": "Bereinigung", + "process-processed-scrobbling-events": "Verarbeitete Scrobbling Ereignisse", + "remove-from-want-to-read": "Möchte Lesen Liste Bereinigung", + "kavita+-data-refresh": "Kavita+ Daten Aktualisierung", + "backup": "Sichern", + "update-yearly-stats": "Aktualisiere Jahresstatistiken", + "error-import-stack": "Es gab Problem beim Importieren des MAL stack", + "scan-libraries": "Bibliotheken scannen", + "collection-already-exists": "Sammlung existiert schon", + "generic-cover-volume-save": "Coverbild kann nicht auf Volume gespeichert werden", + "generic-cover-person-save": "Das Titelbild kann nicht zu dieser Person gespeichert werden", + "person-doesnt-exist": "Die Person existiert nicht", + "person-name-required": "Personenname ist erforderlich und darf nicht null sein", + "person-name-unique": "Der Name der Person muss eindeutig sein", + "person-image-doesnt-exist": "Die Person existiert nicht in CoversDB", + "email-taken": "E-Mail bereits in Gebrauch", + "kavitaplus-restricted": "Dies ist nur auf Kavita+ beschränkt", + "sidenav-stream-only-delete-smart-filter": "Nur Smart-Filter-Streams können aus der Seitennavigation gelöscht werden", + "dashboard-stream-only-delete-smart-filter": "Nur Smart-Filter-Streams können aus dem Dashboard gelöscht werden", + "smart-filter-system-name": "Du kannst den Namen eines vom System bereitgestellten Streams nicht verwenden", + "smart-filter-name-required": "Name des Smart Filters erforderlich" } diff --git a/API/I18N/el.json b/API/I18N/el.json new file mode 100644 index 000000000..7a1173662 --- /dev/null +++ b/API/I18N/el.json @@ -0,0 +1,71 @@ +{ + "confirm-email": "Πρέπει να επιβεβαιώσετε το email σας πρώτα", + "disabled-account": "Ο λογαριασμός σας είναι απενεργοποιημένος. Επικοινωνήστε με τον διαχειριστή του διακομιστή.", + "permission-denied": "Δεν επιτρέπεται αυτή η λειτουργία", + "invalid-password": "Άκυρος κωδικός πρόσβασης", + "invalid-token": "Άκυρο token", + "unable-to-reset-key": "Κάτι πήγε λάθος, αδυναμία επαναφοράς του κλειδιού", + "invalid-payload": "Άκυρο payload", + "nothing-to-do": "Τίποτα να κάνετε", + "share-multiple-emails": "Δεν μπορείτε να μοιράζεστε πολλαπλά email σε πολλαπλούς λογαριασμούς", + "generate-token": "Υπήρξε ένα πρόβλημα δημιουργώντας ένα token επιβεβαίωσης email. Δείτε τα αρχεία καταγραφής", + "no-user": "Ο χρήστης δεν υπάρχει", + "username-taken": "Το ψευδώνυμο είναι κατειλημμένο", + "generic-user-update": "Υπήρξε μια εξαίρεση κατά την ενημέρωση του χρήστη", + "user-already-registered": "Ο χρήστης είναι ήδη εγγεγραμμένος ως {0}", + "chapter-doesnt-exist": "Το κεφάλαιο δεν υπάρχει", + "file-missing": "Το αρχείο δεν βρέθηκε στο βιβλίο", + "send-to-unallowed": "Δεν μπορείτε να στείλετε σε μία συσκευή που δεν είναι δική σας", + "generic-favicon": "Υπήρξε ένα πρόβλημα με το favicon για το domain", + "invalid-filename": "Άκυρο Όνομα Αρχείου", + "no-cover-image": "Δεν υπάρχει εικόνα εξωφύλλου", + "generic-password-update": "Υπήρξε ένα απροσδόκητο σφάλμα κατά την επιβεβαίωση του νέου κωδικού πρόσβασης", + "locked-out": "Έχετε αποκλειστεί από πάρα πολλές προσπάθειες εξουσιοδότησης. Παρακαλώ περιμένετε 10 λεπτά.", + "validate-email": "Υπήρξε ένα πρόβλημα επιβεβαιώνοντας το email σας: {0}", + "password-required": "Πρέπει να εισάγετε τον υπάρχοντα κωδικό πρόσβασης σας για να αλλάξετε το λογαριασμό σας, εκτός αν είστε διαχειριστής", + "register-user": "Κάτι πήγε λάθος κατά την εγγραφή του χρήστη", + "confirm-token-gen": "Υπήρξε ένα πρόβλημα με τη δημιουργίας ενός token επιβεβαίωσης", + "denied": "Δεν επιτρέπεται", + "age-restriction-update": "Υπήρξε ένα πρόβλημα ενημερώνοντας τον περιορισμό ηλικίας", + "user-already-confirmed": "Ο χρήστης έχει ήδη επιβεβαιωθεί", + "manual-setup-fail": "Η χειροκίνητη ρύθμιση δεν μπόρεσε να ολοκληρωθεί. Παρακαλούμε ακυρώστε και δημιουργήστε ξανά την πρόσκληση", + "invalid-email-confirmation": "Άκυρο email επιβεβαίωσης", + "user-already-invited": "Ο χρήστης έχει ήδη προσκληθεί με αυτό το email και δεν έχει αποδεχτεί ακόμη την πρόσκληση.", + "generic-invite-user": "Υπήρξε ένα πρόβλημα με την πρόσκληση του χρήστη. Ελέγξτε τα αρχεία καταγραφής.", + "generic-user-email-update": "Αδυναμία ενημέρωσης του email του χρήστη. Ελέγξτε τα αρχεία καταγραφής.", + "forgot-password-generic": "Θα σας σταλθεί ένα email αν υπάρχει στη βάση δεδομένων μας", + "password-updated": "Ενημερωμένος κωδικός πρόσβασης", + "not-accessible-password": "Ο διακομιστής σας δεν είναι προσβάσιμος. Ο σύνδεσμος επαναφοράς κωδικού πρόσβασης σας βρίσκεται στα αρχεία καταγραφής", + "invalid-email": "Το email στο αρχείο του χρήστη δεν είναι έγκυρο. Δείτε τα αρχεία καταγραφής για τυχόν συνδέσμους.", + "not-accessible": "Ο διακομιστής σας δεν είναι προσβάσιμος εξωτερικά", + "email-sent": "Στάλθηκε email", + "user-migration-needed": "Αυτός ο χρήστης πρέπει να μεταβιβαστεί. Βάλτε τον να αποσυνδεθεί και να συνδεθεί για να ενεργοποιήσετε μια ροή μετάβασης.", + "invalid-username": "Άκυρο ψευδώνυμο", + "critical-email-migration": "Υπήρξε ένα λάθος κατά τη διάρκεια της μετάβασης email. Επικοινωνήστε με την υποστήριξη", + "email-not-enabled": "Το email δεν είναι ενεργοποιημένο σε αυτόν τον διακομιστή. Δεν μπορείτε να εκτελέσετε αυτή την ενέργεια.", + "generic-invite-email": "Υπήρξε πρόβλημα με την επαναποστολή του email πρόσκλησης", + "admin-already-exists": "Υπάρχει ήδη διαχειριστής", + "account-email-invalid": "Το email στο αρχείο του λογαριασμού διαχειριστή δεν είναι έγκυρο. Δεν είναι δυνατή η αποστολή δοκιμαστικού email.", + "email-settings-invalid": "Λείπουν πληροφορίες από τις ρυθμίσεις email. Βεβαιωθείτε ότι έχουν αποθηκευτεί όλες οι ρυθμίσεις email.", + "collection-updated": "Η συλλογή ενημερώθηκε επιτυχώς", + "generic-error": "Κάτι πήγε στραβά, δοκιμάστε ξανά", + "collection-deleted": "Η συλλογή διαγράφτηκε", + "collection-doesnt-exist": "Η συλλογή δεν υπάρχει", + "collection-already-exists": "Η συλλογή υπάρχει ήδη", + "error-import-stack": "Υπήρξε ένα πρόβλημα εισαγωγής στοίβας MAL", + "generic-device-update": "Υπήρξε ένα πρόβλημα στην ενημέρωση της συσκευής", + "device-doesnt-exist": "Η συσκευή δεν υπάρχει", + "generic-device-create": "Υπήρξε ένα πρόβλημα στην δημιουργία της συσκευής", + "generic-device-delete": "Υπήρξε ένα πρόβλημα στην διαγραφή της συσκευής", + "greater-0": "{0} πρέπει να είναι μεγαλύτερο από το 0", + "send-to-kavita-email": "Η αποστολή στη συσκευή δεν μπορεί να χρησιμοποιηθεί χωρίς τη ρύθμιση Email", + "send-to-size-limit": "Το(α) αρχείο(α) που προσπαθείτε να στείλετε είναι πολύ μεγάλο(α) για τον emailer σας", + "generic-send-to": "Υπήρξε ένα πρόβλημα κατά την αποστολή του(ων) αρχείου(ων) στην συσκευή", + "send-to-device-status": "Μεταφέρονται αρχεία στην συσκευή σας", + "series-doesnt-exist": "Η σειρά δεν υπάρχει", + "volume-doesnt-exist": "Ο τόμος δεν υπάρχει", + "bookmarks-empty": "Οι σελιδοδείκτες δεν μπορούν να είναι άδειοι", + "bookmark-doesnt-exist": "Δεν υπάρχει ο σελιδοδείκτης", + "must-be-defined": "{0} πρέπει να οριστεί", + "file-doesnt-exist": "Το αρχείο δεν υπάρχει" +} diff --git a/API/I18N/en.json b/API/I18N/en.json index f24e76d9d..6e37a3cd9 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -1,6 +1,5 @@ { "confirm-email": "You must confirm your email first", - "bad-credentials": "Your credentials are not correct", "locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.", "disabled-account": "Your account is disabled. Contact the server admin.", "register-user": "Something went wrong when registering user", @@ -19,6 +18,7 @@ "age-restriction-update": "There was an error updating the age restriction", "no-user": "User does not exist", "username-taken": "Username already taken", + "email-taken": "Email already in use", "user-already-confirmed": "User is already confirmed", "generic-user-update": "There was an exception when updating the user", "manual-setup-fail": "Manual setup is unable to be completed. Please cancel and recreate the invite", @@ -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", @@ -48,6 +50,13 @@ "collection-deleted": "Collection deleted", "generic-error": "Something went wrong, please try again", "collection-doesnt-exist": "Collection does not exist", + "collection-already-exists":"Collection already exists", + "error-import-stack": "There was an issue importing MAL stack", + + "person-doesnt-exist": "Person does not exist", + "person-name-required": "Person name is required and must not be null", + "person-name-unique": "Person name must be unique", + "person-image-doesnt-exist": "Person does not exist in CoversDB", "device-doesnt-exist": "Device does not exist", "generic-device-create": "There was an error when creating the device", @@ -56,7 +65,7 @@ "greater-0": "{0} must be greater than 0", "send-to-kavita-email": "Send to device cannot be used without Email setup", "send-to-unallowed":"You cannot send to a device that isn't yours", - "send-to-size-limit": "The file(s) you are trying to send are too large for your emailer", + "send-to-size-limit": "The file(s) you are trying to send are too large for your email provider", "send-to-device-status": "Transferring files to your device", "generic-send-to": "There was an error sending the file(s) to the device", "series-doesnt-exist": "Series does not exist", @@ -135,6 +144,8 @@ "generic-cover-reading-list-save": "Unable to save cover image to Reading List", "generic-cover-chapter-save": "Unable to save cover image to Chapter", "generic-cover-library-save": "Unable to save cover image to Library", + "generic-cover-person-save": "Unable to save cover image to Person", + "generic-cover-volume-save": "Unable to save cover image to Volume", "access-denied": "You do not have access", "reset-chapter-lock": "Unable to resetting cover lock for Chapter", @@ -175,6 +186,10 @@ "external-source-required": "ApiKey and Host required", "external-source-doesnt-exist": "External Source doesn't exist", "external-source-already-in-use": "There is an existing stream with this External Source", + "sidenav-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the SideNav", + "dashboard-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the dashboard", + "smart-filter-name-required": "Smart Filter name required", + "smart-filter-system-name": "You cannot use the name of a system provided stream", "not-authenticated": "User is not authenticated", "unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support", @@ -196,12 +211,24 @@ "reading-list-name-exists": "A reading list of this name already exists", "user-no-access-library-from-series": "User does not have access to the library this series belongs to", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", + "kavitaplus-restricted": "This is restricted to Kavita+ only", "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/I18N/es.json b/API/I18N/es.json index bc55639ae..ca1a5c38a 100644 --- a/API/I18N/es.json +++ b/API/I18N/es.json @@ -1,5 +1,4 @@ { - "bad-credentials": "Las credenciales son incorrectas", "confirm-email": "Debes confirmar el correo electrónico primero", "disabled-account": "La cuenta está deshabilitada. Contacta con un administrador.", "validate-email": "Ha habido un error al validar el correo: {0}", @@ -23,7 +22,7 @@ "bookmarks-empty": "Los marcadores no pueden estar vacíos", "must-be-defined": "{0} debe estar definido", "invalid-filename": "Nombre de archivo no válido", - "library-name-exists": "El nombre de la biblioteca ya existe. Por favor, elige un nombre único.", + "library-name-exists": "El nombre de la biblioteca ya existe. Elija un nombre unívoco para el servidor.", "user-doesnt-exist": "El usuario no existe", "library-doesnt-exist": "La biblioteca no existe", "age-restriction-update": "Ha ocurrido un error al actualizar la restricción de edad", @@ -39,11 +38,11 @@ "generic-device-create": "Ha ocurrido un error al crear el dispositivo", "greater-0": "{0} debe ser mayor que 0", "send-to-kavita-email": "Enviar al dispositivo no se puede utilizar sin configurar el correo electrónico", - "no-cover-image": "No hay imagen de portada", + "no-cover-image": "No hay imagen de cubierta", "bookmark-doesnt-exist": "El marcador no existe", "generic-favicon": "Ha ocurrido un error al obtener el icono para el dominio", "file-doesnt-exist": "El archivo no existe", - "generic-library": "Ha ocurrido un error fatal. Por favor, inténtalo de nuevo.", + "generic-library": "Ha ocurrido un error grave. Inténtelo de nuevo.", "no-library-access": "El usuario no tiene acceso a esta biblioteca", "no-user": "El usuario no existe", "username-taken": "El nombre de usuario ya existe", @@ -88,7 +87,7 @@ "update-metadata-fail": "No se han podido actualizar los metadatos", "generic-relationship": "Hubo un problema al actualizar las relaciones", "job-already-running": "Trabajo ya en ejecución", - "ip-address-invalid": "La dirección IP '{0}' no es válida", + "ip-address-invalid": "La dirección IP «{0}» no es válida", "bookmark-dir-permissions": "El directorio de marcadores no tiene los permisos correctos para que Kavita pueda utilizarlo", "total-backups": "El número total de copias de seguridad debe estar entre 1 y 30", "stats-permission-denied": "No está autorizado a ver las estadísticas de otro usuario", @@ -117,18 +116,18 @@ "generic-create-temp-archive": "Hubo un problema al crear un archivo temporal", "epub-malformed": "¡El archivo está malformado! No se puede leer.", "book-num": "Libro {0}", - "issue-num": "Incidencia {0}{1}", + "issue-num": "Número {0}{1}", "search-description": "Buscar series, colecciones o listas de lectura", "unable-to-register-k+": "No se ha podido registrar la licencia debido a un error. Póngase en contacto con el servicio de asistencia de Kavita", "bad-copy-files-for-download": "No se pueden copiar archivos al directorio temporal de descarga de archivos.", - "send-to-permission": "No se puede enviar archivos que no sean EPUB o PDF a dispositivos no compatibles con Kindle", + "send-to-permission": "No se pueden enviar archivos que no sean EPUB o PDF a los dispositivos porque Kindle no los admite", "progress-must-exist": "El progreso debe existir en el usuario", "epub-html-missing": "No se ha podido encontrar el HTML apropiado para esa página", "collection-tag-duplicate": "Ya existe una colección con este nombre", "device-duplicate": "Ya existe un dispositivo con este nombre", "collection-tag-title-required": "El título de la colección no puede estar vacío", "reading-list-title-required": "El título de la lista de lectura no puede estar vacío", - "device-not-created": "Este dispositivo aún no existe. Por favor, créelo primero", + "device-not-created": "Este dispositivo aún no existe. Créelo primero", "reading-list-name-exists": "Ya existe una lista de lectura con este nombre", "user-no-access-library-from-series": "El usuario no tiene acceso a la biblioteca a la que pertenece esta serie", "series-restricted-age-restriction": "El usuario no puede ver esta serie debido a restricciones de edad", @@ -170,7 +169,7 @@ "sidenav-stream-doesnt-exist": "SideNav Stream no existe", "external-source-doesnt-exist": "La fuente externa no existe", "external-sources": "Fuentes externas", - "external-source-required": "Se requiere la clave API y el host", + "external-source-required": "Se requiere la clave de API y el anfitrión", "smart-filter-already-in-use": "Existe una transmisión con este filtro inteligente", "invalid-email": "La dirección de correo electrónico del usuario no es válida. Consulte los registros para ver si hay algún enlace.", "browse-more-in-genre": "Ver más en {0}", @@ -178,7 +177,31 @@ "recently-updated": "Actualizado recientemente", "browse-recently-updated": "Examinar las últimas actualizaciones", "unable-to-reset-k+": "No se ha podido restablecer la licencia de Kavita+ debido a un error. Contacta con el soporte de Kavita", - "send-to-unallowed": "No puedes enviar a un dispositivo que no sea el tuyo", + "send-to-unallowed": "No puede enviar a un dispositivo que no sea el suyo", "email-not-enabled": "El correo electrónico no está habilitado en este servidor. No puede realizar esta acción.", - "send-to-size-limit": "El(Los) archivo(s) que intenta enviar es(son) demasiado(s) grande(s) para su programa de correo electrónico" + "send-to-size-limit": "El(Los) archivo(s) que intenta enviar es(son) demasiado(s) grande(s) para su proveedor de correo electrónico", + "process-scrobbling-events": "Procesar eventos de scrobbling", + "report-stats": "Informe de estadísticas", + "check-scrobbling-tokens": "Comprobar los token de scrobbling", + "process-processed-scrobbling-events": "Volver a procesar eventos de scrobbling procesados", + "cleanup": "Limpieza", + "remove-from-want-to-read": "Eliminar de querer leer", + "kavita+-data-refresh": "Actualización de los datos de Kavita+", + "backup": "Copia de respaldo", + "update-yearly-stats": "Actualizar estadísticas anualmente", + "license-check": "Comprobar la licencia", + "scan-libraries": "Escanear la biblioteca", + "check-updates": "Comprobar actualizaciones", + "account-email-invalid": "El correo electrónico registrado para la cuenta de administrador no es un correo electrónico válido. No se puede enviar un correo electrónico de prueba.", + "email-settings-invalid": "Falta información en la configuración de correo electrónico. Asegúrese de que todas las configuraciones de correo electrónico estén guardadas.", + "collection-already-exists": "La colección ya existe", + "error-import-stack": "Problema al importar la pila MAL", + "generic-cover-person-save": "No se puede guardar la imagen de portada para esta persona", + "generic-cover-volume-save": "No se puede guardar la imagen de portada en el volumen", + "person-doesnt-exist": "No existe la persona", + "person-name-required": "El nombre de la persona es obligatorio y no debe estar vacío", + "person-name-unique": "El nombre de la persona debe ser único", + "person-image-doesnt-exist": "La persona no existe en CoversDB", + "email-taken": "El correo electrónico ya está en uso", + "kavitaplus-restricted": "Esto está restringido a Kavita+" } diff --git a/API/I18N/et.json b/API/I18N/et.json new file mode 100644 index 000000000..b1783e987 --- /dev/null +++ b/API/I18N/et.json @@ -0,0 +1,161 @@ +{ + "confirm-email": "Esmalt pead oma e-posti kinnitama", + "locked-out": "Sinu konto on liiga paljude ebaõnnestunud sisselogimiskatsete tõttu süsteemis piiratud. Palun oota 10 minutit.", + "disabled-account": "Su konto on keelatud. Võta ühendust serveri administraatoriga.", + "register-user": "Kasutaja registreerimisel läks midagi valesti", + "validate-email": "Teie e-posti kinnitamisel ilmnes probleem: {0}", + "denied": "Pole lubatud", + "permission-denied": "Sul ei ole selle toimingu jaoks luba", + "password-required": "Kui sa pole administraator, pead oma konto muutmiseks sisestama olemasoleva parooli", + "invalid-password": "Vale Parool", + "invalid-token": "Vale kinnituskood", + "unable-to-reset-key": "Midagi läks valesti, võtit ei saa lähtestada", + "invalid-payload": "Vigane saadetis", + "share-multiple-emails": "E-maili ei saa erinevate kontode vahel jagada", + "generate-token": "Kinnitusmeili koodi loomisel ilmnes probleem. Vaata logisid", + "confirm-token-gen": "Kinnituskoodi loomisel ilmnes probleem", + "nothing-to-do": "Pole midagi teha", + "age-restriction-update": "Vanusepiirangu värskendamisel ilmnes viga", + "manual-setup-fail": "Mitteautomaatne seadistus ei suuda lõpetada. Palun katkestage ja looge kutse uuesti", + "user-already-registered": "Kasutaja on juba registreeritud kui {0}", + "user-already-confirmed": "Kasutaja on juba kinnitatud", + "generic-user-update": "Kasutajaandmete uuendamisel tekkis viga", + "user-already-invited": "Selle e-posti aadressiga kasutaja on juba kutsutud ja kutse vajab jaatavalt vastamist.", + "generic-invite-user": "Tekkis probleem kasutaja kutsumisel. Palun loe vealogisid.", + "no-user": "Sellenimelist kasutajat ei ole", + "username-taken": "Kasutajanimi on juba kasutuses", + "send-to-unallowed": "Sa ei saa saata seadmele, mis ei ole sinu", + "generic-send-to": "Tekkis viga faili(de) seadmele saatmisel", + "bookmarks-empty": "Järjehoidjad ei saa olla tühjad", + "no-cover-image": "Pole kaanepilti", + "bookmark-doesnt-exist": "Järjehoidjat ei eksisteeri", + "must-be-defined": "{0} peab olema sätestatud", + "generic-favicon": "Domeeni favicon laadimisel tekkis probleem", + "generic-library": "Tekkis möödapääsmatu probleem. Palun proovi uuesti.", + "series-updated": "Edukalt uuendatud", + "no-library-access": "Kasutaja ei oma juurdepääsu sellele kogule", + "user-doesnt-exist": "Kasutajat ei eksisteeri", + "delete-library-while-scan": "Ei saa kustutada tervikkogu, kui skaneerimine on töös. Oota skaneerimise lõppemist või taaskäivita Kavita ning püüa siis uuesti kustutada", + "valid-number": "Peab olema pädev leheküljenumber", + "generic-reading-list-update": "Lugemisloendi uuendamisel tekkis probleem", + "reading-list-position": "Ei õnnestunud uuendada järge", + "series-restricted": "Kasutajal puudub ligipääs sellele sarjale", + "update-yearly-stats": "Uuenda aastate kaupa statistika", + "remove-from-want-to-read": "Lugemisloendi puhastus", + "process-scrobbling-events": "Töötle scrobble juhtumeid", + "user-no-access-library-from-series": "Kasutaja ei oma juurdepääsu täiskogule, milles see seeria on", + "progress-must-exist": "Kasutajal peab olema järg", + "generic-create-temp-archive": "Tekkis probleem ajutise arhiivi loomisel", + "smart-filter-doesnt-exist": "Nutikas filter ei eksisteeri", + "browse-external-sources": "Lehitse väliseid allikaid", + "recently-updated": "Hiljuti uuendatud", + "browse-recently-updated": "Lehitse hiljuti uuendatuid", + "collections": "Kõik kogumid", + "browse-libraries": "Lehitse täiskogude kaupa", + "password-updated": "Parool uuendatud", + "invalid-username": "Vigane kasutajanimi", + "critical-email-migration": "E-posti migreerimisel tekkis viga. Võta toega ühendust", + "email-not-enabled": "E-post ei ole sellel serveril seadistatud. Seda muudatust ei saa teha.", + "chapter-doesnt-exist": "Peatükki ei ole olemas", + "file-missing": "Faili ei leitud raamatust", + "collection-updated": "Kogu uuendatud edukalt", + "collection-deleted": "Kogu kustutatud", + "generic-error": "Midagi ebaõnnestus, palun proovi uuesti", + "collection-doesnt-exist": "Kogu ei eksisteeri", + "device-doesnt-exist": "Seade ei eksisteeri", + "greater-0": "{0} peab olema suurem, kui 0", + "send-to-kavita-email": "Seadmele saatmine ei saa töötada ilma e-posti seadistamata", + "series-doesnt-exist": "Sari ei eksisteeri", + "file-doesnt-exist": "Faili ei eksisteeri", + "library-name-exists": "Kogu nimi juba eksisteerib, palun vali süsteemisiseselt unikaalne nimi.", + "generic-reading-list-create": "Lugemisloendi loomisel tekkis probleem", + "reading-list-doesnt-exist": "Lugemisloendit ei eksisteeri", + "browse-reading-lists": "Lehitse lugemisloendite kaupa", + "external-sources": "Välised allikad", + "smart-filters": "Nutikad filtrid", + "search": "Otsing", + "query-required": "Päringuparameeter on vaja kaasa anda", + "external-source-doesnt-exist": "Väline allikas ei eksisteeri", + "external-source-required": "APIvõti ja serverinimi on vajalikud", + "external-source-already-exists": "Väline allikas on juba olemas", + "device-duplicate": "Sellenimeline seade juba eksisteerib", + "collection-tag-duplicate": "Sellenimeline kogu juba eksisteerib", + "chapter-num": "Peatükk {0}", + "license-check": "Litsentsikontroll", + "check-updates": "Kontrolli uuendusi", + "process-processed-scrobbling-events": "Töötle juba töödeldud scrobble juhtumid", + "backup": "Varund", + "collection-already-exists": "Kogu juba eksisteerib", + "error-import-stack": "Tekkis probleem MAL kuhja importimisel", + "send-to-device-status": "Edastame failid sinu seadmele", + "volume-doesnt-exist": "Raamat ei eksisteeri", + "invalid-filename": "Vigane failinimi", + "generic-library-update": "Tekkis möödapääsmatu probleem tervikkogu uuendamisel.", + "pdf-doesnt-exist": "PDF ei eksisteeri - samas peaks", + "cache-file-find": "Ei leidnud puhverdatud pilti - taaslae ja proovi uuesti.", + "name-required": "Nimi ei või tühjaks jääda", + "reading-list-permission": "Teil ei ole õigusi sellele lugemisloendile või seda loendit ei eksisteeri", + "reading-list-item-delete": "Ei õnnestunud kustutada element(e|i)", + "reading-list-deleted": "Lugemisloend on kustutatud", + "generic-reading-list-delete": "Lugemisloendi kustutamisel tekkis probleem", + "browse-recently-added": "Lehitse hiljuti lisatuid", + "cleanup": "Puhastus", + "issue-num": "Väljaanne {0}{1}", + "book-num": "Raamat {0}", + "series-restricted-age-restriction": "Kasutajal ei ole vanusepiirangust tulenevalt seeriale juurdepääsu", + "send-to-permission": "Kindle ei toeta mitte-EPUB või mitte-PDF formaati", + "device-not-created": "See seade veel ei eksisteeri. Palun loo seade", + "collection-tag-title-required": "Kogu pealkiri ei saa jääda tühjaks", + "epub-html-missing": "Ei suutnud leida sobivat HTML selle lehekülje jaoks", + "epub-malformed": "Failis on süntaksivead! Ei saa lugeda.", + "theme-doesnt-exist": "Teemafail puudub või on vigane", + "external-source-already-in-use": "Juba eksisteerib voog selle välise allikaga", + "sidenav-stream-doesnt-exist": "SideNav voog ei eksisteeri", + "dashboard-stream-doesnt-exist": "Koondpaneeli voog ei eksisteeri", + "smart-filter-already-in-use": "Juba eksisteerib selle nutika filtriga voog", + "favicon-doesnt-exist": "Faviconi ei eksisteeri", + "search-description": "Otsi sarju, kogusid või lugemisloendeid", + "reading-list-restricted": "Lugemisloend ei eksisteeri või teil puudub juurdepääs", + "browse-smart-filters": "Lehitse nutikate filtritega", + "browse-more-in-genre": "Brausi rohkem {0}", + "more-in-genre": "Rohkem žanris {0}", + "browse-collections": "Lehitse kogude kaupa", + "reading-lists": "Lugemisloendid", + "invalid-email-confirmation": "Vigane e-posti kinnitus", + "generic-user-email-update": "Pole võimalik uuendada kasutaja e-posti aadressi. Kontrolli logisid.", + "not-accessible": "Sinu server ei ole väljast ligipääsetav", + "invalid-email": "E-posti aadress selle kasutaja juures ei vasta RFC-le. Vaata palun logisid.", + "not-accessible-password": "Sinu server ei ole ligipääsetav. Sinu parooli taasseadmise link on logides", + "forgot-password-generic": "E-post saadetakse aadressile, mis on meie andmebaasis", + "generic-password-update": "Uue parooli kinnitamisel esines ootamatu viga", + "email-sent": "E-post saadetud", + "user-migration-needed": "Selle kasutaja andmeid on vaja migreerida. Palu tal välja logida, et saaks migratsiooni töövoo käivitada", + "generic-invite-email": "Esines probleem e-postiga kutse taas-saatmisel", + "admin-already-exists": "Administraator on juba määratud", + "account-email-invalid": "Selle administraatorkonto e-posti aadress ei vasta RFC-le. Test e-posti ei saa saata.", + "email-settings-invalid": "E-posti seaded on puudulikud. Veendu, et e-posti seaded saaksid salvestatud.", + "generic-device-create": "Tekkis viga seadme loomisel", + "generic-device-update": "Tekkis viga seadme uuendamisel", + "generic-device-delete": "Tekkis viga seadme kustutamisel", + "send-to-size-limit": "Fail(id) mida üritad saata on e-posti serveri jaoks liiga suured", + "library-doesnt-exist": "Tervikkogu ei eksisteeri", + "invalid-access": "Vigane juurdepääs", + "no-image-for-page": "Pole sellist pilti leheküljel {0}. Proovi taaslaadimist, et võimaldada taaspuhverdamine.", + "bookmark-save": "Ei õnnestunud salvestada järjehoidjat", + "perform-scan": "Palun viige läbi selle seeria või täiskogu skaneerimine, ning proovige uuesti", + "generic-read-progress": "Tekkis probleem järje salvestamisel", + "generic-clear-bookmarks": "Ei suutnud puhastada järjehoidjaid", + "bookmark-permission": "Teil ei ole õigust järjehoidja seadmiseks/kustutamiseks", + "duplicate-bookmark": "Järjehoidja topeltsissekanne on juba olemas", + "reading-list-updated": "Uuendatud", + "bad-copy-files-for-download": "Ei õnnestu kopeerida faile ajutisse kataloogi arhiivina allalaadimiseks.", + "volume-num": "Köide {0}", + "reading-list-title-required": "Lugemisloendi pealkiri ei saa jääda tühjaks", + "reading-list-name-exists": "Sellenimeline lugemisloend on juba olemas", + "check-scrobbling-tokens": "Kontrolli scrobble turvažetoone", + "report-stats": "Raporteeri statistika", + "invalid-path": "Vigane tee", + "not-authenticated": "Kasutaja on autentimata", + "kavita+-data-refresh": "Kavita+andmete värskendus", + "scan-libraries": "Skaneeri täiskogud" +} diff --git a/API/I18N/fa.json b/API/I18N/fa.json new file mode 100644 index 000000000..0933c41d5 --- /dev/null +++ b/API/I18N/fa.json @@ -0,0 +1,7 @@ +{ + "validate-email": "در اعتبارسنجی ایمیل شما مشکلی رخ داد: {0}", + "confirm-email": "اول ایمیل خود را تایید کنید", + "locked-out": "شما به علت تلاش‌های ورود بیش از حد، محدود شدید. لطفاً ۱۰ دقیقه صبر کنید.", + "disabled-account": "حساب شما غیرفعال شده است ، لطفاً با مدیر سرور تماس حاصل کنید.", + "register-user": "در هنگام ثبت کاربر مشکلی رخ داد." +} diff --git a/API/I18N/fi.json b/API/I18N/fi.json new file mode 100644 index 000000000..dd2a3f070 --- /dev/null +++ b/API/I18N/fi.json @@ -0,0 +1,179 @@ +{ + "generic-user-update": "Käyttäjää päivitettäessä oli poikkeus", + "manual-setup-fail": "Manuaalinen asennus ei ole mahdollista. Peruuta ja luo kutsu uudelleen", + "user-already-registered": "Käyttäjä on jo rekisteröity tunnuksella {0}", + "user-already-invited": "Käyttäjä on jo kutsuttu tällä sähköpostilla ja ei ole vielä hyväksynyt kutsua.", + "forgot-password-generic": "Sähköpostia lähetetään sähköpostiosoitteeseen, jos se on olemassa tietokannassamme", + "email-sent": "Sähköposti lähetetty", + "device-doesnt-exist": "Laitetta ei ole olemassa", + "generic-device-create": "Laitteen luomisessa tapahtui virhe", + "generic-device-update": "Laitteen päivittämisessä tapahtui virhe", + "send-to-kavita-email": "Laitteelle lähettämistä ei voi käyttää ilman sähköpostin asetuksia", + "send-to-unallowed": "Et voi lähettää laitteeseen, joka ei ole sinun", + "send-to-size-limit": "Tiedostot, joita yrität lähettää, ovat liian suuria sähköpostipalveluntarjoajallesi", + "send-to-device-status": "Tiedostoja siirretään laitteellesi", + "generic-send-to": "Tiedoston / tiedostojen lähettämisessä tapahtui virhe", + "no-cover-image": "Ei kansikuvaa", + "file-doesnt-exist": "Tiedostoa ei ole olemassa", + "user-doesnt-exist": "Käyttäjää ei ole olemassa", + "library-doesnt-exist": "Kirjastoa ei ole olemassa", + "invalid-path": "Virheellinen polku", + "bookmark-save": "Ei voitu tallentaa kirjanmerkkiä", + "cache-file-find": "Välimuistiin ladattua kuvaa ei löytynyt. Lataa ja yritä uudelleen.", + "name-required": "Nimi ei voi olla tyhjä", + "valid-number": "Täytyy olla voimassa oleva sivunumero", + "duplicate-bookmark": "Päällekkäinen kirjanmerkki on jo olemassa", + "reading-list-permission": "Sinulla ei ole lupaa tähän lukulistaan tai sitä ei ole olemassa", + "reading-list-position": "Sijaintia ei voitu päivittää", + "reading-list-updated": "Päivitetty", + "reading-list-deleted": "Lukulista poistettiin", + "generic-reading-list-create": "Lukulistan luomisessa tapahtui virhe", + "reading-list-doesnt-exist": "Lukulistaa ei ole olemassa", + "generic-scrobble-hold": "Säilytykseen siirtämisessä tapahtui virhe", + "no-series": "Ei saatu sarjoja Kirjastoon.", + "no-series-collection": "Ei saatu sarjaa kokoelmaan", + "generic-series-delete": "Sarjan poistamisessa kohdattiin ongelma", + "generic-series-update": "Sarjan päivittämisessä tapahtui virhe", + "series-updated": "Päivitetty onnistuneesti", + "update-metadata-fail": "Metatietoja ei voitu päivittää", + "age-restriction-not-applicable": "Ei rajoituksia", + "generic-relationship": "Suhteiden päivittämisessä oli ongelma", + "job-already-running": "Työ on jo käynnissä", + "encode-as-warning": "Et voi muuttaa PNG:ksi. Kansikuville, kokeile päivitystä. Kirjamerkkejä ja faviconeja ei voi koodata takaisin.", + "ip-address-invalid": "IP-osoite '{0}' on virheellinen", + "bookmark-dir-permissions": "Kirjanmerkki hakemistolla ei ole oikeita käyttöoikeuksia Kavitalle", + "total-backups": "Varmuuskopioiden kokonaismäärän on oltava välillä 1–30", + "total-logs": "Lokien kokonaismäärän on oltava välillä 1–30", + "stats-permission-denied": "Sinulla ei ole oikeutta katsoa toisen käyttäjän tilastoja", + "url-not-valid": "Url ei palauta kelvollista kuvaa tai vaatii luvan", + "url-required": "Sinun on annattava url käyttääksesi", + "reading-lists": "Lukulista", + "browse-libraries": "Selaa Kirjastoja", + "collections": "Kaikki kokoelmat", + "browse-collections": "Selaa Kokoelmia", + "smart-filters": "Älykkäät suodattimet", + "external-sources": "Ulkoiset lähteet", + "reading-list-restricted": "Lukulistaa ei ole olemassa tai sinulla ei ole pääsyä", + "search": "Haku", + "smart-filter-doesnt-exist": "Älykkäitä Suodattimia ei ole olemassa", + "unable-to-reset-k+": "Kavita+-lisenssiä ei voida palauttaa virheen vuoksi.Ota yhteyttä Kavita+ tukeen", + "theme-doesnt-exist": "Teema tiedosto puuttuu tai on virheellinen", + "epub-malformed": "Tiedosto on epämuodostunut! Ei voi lukea.", + "epub-html-missing": "Ei löytynyt sopivaa html:ää tälle sivulle", + "reading-list-title-required": "Luettelon otsikko ei voi olla tyhjä", + "collection-tag-title-required": "Kokoelman otsikko ei voi olla tyhjä", + "collection-tag-duplicate": "Kokoelma tällä nimellä on jo olemassa", + "device-duplicate": "On jo olemassa laite, jolla on tämä nimi", + "series-restricted-age-restriction": "Käyttäjä ei saa katsoa tätä sarjaa ikärajoitusten vuoksi", + "volume-num": "Nidos {0}", + "book-num": "Kirja {0}", + "issue-num": "Painos {0}{1}", + "check-updates": "Tarkista päivitykset", + "license-check": "Lisenssi tarkistus", + "report-stats": "Raporttitilat", + "scan-libraries": "Skannaa Kirjastot", + "confirm-email": "Sinun on vahvistettava sähköpostisi ensin", + "locked-out": "Sinut on lukittu ulos liian monen valtuutus yrityksen vuoksi. Ole hyvä ja odota 10 minuuttia.", + "disabled-account": "Sinun tilisi on poistettu käytöstä. Ota yhteyttä palvelimen ylläpitäjään.", + "register-user": "Jokin meni pieleen, kun käyttäjä rekisteröityi", + "validate-email": "Sähköpostin vahvistamisessa oli ongelma: {0}", + "confirm-token-gen": "Vahvistuspoletin luomisessa oli ongelma", + "denied": "Ei sallittu", + "permission-denied": "Tämä toiminto on sinulta kielletty", + "invalid-password": "Virheellinen Salasana", + "invalid-token": "Virheellinen merkki", + "invalid-payload": "Virheellinen hyötykuorma", + "nothing-to-do": "Ei mitään tekemistä", + "share-multiple-emails": "Et voi jakaa sähköpostia useilla tileillä", + "password-required": "Sinun on syötettävä olemassa oleva salasanasi muuttaaksesi tiliäsi, ellet ole järjestelmänvalvoja", + "unable-to-reset-key": "Jotain meni pieleen, ei pystytä nollaamaan avainta", + "generate-token": "Ongelmana oli vahvistus merkin luominen sähköpostitse. Katso virhelokit", + "age-restriction-update": "Ikärajoituksen päivittämisessä tapahtui virhe", + "no-user": "Käyttäjää ei ole olemassa", + "username-taken": "Käyttäjänimi on jo olemassa", + "user-already-confirmed": "Käyttäjä on jo vahvistettu", + "generic-invite-user": "Kutsuessa käyttäjää kohdattiin ongelma. Ole hyvä ja tarkista virhelokit.", + "invalid-email-confirmation": "Virheellinen sähköposti vahvistus", + "generic-user-email-update": "Et voi päivittää sähköpostia käyttäjälle. Tarkista lokit.", + "generic-password-update": "Odottamaton virhe uuden salasanan vahvistuksessa", + "password-updated": "Salasana päivitetty", + "not-accessible-password": "Palvelin ei ole käytettävissä. Linkki salasanan palauttamiseen on lokeissa", + "invalid-email": "Sähköposti käyttäjälle ei ole voimassa oleva sähköposti. Katso lokista mahdollisia linkkejä.", + "not-accessible": "Palvelin ei ole käytettävissä ulkoisesti", + "invalid-username": "Virheellinen käyttäjätunnus", + "user-migration-needed": "Tämä käyttäjä on pakko siirtää. Siirto tapahtuu ulos- ja sisäänkirjautumisen yhteydessä", + "admin-already-exists": "Admin on jo olemassa", + "generic-invite-email": "Sähköpostiviestin uudelleen lähettämisessä kohdattiin ongelma", + "critical-email-migration": "Sähköpostien siirron aikana tapahtui ongelma. Ota yhteyttä tukeen", + "email-not-enabled": "Sähköposti ei ole käytössä tällä palvelimella. Et voi suorittaa tätä toimintaa.", + "account-email-invalid": "Admin-tilin tiedostossa oleva sähköposti ei ole voimassa oleva sähköposti. Testi viestiä ei voi lähettää.", + "collection-deleted": "Kokoelma poistettu", + "generic-error": "Jokin meni pieleen, yritä uudelleen", + "email-settings-invalid": "Sähköposti asetuksista puuttuu tietoja. Varmista, että kaikki sähköposti asetukset on tallennettu.", + "chapter-doesnt-exist": "Lukua ei ole olemassa", + "collection-doesnt-exist": "Kokoelmaa ei ole olemassa", + "file-missing": "Tiedostoa ei löytynyt kirjasta", + "collection-updated": "Kokoelma päivitetty onnistuneesti", + "collection-already-exists": "Kokoelma on jo olemassa", + "error-import-stack": "MAL pinon tuonnissa tapahtui virhe", + "generic-device-delete": "Laitteen poistamisessa tapahtui virhe", + "greater-0": "{0} on oltava suurempi kuin 0", + "series-doesnt-exist": "Sarjaa ei ole olemassa", + "volume-doesnt-exist": "Nidosta ei ole olemassa", + "bookmarks-empty": "Kirjanmerkit eivät voi olla tyhjiä", + "bookmark-doesnt-exist": "Kirjanmerkkiä ei ole olemassa", + "must-be-defined": "{0} on määriteltävä", + "generic-favicon": "Verkkotunnuksen faviconin noutamisessa ilmeni ongelma", + "invalid-filename": "Virheellinen tiedostonimi", + "library-name-exists": "Kirjaston nimi on jo olemassa. Valitse palvelimelle yksilöllinen nimi.", + "generic-library": "Tapahtui kriittinen virhe. Kokeile uudestaan.", + "no-library-access": "Käyttäjällä ei ole pääsyä tähän kirjastoon", + "delete-library-while-scan": "Kirjastoa ei voi poistaa, kun skannaus on käynnissä. Odota, että skannaus suoritetaan tai uudelleenkäynnistä Kavita, ja yritä poistaa", + "generic-library-update": "Kirjastoa päivittäessä tapahtui kriittinen virhe.", + "invalid-access": "Virheellinen käyttöoikeus", + "pdf-doesnt-exist": "PDF:tä ei ole olemassa, vaikka pitäisi", + "no-image-for-page": "Ei tällaista kuvaa sivulla {0}. Yritä päivittää, jotta välimuisti voidaan tallentaa uudelleen.", + "perform-scan": "Tee skannaus sarjaan tai kirjastoon ja yritä uudelleen", + "generic-read-progress": "Edistyksen tallennuksessa tapahtui ongelma", + "bookmark-permission": "Sinulla ei ole lupaa lisätä / poistaa kirjanmerkkejä", + "generic-clear-bookmarks": "Kirjanmerkkejä ei voitu tyhjentää", + "reading-list-item-delete": "Kohdetta / kohteita ei voitu poistaa", + "generic-reading-list-delete": "Lukulistan poistamisessa tapahtui virhe", + "generic-reading-list-update": "Lukulistan päivityksessä tapahtui virhe", + "series-restricted": "Käyttäjällä ei ole pääsyä tähän sarjaan", + "libraries-restricted": "Käyttäjällä ei ole pääsyä kirjastoihin", + "generic-cover-series-save": "Ei voitu tallentaa kansikuvaa Sarjaan", + "generic-cover-collection-save": "Ei voitu tallentaa kansikuvaa Kokoelmaan", + "generic-cover-reading-list-save": "Ei voitu tallentaa kansikuvaa Lukulistaan", + "generic-cover-chapter-save": "Ei voitu tallentaa kansikuvaa Kappaleeseen", + "generic-cover-library-save": "Ei voitu tallentaa kansikuvaa Kirjastoon", + "generic-user-pref": "Asetusten tallentamisessa tapahtui virhe", + "generic-cover-person-save": "Ei voitu tallentaa kansikuvaa henkilölle", + "generic-cover-volume-save": "Ei voitu tallentaa kansikuvaa Nidokseen", + "access-denied": "Sinulla ei ole pääsyä", + "generic-user-delete": "Käyttäjää ei voitu poistaa", + "opds-disabled": "OPDS ei ole käytössä tällä palvelimella", + "recently-added": "Äskettäin lisätty", + "libraries": "Kaikki Kirjastot", + "browse-external-sources": "Selaa ulkoisia lähteitä", + "recently-updated": "Äskettäin päivitetty", + "browse-recently-updated": "Selaa äskettäin päivitettyjä", + "favicon-doesnt-exist": "Faviconia ei ole olemassa", + "search-description": "Haku Sarjoille, Kokoelmille tai Lukulistoille", + "external-source-already-exists": "Ulkoinen lähde on jo olemassa", + "external-source-required": "Apikey ja isäntä tarvittiin", + "external-source-doesnt-exist": "Ulkoista lähdettä ei ole olemassa", + "not-authenticated": "Käyttäjää ei ole todennettu", + "unable-to-register-k+": "Et voi rekisteröidä lisenssiä virheen vuoksi. Ota yhteyttä Kavita+ tukeen", + "device-not-created": "Tätä laitetta ei ole vielä olemassa. Luo ensin", + "reading-list-name-exists": "Tämän niminen Lukulista on jo olemassa", + "user-no-access-library-from-series": "Käyttäjällä ei ole pääsyä kirjastoon, johon sarja kuuluu", + "chapter-num": "Luku {0}", + "cleanup": "Puhdistus", + "browse-reading-lists": "Selaa Lukulistoja", + "person-doesnt-exist": "Henkilöä ei ole olemassa", + "person-name-required": "Henkilön nimi on pakollinen, eikä se saa olla tyhjä", + "person-name-unique": "Henkilön nimen on oltava yksilöllinen", + "person-image-doesnt-exist": "Henkilöä ei ole olemassa CoversDB:ssa", + "email-taken": "Sähköposti jo käytössä" +} diff --git a/API/I18N/fr.json b/API/I18N/fr.json index b106d19d7..6b3dc735a 100644 --- a/API/I18N/fr.json +++ b/API/I18N/fr.json @@ -5,14 +5,13 @@ "disabled-account": "Votre compte a été désactivé. Veuillez contacter un administrateur.", "confirm-email": "Vous devez d'abord confirmer votre email", "locked-out": "Vous avez été bloqués suite à un nombre trop élevé de tentatives. Veuillez réessayer dans 10 minutes.", - "bad-credentials": "Vos codes d'accès sont invalides", "validate-email": "Une erreur est survenue lors de la validation de votre courriel : {0}", "confirm-token-gen": "Une erreur est survenue lors de la génération du jeton de confirmation", "password-required": "Vous devez entrer votre mot de passe actuel pour changer votre compte à moins que vous ne soyez administrateur", "invalid-password": "Mot de passe invalide", "invalid-token": "Jeton invalide", "unable-to-reset-key": "Une erreur est survenue, impossible de réinitialiser la clé", - "generate-token": "Une erreur est survenue lors de la génération du jeton de confirmation de l'émail. Voir les logs", + "generate-token": "Une erreur est survenue lors de la génération du jeton de confirmation de l'email. Voir les logs", "nothing-to-do": "Rien à faire", "share-multiple-emails": "Vous ne pouvez pas partager un email sur plusieurs comptes", "age-restriction-update": "Une erreur est survenue lors de la mise à jour de la restriction d'âge", @@ -21,7 +20,7 @@ "user-already-confirmed": "L'utilisateur a déjà été confirmé", "generic-user-update": "Une erreur est survenue lors de la mise à jour de l'utilisateur", "user-already-registered": "L'utilisateur a déjà été enregistré en tant que {0}", - "user-already-invited": "L'utilisateur a déjà été invité avec cet émail et n'a pas encore accepté l'invitation.", + "user-already-invited": "L'utilisateur a déjà été invité avec cet email et n'a pas encore accepté l'invitation.", "generic-invite-user": "Une erreur est survenue lors de l'invitation de l'usager. Voir le journal.", "invalid-email-confirmation": "La confirmation de courriel est invalide", "invalid-payload": "Payload invalide", @@ -41,7 +40,7 @@ "chapter-doesnt-exist": "Chapitre non existant", "file-missing": "Fichier introuvable dans le livre", "generic-device-delete": "Erreur lors de la suppression de l'appareil", - "send-to-kavita-email": "Envoyer à l'appareil ne peut pas être utilisé sans configurer E-mail", + "send-to-kavita-email": "La fonction \"Envoyer à l'appareil\" ne peut pas être utilisée sans configurer l'email", "generic-favicon": "Erreur lors de la récupération de la favicon pour le domaine", "generic-library": "Erreur critique. Essayez à nouveau.", "delete-library-while-scan": "Vous ne pouvez pas supprimer une bibliothèque lorsqu'une analyse est en cours. Veuillez attendre la fin de l'analyse ou redémarrez Kavita, puis essayez de la supprimer", @@ -122,7 +121,7 @@ "generic-user-delete": "Impossible de supprimer l'utilisateur", "generic-user-pref": "Erreur lors de la sauvegarde des préférences", "opds-disabled": "OPDS n'est pas activé sur ce serveur", - "on-deck": "Continuez votre lecture", + "on-deck": "En Cours", "recently-added": "Récemment Ajouté", "browse-recently-added": "Parcourir Récemment Ajouté", "reading-lists": "Liste de Lecture", @@ -170,7 +169,7 @@ "external-source-already-exists": "La source externe existe déjà", "external-source-doesnt-exist": "La source externe n'existe pas", "external-source-required": "La clé API et l'hôte sont requis", - "invalid-email": "Le mail du fichier de l'utilisateur n'est pas un mail valide. Voir les logs pour les liens.", + "invalid-email": "L'email du fichier de l'utilisateur n'est pas un email valide. Voir les logs pour les liens.", "sidenav-stream-doesnt-exist": "Le flux de la barre de navigation latérale n'existe pas", "smart-filter-already-in-use": "Il existe un flux avec ce filtre intelligent", "browse-more-in-genre": "Parcourir plus dans {0}", @@ -180,5 +179,33 @@ "unable-to-reset-k+": "Impossible de réinitialiser la licence Kavita+ en raison d'une erreur. Contactez le support Kavita+", "email-not-enabled": "E-mail non activé sur ce serveur. Vous ne pouvez pas lancer cette action.", "send-to-unallowed": "Vous ne pouvez envoyer à un appareil qui ne vous appartient pas", - "send-to-size-limit": "Le(s) fichier(s) que vous essayez d'envoyer sont trop lourds pour votre emailer" + "send-to-size-limit": "Le(s) fichier(s) que vous essayez d'envoyer est (sont) trop volumineux pour votre fournisseur d'email", + "check-updates": "Vérifier les mises à jour", + "license-check": "Vérification de la licence", + "cleanup": "Nettoyage", + "report-stats": "Rapport des statistiques", + "process-scrobbling-events": "Traiter les événements de Scrobbling", + "check-scrobbling-tokens": "Vérifier les jetons de Scrobbling", + "remove-from-want-to-read": "Nettoyage de la liste d'envie (déjà lu)", + "scan-libraries": "Analyse des bibliothèques", + "kavita+-data-refresh": "Actualisation des données Kavita+", + "backup": "Sauvegarde", + "process-processed-scrobbling-events": "Traiter les événements de Scrobbling traités", + "update-yearly-stats": "Mettre à jour les statistiques annuelles", + "account-email-invalid": "L'adresse électronique figurant dans le fichier du compte administrateur n'est pas valide. Impossible d'envoyer un courriel de test.", + "email-settings-invalid": "Informations manquantes dans les paramètres de l'email. Assurez-vous que tous les paramètres de l'email sont sauvegardés.", + "collection-already-exists": "Collection déjà existante", + "error-import-stack": "Il y a eu un problème lors de l'importation de la pile MAL", + "generic-cover-person-save": "Impossible d'enregistrer l'image de couverture pour cette personne", + "generic-cover-volume-save": "Impossible d'enregistrer l'image de couverture sur le volume", + "person-name-unique": "Le nom de la personne doit être unique", + "person-image-doesnt-exist": "La personne n'existe pas dans CoversDB", + "person-doesnt-exist": "La personne n'existe pas", + "person-name-required": "Le nom de la personne est obligatoire et ne doit pas être nul", + "email-taken": "Email déjà existant", + "kavitaplus-restricted": "Ce service est réservé à Kavita+", + "sidenav-stream-only-delete-smart-filter": "Seuls les flux de filtres intelligents peuvent être supprimés de la SideNav", + "dashboard-stream-only-delete-smart-filter": "Seuls les flux de filtres intelligents peuvent être supprimés du tableau de bord", + "smart-filter-name-required": "Nom du filtre intelligent requis", + "smart-filter-system-name": "Vous ne pouvez pas utiliser le nom d'un flux fourni par le système" } diff --git a/API/I18N/ga.json b/API/I18N/ga.json new file mode 100644 index 000000000..2d16bcb05 --- /dev/null +++ b/API/I18N/ga.json @@ -0,0 +1,211 @@ +{ + "confirm-email": "Ní mór duit do ríomhphost a dhearbhú ar dtús", + "disabled-account": "Tá do chuntas díchumasaithe. Déan teagmháil le riarthóir an fhreastalaí.", + "validate-email": "Bhí fadhb ann agus do ríomhphost á bhailíochtú: {0}", + "confirm-token-gen": "Bhí fadhb ann agus comhartha deimhnithe á ghiniúint", + "denied": "Ní cheadaítear", + "permission-denied": "Níl cead agat an oibríocht seo a dhéanamh", + "invalid-password": "Pasfhocal Neamhbhailí", + "invalid-token": "Comhartha neamhbhailí", + "unable-to-reset-key": "Tharla earráid, níorbh fhéidir an eochair a athshocrú", + "invalid-payload": "Ualach neamhbhailí", + "nothing-to-do": "Tada le déanamh", + "share-multiple-emails": "Ní féidir leat ríomhphoist a roinnt thar níos mó ná cuntas amháin", + "generate-token": "Bhí fadhb ann agus comhartha ríomhphoist deimhnithe á ghiniúint. Féach logs", + "age-restriction-update": "Tharla earráid agus an srian aoise á nuashonrú", + "no-user": "Níl an t-úsáideoir ann", + "username-taken": "Ainm úsáideora tógtha cheana féin", + "user-already-confirmed": "Úsáideoir deimhnithe cheana féin", + "generic-user-update": "Bhí eisceacht ann nuair a bhí an t-úsáideoir á nuashonrú", + "manual-setup-fail": "Ní féidir an socrú láimhe a chur i gcrích. Cuir ar ceal agus athchruthaigh an cuireadh", + "user-already-invited": "Tá cuireadh faighte ag an úsáideoir faoin ríomhphost seo cheana féin agus níor ghlac sé leis an gcuireadh fós.", + "invalid-email-confirmation": "Deimhniú ríomhphoist neamhbhailí", + "generic-user-email-update": "Ní féidir ríomhphost a nuashonrú don úsáideoir. Seiceáil logs.", + "generic-password-update": "Tharla earráid gan choinne agus pasfhocal nua á dheimhniú", + "password-updated": "Pasfhocal Nuashonraithe", + "forgot-password-generic": "Seolfar ríomhphost chuig an ríomhphost má tá sé inár mbunachar sonraí", + "not-accessible-password": "Níl do fhreastalaí inrochtana. Tá an nasc chun do phasfhocal a athshocrú sna logaí", + "invalid-email": "Ní ríomhphost bailí é an ríomhphost atá ar comhad don úsáideoir. Féach logaí le haghaidh naisc ar bith.", + "email-sent": "Ríomhphost seolta", + "user-migration-needed": "Ní mór don úsáideoir seo dul ar imirce. Iarr orthu logáil amach agus logáil isteach chun sreabhadh imirce a spreagadh", + "generic-invite-email": "Bhí fadhb ann agus an ríomhphost cuireadh á athsheoladh", + "admin-already-exists": "Tá riarachán ann cheana féin", + "invalid-username": "Ainm Úsáideora neamhbhailí", + "critical-email-migration": "Bhí fadhb ann le linn aistriú ríomhphoist. Tacaíocht teagmhála", + "email-not-enabled": "Níl ríomhphost cumasaithe ar an bhfreastalaí seo. Ní féidir leat an gníomh seo a dhéanamh.", + "email-settings-invalid": "Eolas in easnamh i socruithe ríomhphoist. Cinntigh go ndéantar gach socrú ríomhphoist a shábháil.", + "file-missing": "Ní bhfuarthas comhad sa leabhar", + "collection-updated": "D'éirigh leis an mbailiúchán a nuashonrú", + "generic-error": "Tharla earráid, bain triail eile as", + "error-import-stack": "Bhí fadhb ann maidir le stoic MAL a iompórtáil", + "device-doesnt-exist": "Níl an gléas ann", + "generic-device-create": "Tharla earráid agus an gléas á chruthú", + "generic-device-update": "Tharla earráid agus an gléas á nuashonrú", + "send-to-kavita-email": "Ní féidir seoladh chuig an ngléas a úsáid gan R-phost a shocrú", + "send-to-unallowed": "Ní féidir leat seoladh chuig gléas nach leatsa é", + "send-to-size-limit": "Tá an comhad/na comhaid atá tú ag iarraidh a sheoladh rómhór do do sholáthraí ríomhphoist", + "send-to-device-status": "Comhaid a aistriú chuig do ghléas", + "must-be-defined": "Ní mór {0} a shainiú", + "generic-favicon": "Bhí fadhb ann maidir le favicon a fháil don fhearann", + "invalid-filename": "Ainm Comhaid Neamhbhailí", + "file-doesnt-exist": "Níl an comhad ann", + "generic-library": "Bhí ceist chriticiúil ann. Arís, le d'thoil.", + "user-doesnt-exist": "Níl an t-úsáideoir ann", + "library-doesnt-exist": "Níl an leabharlann ann", + "invalid-path": "Conair Neamhbhailí", + "delete-library-while-scan": "Ní féidir leat leabharlann a scriosadh agus scanadh ar siúl. Fan go mbeidh an scanadh críochnaithe nó chun Kavita a atosú agus ansin déan iarracht é a scriosadh", + "generic-library-update": "Bhí ceist ríthábhachtach ag baint le nuashonrú na leabharlainne.", + "pdf-doesnt-exist": "Níl PDF ann nuair ba chóir", + "invalid-access": "Rochtain Neamhbhailí", + "no-image-for-page": "Níl a leithéid d'íomhá don leathanach {0}. Bain triail as athnuachan chun ath-taisce a cheadú.", + "perform-scan": "Déan scanadh ar an tsraith nó ar an leabharlann seo agus bain triail eile as", + "generic-read-progress": "Bhí fadhb ann maidir le dul chun cinn a shábháil", + "generic-clear-bookmarks": "Níorbh fhéidir leabharmharcanna a ghlanadh", + "bookmark-permission": "Níl cead agat leabharmharcáil/dí-leabharmharc a dhéanamh", + "bookmark-save": "Níorbh fhéidir leabharmharc a shábháil", + "cache-file-find": "Níorbh fhéidir íomhá i dtaisce a aimsiú. Athlódáil agus bain triail eile as.", + "name-required": "Ní féidir leis an ainm a bheith folamh", + "duplicate-bookmark": "Tá iontráil leabharmharc dúblach ann cheana féin", + "reading-list-permission": "Níl ceadanna agat ar an liosta léitheoireachta seo nó níl an liosta ann", + "reading-list-position": "Níorbh fhéidir an t-ionad a nuashonrú", + "reading-list-updated": "Nuashonraithe", + "reading-list-item-delete": "Níorbh fhéidir an mhír(eanna) a scriosadh", + "reading-list-deleted": "Scriosadh an Liosta Léitheoireachta", + "generic-reading-list-delete": "Bhí fadhb ann agus an liosta léitheoireachta á scriosadh", + "generic-reading-list-create": "Bhí fadhb ann agus an liosta léitheoireachta á chruthú", + "generic-scrobble-hold": "Tharla earráid agus an coimeád á chur leis", + "libraries-restricted": "Níl rochtain ag an úsáideoir ar aon leabharlanna", + "no-series": "Níorbh fhéidir sraith a fháil don Leabharlann", + "no-series-collection": "Níorbh fhéidir sraith a fháil le haghaidh Bailiúchán", + "generic-series-delete": "Bhí fadhb ann an tsraith a scriosadh", + "generic-series-update": "Tharla earráid agus an tsraith á nuashonrú", + "series-updated": "D'éirigh le nuashonrú", + "age-restriction-not-applicable": "Gan srian", + "generic-relationship": "Bhí fadhb ann maidir le caidrimh a nuashonrú", + "job-already-running": "Job ar siúl cheana féin", + "ip-address-invalid": "Seoladh IP '{0}' neamhbhailí", + "bookmark-dir-permissions": "Níl na ceadanna cearta ag an Eolaire Leabharmharcanna chun Kavita a úsáid", + "total-backups": "Caithfidh Cúltaca Iomlána a bheith idir 1 agus 30", + "total-logs": "Caithfidh an Logchomhaid Iomlán a bheith idir 1 agus 30", + "url-not-valid": "Ní sheolann URL íomhá bhailí ar ais nó éilíonn sé údarú", + "url-required": "Caithfidh tú pas a fháil i url le húsáid", + "generic-cover-series-save": "Ní féidir íomhá an chlúdaigh a shábháil sa tSraith", + "generic-cover-collection-save": "Ní féidir íomhá an chlúdaigh a shábháil sa Bhailiúchán", + "generic-cover-reading-list-save": "Ní féidir íomhá an chlúdaigh a shábháil ar an Liosta Léitheoireachta", + "generic-cover-volume-save": "Ní féidir íomhá an chlúdaigh a shábháil in Volume", + "access-denied": "Níl rochtain agat", + "reset-chapter-lock": "Ní féidir glas clúdaigh a athshocrú do Chapter", + "generic-user-delete": "Níorbh fhéidir an t-úsáideoir a scriosadh", + "opds-disabled": "Níl OPDS cumasaithe ar an bhfreastalaí seo", + "on-deck": "Ar Deic", + "browse-on-deck": "Brabhsáil Ar Deic", + "recently-added": "Leis Le Déanaí", + "want-to-read": "Ba mhaith liom a léamh", + "browse-reading-lists": "Brabhsáil de réir Liostaí Léitheoireachta", + "libraries": "Gach Leabharlann", + "browse-libraries": "Brabhsáil de réir Leabharlanna", + "collections": "Gach Bailiúchán", + "browse-recently-updated": "Brabhsáil Nuashonraithe Le Déanaí", + "smart-filters": "Scagairí Cliste", + "external-sources": "Foinsí Seachtracha", + "browse-external-sources": "Brabhsáil Foinsí Seachtracha", + "browse-smart-filters": "Brabhsáil de réir Scagairí Cliste", + "reading-list-restricted": "Níl an liosta léitheoireachta ann nó níl rochtain agat", + "query-required": "Caithfidh tú pas a fháil i bparaiméadar fiosrúcháin", + "search": "Cuardach", + "search-description": "Cuardaigh Sraitheanna, Bailiúcháin, nó Liostaí Léitheoireachta", + "favicon-doesnt-exist": "Níl Favicon ann", + "smart-filter-doesnt-exist": "Níl Smart Scagaire ann", + "smart-filter-already-in-use": "Tá sruth leis an Scagaire Cliste seo cheana féin", + "dashboard-stream-doesnt-exist": "Níl Sruth an Deais ann", + "sidenav-stream-doesnt-exist": "Níl Sruth SideNav ann", + "external-source-already-exists": "Tá Foinse Seachtrach ann cheana féin", + "external-source-required": "ApiKey agus Óstach ag teastáil", + "external-source-doesnt-exist": "Níl Foinse Sheachtrach ann", + "external-source-already-in-use": "Tá sruth leis an bhFoinse Sheachtrach seo cheana féin", + "not-authenticated": "Níl an t-úsáideoir fíordheimhnithe", + "unable-to-register-k+": "Ní féidir ceadúnas a chlárú mar gheall ar earráid. Déan teagmháil le Tacaíocht Kavita+", + "anilist-cred-expired": "Tá Dintiúir AniList imithe in éag nó gan a bheith socraithe", + "scrobble-bad-payload": "Droch-ualú pá ó Sholáthraí Scrobble", + "theme-doesnt-exist": "Comhad téama in easnamh nó neamhbhailí", + "generic-create-temp-archive": "Bhí fadhb ann agus cartlann ama á cruthú", + "epub-html-missing": "Níorbh fhéidir an html cuí a aimsiú don leathanach sin", + "collection-tag-title-required": "Ní féidir le Teideal an Bhailiúcháin a bheith folamh", + "collection-tag-duplicate": "Tá bailiúchán leis an ainm seo ann cheana féin", + "device-duplicate": "Tá gléas leis an ainm seo ann cheana", + "send-to-permission": "Ní féidir neamh-EPUB nó PDF a sheoladh chuig gléasanna mar nach dtacaítear leo ar Kindle", + "progress-must-exist": "Caithfidh dul chun cinn a bheith ann ar an úsáideoir", + "reading-list-name-exists": "Tá liosta léitheoireachta den ainm seo ann cheana", + "user-no-access-library-from-series": "Níl rochtain ag an úsáideoir ar an leabharlann lena mbaineann an tsraith seo", + "volume-num": "Imleabhar {0}", + "book-num": "Leabhar {0}", + "issue-num": "Eagrán {0}{1}", + "chapter-num": "Caibidil {0}", + "check-updates": "Seiceáil Nuashonruithe", + "license-check": "Seiceáil Ceadúnais", + "process-scrobbling-events": "Próiseáil Imeachtaí Scroblach", + "report-stats": "Staitisticí Tuairisce", + "check-scrobbling-tokens": "Seiceáil Comharthaí Scrobbling", + "cleanup": "Glan Suas", + "process-processed-scrobbling-events": "Imeachtaí Scrobarnach Próiseáilte a Phróiseáil", + "remove-from-want-to-read": "Glanadh Ba Mhaith Liom a Léamh", + "scan-libraries": "Scanadh Leabharlanna", + "kavita+-data-refresh": "Kavita+ Athnuachan Sonraí", + "backup": "Cúltaca", + "update-yearly-stats": "Nuashonraigh na Staitisticí Bliantúla", + "account-email-invalid": "Ní ríomhphost bailí é an ríomhphost atá ar comhad don chuntas riaracháin. Ní féidir ríomhphost tástála a sheoladh.", + "locked-out": "Glasáladh amach thú as an iomarca iarrachtaí údaraithe. Fan 10 nóiméad le do thoil.", + "register-user": "Tharla earráid agus úsáideoir á chlárú", + "password-required": "Ní mór duit do phasfhocal reatha a chur isteach chun do chuntas a athrú ach amháin más riarthóir thú", + "generic-invite-user": "Bhí fadhb ann le cuireadh a thabhairt don úsáideoir. Seiceáil na logaí le do thoil.", + "user-already-registered": "Tá an t-úsáideoir cláraithe mar {0} cheana", + "not-accessible": "Níl rochtain sheachtrach ar do fhreastalaí", + "collection-deleted": "Bailiúchán scriosta", + "series-doesnt-exist": "Níl an tsraith ann", + "bookmark-doesnt-exist": "Níl leabharmharc ann", + "collection-doesnt-exist": "Níl bailiúchán ann", + "generic-send-to": "Tharla earráid agus an comhad/na comhaid á seoladh chuig an ngléas", + "volume-doesnt-exist": "Níl toirt ann", + "bookmarks-empty": "Ní féidir le leabharmharcanna a bheith folamh", + "chapter-doesnt-exist": "Níl Caibidil ann", + "no-cover-image": "Gan íomhá clúdaigh", + "generic-cover-library-save": "Ní féidir íomhá an chlúdaigh a shábháil sa Leabharlann", + "collection-already-exists": "Tá bailiúchán ann cheana féin", + "generic-device-delete": "Tharla earráid agus an gléas á scriosadh", + "greater-0": "Caithfidh {0} a bheith níos mó ná 0", + "library-name-exists": "Tá ainm na leabharlainne ann cheana féin. Roghnaigh ainm ar leith don fhreastalaí.", + "no-library-access": "Níl rochtain ag an úsáideoir ar an leabharlann seo", + "valid-number": "Caithfidh gur uimhir leathanaigh bhailí é", + "generic-reading-list-update": "Bhí fadhb ann an liosta léitheoireachta a nuashonrú", + "reading-list-doesnt-exist": "Níl liosta léitheoireachta ann", + "series-restricted": "Níl rochtain ag an úsáideoir ar an Sraith seo", + "update-metadata-fail": "Níorbh fhéidir meiteashonraí a nuashonrú", + "encode-as-warning": "Ní féidir leat tiontú go PNG. Le haghaidh clúdaigh, bain úsáid as Clúdaigh Athnuaigh. Ní féidir leabharmharcanna agus favicons a ionchódú ar ais.", + "stats-permission-denied": "Níl tú údaraithe chun féachaint ar staitisticí úsáideora eile", + "generic-cover-chapter-save": "Ní féidir íomhá an chlúdaigh a shábháil ar Chapter", + "generic-cover-person-save": "Ní féidir íomhá an chlúdaigh a shábháil don Duine", + "generic-user-pref": "Bhí fadhb ann maidir le roghanna a shábháil", + "reading-lists": "Liostaí Léitheoireachta", + "browse-collections": "Brabhsáil de réir Bailiúcháin", + "browse-more-in-genre": "Brabhsáil tuilleadh in {0}", + "recently-updated": "Nuashonraithe Le Déanaí", + "reading-list-title-required": "Ní féidir le Teideal an Liosta Léitheoireachta a bheith folamh", + "device-not-created": "Níl an gléas seo ann fós. Cruthaigh ar dtús le do thoil", + "browse-recently-added": "Brabhsáil Curtha Leis Le Déanaí", + "browse-want-to-read": "Brabhsáil Ba Mhaith Liom a Léamh", + "more-in-genre": "Tuilleadh sa Seánra {0}", + "unable-to-reset-k+": "Ní féidir ceadúnas Kavita+ a athshocrú de bharr earráide. Déan teagmháil le Tacaíocht Kavita+", + "series-restricted-age-restriction": "Níl cead ag an úsáideoir an tsraith seo a fheiceáil mar gheall ar shrianta aoise", + "bad-copy-files-for-download": "Ní féidir na comhaid a chóipeáil go dtí an chartlann eolaire sealadach íoslódáil.", + "epub-malformed": "Tá an comhad míchumtha! Ní féidir a léamh.", + "person-doesnt-exist": "Níl duine ann", + "person-name-required": "Tá ainm an duine ag teastáil agus ní féidir é a bheith ar neamhní", + "person-name-unique": "Caithfidh ainm duine a bheith uathúil", + "person-image-doesnt-exist": "Níl an duine in CoversDB", + "email-taken": "Ríomhphost in úsáid cheana féin", + "kavitaplus-restricted": "Tá sé seo teoranta do Kavita+ amháin", + "smart-filter-system-name": "Ní féidir leat ainm srutha an chórais a sholáthair tú a úsáid", + "sidenav-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh as an SideNav", + "dashboard-stream-only-delete-smart-filter": "Ní féidir ach sruthanna cliste scagaire a scriosadh ón deais", + "smart-filter-name-required": "Ainm Scagaire Cliste ag teastáil" +} diff --git a/API/I18N/he.json b/API/I18N/he.json index 3aef062e0..41a9a7de7 100644 --- a/API/I18N/he.json +++ b/API/I18N/he.json @@ -1,7 +1,6 @@ { "confirm-email": "חובה לאמת תחילה כתובת דואר אלקטרוני", "denied": "לא מאושר", - "bad-credentials": "שם משתמש או סיסמא לא נכונים", "locked-out": "חשבונך ננעל לאחר מספר מקסימלי של נסיונות כניסה לא מוצלחים. אנא המתן/ני 10 דקות.", "disabled-account": "חשבונך לא פעיל. אנא פנה למנהל המערכת.", "validate-email": "אירעה תקלה בעת ניסיון וידוא כתובת הדואר האלקטרוני שלך: {0}", diff --git a/API/I18N/hi.json b/API/I18N/hi.json index 5fb9edd0a..5e1ea21f6 100644 --- a/API/I18N/hi.json +++ b/API/I18N/hi.json @@ -67,7 +67,6 @@ "volume-num": "वॉल्यूम {0}", "book-num": "बुक {0}", "issue-num": "अंक(Issue) {0}{1}", - "bad-credentials": "आपकी क्रेडेंशियल सही नहीं हैं", "locked-out": "आपको कई प्राधिकरण प्रयासों के कारण बंद कर दिया गया है। कृपया 10 मिनट प्रतीक्षा करें।।", "register-user": "उपयोगकर्ता पंजीकरण करते समय कुछ गलत हो गया", "disabled-account": "आपका खाता अक्षम है। सर्वर व्यवस्थापक से संपर्क करें।।", diff --git a/API/I18N/hu.json b/API/I18N/hu.json new file mode 100644 index 000000000..7c9473116 --- /dev/null +++ b/API/I18N/hu.json @@ -0,0 +1,200 @@ +{ + "disabled-account": "A fiókod le lett tiltva. Vedd fel a kapcsolatot a szerver adminnal.", + "permission-denied": "Ez a művelet nem engedélyezett", + "password-required": "Be kell írnod a meglévő jelszavadatat a fiók megváltoztatásához, kivéve ha admin vagy", + "unable-to-reset-key": "Probléma a kulcs visszaállítása során", + "invalid-payload": "Érvénytelen adat", + "generate-token": "Probléma a megerősítő e-mail token generálása során. Lásd a logokat", + "age-restriction-update": "Probléma a korhatár frissítésénél", + "username-taken": "A felhasználónév foglalt", + "generic-user-update": "Hiba a felhasználó frissítésekor", + "user-already-invited": "A felhasználót már meghívták e-mailben, de még nem fogadta el.", + "generic-user-email-update": "Nem lehet frissíteni a felhasználó e-mail címét. Kérlek ellenőrizd a logokat.", + "generic-password-update": "Váratlan hiba az új jelszó megerősítésekor", + "forgot-password-generic": "Küldünk egy e-mailt, ha a cím szerepel az adatbázisunkban", + "not-accessible": "A szervered kívülről nem érhető el", + "email-sent": "E-mail elküldve", + "generic-invite-email": "Probléma volt a meghívó e-mail újraküldésekor", + "email-not-enabled": "Az e-mail nem engedélyezett ezen a szerveren. A feladat nem indítható el.", + "email-settings-invalid": "Az e-mail beállításokból információk hiányoznak. Győződj meg róla hogy minden e-mail beállítás el van mentve.", + "collection-deleted": "Gyűjtemény törölve", + "generic-error": "Valami nem jó, kérlek próbáld újra", + "collection-doesnt-exist": "A gyűjtemény nem létezik", + "device-doesnt-exist": "Az eszköz nem létezik", + "generic-device-update": "Probléma volt az eszköz frissítésével", + "send-to-unallowed": "Nem küldhetsz eszközre ami nem a tiéd", + "send-to-size-limit": "A file(ok) amiket küldeni próbálsz túl nagyok az e-mailhez", + "send-to-device-status": "Fileok átvitele az eszközödre", + "generic-send-to": "Probléma volt a file(ok) eszközre küldésével", + "volume-doesnt-exist": "A kötet nem létezik", + "library-name-exists": "A könyvtár név már létezik. Válassz egyedi nevet a szerveren.", + "generic-library": "Volt egy kritikus probléma. Kérlek próbáld újra.", + "no-library-access": "A felhasználónak nincs hozzáférése ehhez a könyvtárhoz", + "library-doesnt-exist": "A könyvtár nem létezik", + "generic-library-update": "Kritikus probléma a könyvtár frissítése közben.", + "no-image-for-page": "Nincs kép a(z) {0} oldalhoz. Frissíts az újra cache-eléshez.", + "generic-read-progress": "Probléma volt a mentés közben", + "generic-clear-bookmarks": "Nem lehetett a könyvjelzőket kiüríteni", + "bookmark-save": "Nem lehet a könyvjelzőt menteni", + "valid-number": "Az oldalszámnak érvényesnek kell lennie", + "duplicate-bookmark": "Már létező könyvjelző", + "reading-list-permission": "Nincs hozzáférésed a lista olvasásához vagy a lista nem létezik", + "reading-list-updated": "Frissítve", + "generic-reading-list-update": "Probléma volt az olvasási lista frissítése közben", + "series-restricted": "A felhasználónak nincs joga ehhez a sorozathoz", + "confirm-email": "Erősítsd meg az e-mail címedet először", + "register-user": "Felhasználó regisztrálása sikertelen", + "invalid-password": "Érvénytelen jelszó", + "invalid-token": "Érvénytelen token", + "locked-out": "Túl sok hibás bejelentkezési próbálkozás. Kérlek várj 10 percet.", + "validate-email": "Probléma volt az e-mail címed ellenőrzésekor: {0}", + "share-multiple-emails": "Az e-mail cím megosztása nem lehetséges több fiók között", + "confirm-token-gen": "Probléma a megerősítő token generálása során", + "denied": "Nem engedélyezett", + "no-user": "A felhasználó nem létezik", + "user-already-confirmed": "A felhasználó már meg van erősítve", + "manual-setup-fail": "A kézi beállítást nem lehet befejezni. Kérlek töröld és készítsd el újra a meghívót", + "user-already-registered": "A felhasználó már regisztrált, mint {0}", + "generic-invite-user": "Probléma volt a felhasználó meghívásánál. Kérlek ellenőrizd a logokat.", + "invalid-email-confirmation": "Érvénytelen e-mail megerősítés", + "password-updated": "Jelszó frissítve", + "not-accessible-password": "A szerver nem elérhető. A jelszó visszaállításához való hivatkozás a logokban található", + "nothing-to-do": "Nincs mit tenni", + "user-migration-needed": "A felhasználót migrálni kell. Kérd meg hogy lépjen ki és be, hogy a migráció elkezdődjön", + "critical-email-migration": "Probléma volt az e-mail migráció közben. Vedd fel a kapcsolatot a supporttal", + "admin-already-exists": "Az Admin már létezik", + "invalid-username": "Érvénytelen felhasználónév", + "account-email-invalid": "Az e-mail cím az admin fiókhoz nem érvényes. A teszt e-mail nem küldhető el.", + "invalid-email": "A felhasználóhoz rendelt e-mai cím nem érvényes. Lásd a logokat a hivatkozásokhoz.", + "chapter-doesnt-exist": "A fejezet nem létezik", + "file-missing": "A fájl nem található a könyvben", + "collection-updated": "A gyűjtemény sikeresen frissítve", + "collection-already-exists": "A gyűjtemény már létezik", + "error-import-stack": "Probléma volt a „MAL stack” importálásakor", + "generic-device-create": "Probléma volt az eszköz készítésével", + "generic-device-delete": "Probléma volt az eszköz törlésével", + "greater-0": "{0} nagyobbnak kell lennie mint 0", + "send-to-kavita-email": "Az „Eszközre küldés” nem használható e-mail beállítás nélkül", + "series-doesnt-exist": "A sorozat nem létezik", + "bookmarks-empty": "A könyvjelzők nem lehetnek üresek", + "no-cover-image": "Nincs borítókép", + "bookmark-doesnt-exist": "A könyvjelző nem létezik", + "generic-favicon": "Probléma volt a domainhez tartozó favicon lekérésekor", + "must-be-defined": "{0} definiálni kell", + "invalid-filename": "Hibás filenév", + "file-doesnt-exist": "A fájl nem létezik", + "user-doesnt-exist": "A felhasználó nem létezik", + "invalid-path": "Hibás útvonal", + "delete-library-while-scan": "Nem törölhetsz könyvtárat a scannelés közben. Kérlek várj míg a scannelés befejeződik vagy indítsd újra a Kavitát és próbáld újra a törlést", + "pdf-doesnt-exist": "Nem létezik a PDF, pedig kellene", + "invalid-access": "Hibás hozzáférés", + "perform-scan": "Kérlek scanneld újra ezt a sorozatot vagy könyvtárat és próbáld újra", + "bookmark-permission": "Nincs jogosultságod a könyvjelző készítéshez/törléshez", + "cache-file-find": "Nem lehetett a cache-elt képet megtalálni. Töltsd újra és próbáld még egyszer.", + "name-required": "A név nem lehet üres", + "reading-list-position": "Nem lehet a pozíciót frissíteni", + "reading-list-item-delete": "Nem lehet törölni a tétel(eket)", + "reading-list-deleted": "Olvasási lista törölve", + "generic-reading-list-delete": "Probléma volt az olvasási lista törlése közben", + "generic-reading-list-create": "Probléma volt az olvasási lista készítése közben", + "reading-list-doesnt-exist": "Az olvasási lista nem létezik", + "libraries-restricted": "A felhasználónak nincs könyvtár hozzáférése", + "generic-scrobble-hold": "Hiba történt a \"hold\" hozzáadásakor", + "no-series": "Nem lehet sorozatot találni a könyvtárhoz", + "no-series-collection": "Nem lehet sorozatot találni a gyűjteményhez", + "generic-series-delete": "Probléma volt a sorozat törlése közben", + "generic-series-update": "Probléma volt a sorozat frissítése közben", + "update-metadata-fail": "Nem lehet a metaadatot frissíteni", + "series-updated": "Sikeresen frissítve", + "age-restriction-not-applicable": "Nincs korlátozás", + "generic-relationship": "Probléma volt a kapcsolatok frissítése közben", + "job-already-running": "Már fut a munkamenet", + "encode-as-warning": "Nem konvertálhasz PNG-be. Borítókhoz használd a Borítók frissítését. Könyvjelzőket és Favikononkat nem lehet vissza kódolni.", + "ip-address-invalid": "IP cím '{0}' nem érvényes", + "bookmark-dir-permissions": "Könyvjelző könyvtárnak nincs helyes jogosultsága a Kavitának", + "total-backups": "Az Összes Mentésnek 1 és 30 között kell lennie", + "total-logs": "Az Összes Logoknak 1 és 30 között kell lennie", + "stats-permission-denied": "Nincs jogosultságod másik felhasználó statisztikáját megnézni", + "url-not-valid": "URL nem ad vissza valódi képet, vagy bejelentkezés szükséges", + "url-required": "Be kell írnod egy URL-t a használathoz", + "generic-cover-series-save": "Nem lehet borítóképet menteni a sorozathoz", + "generic-cover-collection-save": "Nem lehet borítóképet menteni a gyűjteményhez", + "generic-cover-reading-list-save": "Nem lehet borítóképet menteni az olvasási listához", + "generic-cover-library-save": "Nem lehet borítóképet menteni a könyvtárhoz", + "access-denied": "Nincs hozzáférésed", + "generic-cover-chapter-save": "Nem lehet borítóképet menteni a fejezethez", + "reset-chapter-lock": "Nem lehet a fejezet borító rögzítését visszaállítani", + "generic-user-delete": "Nem lehet törölni a felhasználót", + "generic-user-pref": "Probléma volt a beállítások mentésénél", + "opds-disabled": "Az ODPS nincs engedélyezve ezen a szerveren", + "on-deck": "A Fedélzeten", + "browse-on-deck": "Böngéssz a fedélzeten", + "recently-added": "Nemrég hozzáadott", + "want-to-read": "Olvasandó", + "browse-want-to-read": "Olvasandó böngészése", + "browse-recently-added": "Nemrég hozzáadott böngészése", + "reading-lists": "Olvasási Listák", + "libraries": "Összes Könyvtár", + "browse-libraries": "Könyvtárak böngészése", + "collections": "Összes gyűjtemény", + "browse-collections": "Gyűjtemények böngészése", + "more-in-genre": "Több hasonló {0}", + "browse-more-in-genre": "Böngéssz a {0}", + "recently-updated": "Nemrég frissítve", + "browse-recently-updated": "Nemrég frissítve böngészése", + "smart-filters": "Intelligens szűrők", + "external-sources": "Külső források", + "browse-external-sources": "Külső források böngészése", + "browse-smart-filters": "Intelligens szűrők böngészése", + "search": "Keresés", + "smart-filter-doesnt-exist": "Az intelligens szűrő nem létezik", + "external-source-already-exists": "A külső forrás már létezik", + "external-source-required": "ApiKey és Host szükséges", + "external-source-doesnt-exist": "A külső forrás nem létezik", + "unable-to-reset-k+": "Nem lehet a licenszt visszaállítani egy hiba miatt. Kérlek fordulj a Kavita+ Supporthoz", + "theme-doesnt-exist": "Téma fájl hiányzik vagy hibás", + "user-no-access-library-from-series": "Felhasználónak nincs hozzáférése a könyvtárhoz amihez ez a sorozat tartozik", + "reading-list-name-exists": "Egy hasonló nevű olvasási lista már létezik", + "cleanup": "Takarítás", + "check-updates": "Frissítések ellenőrzése", + "license-check": "Licensz ellenőrzése", + "chapter-num": "Fejezet {0}", + "issue-num": "Kiadás {0}{1}", + "book-num": "Könyv {0}", + "volume-num": "Kötet {0}", + "series-restricted-age-restriction": "A felhasználó nem nézheti meg ezt a sorozatot korhatár miatt", + "update-yearly-stats": "Éves statisztika frissítése", + "backup": "Mentés", + "kavita+-data-refresh": "Kavita+ adat frissítés", + "scan-libraries": "Könyvtárak szkennelése", + "remove-from-want-to-read": "Olvasandó tisztítása", + "browse-reading-lists": "Olvasási Listák böngészése", + "external-source-already-in-use": "Van egy létező folyamat ezzel a külső forrással", + "not-authenticated": "A felhasználó nincs azonosítva", + "unable-to-register-k+": "Nem lehet a licenszt regisztrálni egy hiba miatt. Kérlek fordulj a Kavita+ Supporthoz", + "anilist-cred-expired": "AniList azonosító lejárt vagy nincs beállítva", + "reading-list-restricted": "Az olvasási lista nem létezik, vagy nincs hozzá hozzáférésed", + "query-required": "Meg kell adnod egy lekérdezés paramétert", + "search-description": "Sorozat, gyűjtemény vagy olvasási lista keresése", + "favicon-doesnt-exist": "Favikon nem létezik", + "epub-malformed": "A fájl tartalma hibás! Nem lehet olvasni.", + "collection-tag-title-required": "A gyűjtemény címe nem lehet üres", + "reading-list-title-required": "Az olvasási lista címe nem lehet üres", + "collection-tag-duplicate": "Egy azonos nevű gyűjtemény már létezik", + "device-duplicate": "Egy azonos nevű eszköz már létezik", + "device-not-created": "Ez az eszköz még nem létezik. Kérlek készítsd el először", + "smart-filter-already-in-use": "Van egy létező adatfolyam ezzel az Okos Szűrővel", + "dashboard-stream-doesnt-exist": "Az Irányítópult Adatfolyam nem létezik", + "sidenav-stream-doesnt-exist": "OldalNav Adatfolyam nem létezik", + "scrobble-bad-payload": "Rossz adat a Feldolgozó szolgáltatótól", + "bad-copy-files-for-download": "Nem lehet fájlokat másolni az átmeneti könyvtár archívum letöltésébe.", + "generic-create-temp-archive": "Probléma volt az átmeneti archívum létrehozásakor", + "epub-html-missing": "Nem lehet megfelelő html-t találni ahhoz az oldalhoz", + "send-to-permission": "Nem lehet nem-EPUB vagy PDF-et az eszközökre, mivel a Kindle nem támogatja", + "progress-must-exist": "A folyamatnak léteznie kell a felhasználón", + "report-stats": "Kimutatás Statisztikák", + "check-scrobbling-tokens": "Ellenőrizd a Feldolgozó tokeneket", + "process-scrobbling-events": "Feldolgozó események feldolgozása", + "process-processed-scrobbling-events": "A feldolgozott Feldolgozó események felolgozása", + "generic-cover-volume-save": "Nem lehet borítóképet menteni a kötethez" +} diff --git a/API/I18N/id.json b/API/I18N/id.json index cd4fe1abb..b38fabefb 100644 --- a/API/I18N/id.json +++ b/API/I18N/id.json @@ -60,7 +60,6 @@ "generic-cover-library-save": "Tidak dapat menyimpan gambar sampul ke Pustaka", "generic-cover-chapter-save": "Tidak dapat menyimpan gambar sampul ke Bab", "reset-chapter-lock": "Tidak dapat mereset kunci sampul untuk Bab", - "bad-credentials": "Kredensial anda salah", "no-image-for-page": "Gambar untuk halaman {0} tidak ditemukan. Coba perbarui halaman untuk memperbaharui cache.", "encode-as-warning": "Anda tidak dapat mengonversi ke PNG. Untuk sampul, gunakan Perbarui Sampul. Penanda dan favicon tidak dapat diubah kembali.", "total-logs": "Total log harus diantara 1 dan 30", @@ -96,5 +95,10 @@ "age-restriction-not-applicable": "Tidak ada batasan", "generic-cover-collection-save": "Tidak dapat menyimpan gambar sampul ke Koleksi", "generic-cover-reading-list-save": "Tidak dapat menyimpan gambar sampul ke Daftar Baca", - "generic-user-delete": "Tidak dapat menghapus pengguna" + "generic-user-delete": "Tidak dapat menghapus pengguna", + "email-not-enabled": "Email tidak diaktifkan pada server ini. Anda tidak dapat melakukan tindakan ini.", + "generic-device-update": "Terjadi error saat memperbarui perangkat", + "generic-device-create": "Ada error saat membuat perangkat", + "generic-device-delete": "Terjadi error saat menghapus perangkat", + "greater-0": "{0} harus lebih besar dari 0" } diff --git a/API/I18N/it.json b/API/I18N/it.json index b664f9338..cf43101a6 100644 --- a/API/I18N/it.json +++ b/API/I18N/it.json @@ -53,7 +53,6 @@ "ip-address-invalid": "Indirizzo IP '{0}' non valido", "bookmark-dir-permissions": "La directory dei segnalibri non dispone delle autorizzazioni corrette per l'utilizzo da parte di Kavita", "confirm-email": "Prima devi confermare la tua email", - "bad-credentials": "Le tue credenziali non sono corrette", "register-user": "Qualcosa è andato storto nella registrazione dell'utente", "confirm-token-gen": "Si è verificato un problema durante la generazione di un token di conferma", "generic-user-email-update": "Impossibile aggiornare l'e-mail per l'utente. Controlla i log.", @@ -91,7 +90,7 @@ "user-no-access-library-from-series": "L'utente non ha accesso alla libreria a cui appartiene questa serie", "volume-num": "Volume {0}", "book-num": "Libro {0}", - "issue-num": "Problema {0}{1}", + "issue-num": "Numero {0}{1}", "chapter-num": "Capitolo {0}", "epub-malformed": "Il file è corrotto! Non posso leggere.", "collection-updated": "Collezione aggiornata con successo", @@ -121,7 +120,7 @@ "generic-device-update": "Si è verificato un errore durante l'aggiornamento del dispositivo", "generic-device-delete": "Si è verificato un errore durante l'eliminazione del dispositivo", "greater-0": "{0} deve essere maggiore di 0", - "send-to-kavita-email": "Invia al dispositivo non può essere utilizzato con il servizio e-mail di Kavita. Si prega di configurare il proprio.", + "send-to-kavita-email": "Invia al dispositivo non può essere utilizzato senza la configurazione dell'email", "generic-send-to": "Si è verificato un errore durante l'invio dei file al dispositivo", "generic-favicon": "Si è verificato un problema durante il recupero della favicon per il dominio", "library-name-exists": "Il nome della libreria esiste già. Scegli un nome univoco per il server.", @@ -176,5 +175,33 @@ "browse-more-in-genre": "Sfoglia di più in {0}", "more-in-genre": "Altro in Genere {0}", "recently-updated": "Aggiornato di recente", - "browse-recently-updated": "Sfoglia gli aggiornamenti recenti" + "browse-recently-updated": "Sfoglia gli aggiornamenti recenti", + "email-not-enabled": "L'email non è attivata in questo server. Non puoi compiere questa azione.", + "send-to-unallowed": "Non puoi inviare ad un dispositivo non tuo", + "send-to-size-limit": "Il/I file che stai cercando di mandare sono troppo grandi per il tuo provider email", + "unable-to-reset-k+": "Impossibile ripristinare la licenza Kavita+. Contatta il supporto Kavita+", + "check-updates": "Controlla aggiornamenti", + "license-check": "Controlla Licenza", + "process-scrobbling-events": "Elabora gli eventi di scrobbling", + "cleanup": "Pulizia", + "report-stats": "Rapporto Statistiche", + "check-scrobbling-tokens": "Controlla i Tokens per lo Scrobbling", + "process-processed-scrobbling-events": "Elabora gli eventi di scrobbling processati", + "remove-from-want-to-read": "Voglio per Pulizia Letture", + "scan-libraries": "Scansiona librerie", + "update-yearly-stats": "Aggiorna le statistiche annuali", + "kavita+-data-refresh": "Aggiornamento dati Kavita+", + "backup": "Backup", + "account-email-invalid": "L'e-mail in archivio per l'account amministratore non è un'e-mail valida. Impossibile inviare e-mail di prova.", + "email-settings-invalid": "Informazioni mancanti nelle impoastazioni email. Assicurati che tutte le impostazioni email siano salvate.", + "person-doesnt-exist": "La persona non esiste", + "email-taken": "Email già in uso", + "person-name-required": "Il nome della persona è richiesto e non può essere null", + "person-name-unique": "Il nome della persona deve essere univoco", + "person-image-doesnt-exist": "La persona non esiste su CoversDB", + "collection-already-exists": "La collezione esiste già", + "generic-cover-volume-save": "Impossibile salvare l'immagine di copertina nel Volume", + "generic-cover-person-save": "Impossibile salvare l'immagine di copertina nella Persona", + "error-import-stack": "Si è verificato un errore durante l'importazione dello stack MAL", + "kavitaplus-restricted": "Riservato a Kavita+" } diff --git a/API/I18N/ja.json b/API/I18N/ja.json index 8fd45630c..07efb40ef 100644 --- a/API/I18N/ja.json +++ b/API/I18N/ja.json @@ -3,7 +3,6 @@ "invalid-token": "無効トークン", "invalid-password": "無効なパスワード", "validate-email": "メールの検証中に問題が発生しました: {0}", - "bad-credentials": "資格情報が正しくありません", "confirm-email": "最初にメールを確認する必要があります", "disabled-account": "あなたのアカウントは無効になりました。サーバー管理者に連絡してください。", "locked-out": "認証試行が多すぎるとロックアウトされました。 10分ほどお待ちください。", @@ -26,7 +25,7 @@ "not-accessible": "サーバーが外部からアクセスできません", "email-sent": "Eメールの送信", "generic-password-update": "新しいパスワードの確認時に予期しないエラーが発生しました", - "user-already-confirmed": "ユーザーは既に確定しています", + "user-already-confirmed": "ユーザーは確認済みです", "user-migration-needed": "このユーザーは移行する必要があります。移行フローを実行するために、ログアウトしてからログインしてください", "generic-user-update": "ユーザーの更新時に例外が発生しました", "generic-user-email-update": "ユーザーの電子メールを更新できません。ログを確認してください。", @@ -96,7 +95,7 @@ "bookmark-permission": "ブックマーク/ブックマークを解除する権限があなたにはありません。", "valid-number": "有効なページ番号である必要があります", "duplicate-bookmark": "重複したブックマークエントリーがすでに存在します", - "send-to-size-limit": "送信しようとしているファイルはメーラーにとっては大きすぎます。", + "send-to-size-limit": "送信しようとしているファイルはあなたのメールプロバイダにとっては大きすぎます。", "series-doesnt-exist": "シリーズが存在しません", "pdf-doesnt-exist": "PDFが存在すべきですが存在しません。", "generic-reading-list-create": "リーディングリストを作成している際に問題が発生しました", @@ -180,5 +179,17 @@ "scrobble-bad-payload": "Scrobbleプロバイダからの悪いペイロード", "generic-create-temp-archive": "一時アーカイブの作成中に問題が発生しました", "user-no-access-library-from-series": "ユーザーは、このシリーズが所属するライブラリにアクセス権限がありません", - "collection-tag-duplicate": "この名前のコレクションは既に存在しています" + "collection-tag-duplicate": "この名前のコレクションは既に存在しています", + "account-email-invalid": "管理者アカウントに登録されている電子メールは有効な電子メールではありません。 テストメールを送信できません。", + "check-updates": "アップデートをチェックする", + "license-check": "ライセンスを確認", + "collection-already-exists": "コレクションは既に存在しています", + "email-settings-invalid": "メール設定に不足している情報があります。すべてのメール設定が保存されていることを確認してください。", + "email-taken": "メールアドレスは既に使われています", + "person-doesnt-exist": "人物は存在しません", + "person-name-unique": "人名は一意でなければなりません", + "person-name-required": "人物の名前は必須であり、空にすることはできません", + "person-image-doesnt-exist": "人物はCoversDBに存在しません", + "generic-cover-person-save": "カバー画像を人物に保存できません", + "generic-cover-volume-save": "カバー画像を巻に保存できません" } diff --git a/API/I18N/ko.json b/API/I18N/ko.json index 28ca19827..bb087536b 100644 --- a/API/I18N/ko.json +++ b/API/I18N/ko.json @@ -1,184 +1,207 @@ { "confirm-email": "먼저 이메일을 확인해야 합니다", - "bad-credentials": "자격 증명이 올바르지 않습니다", - "locked-out": "너무 많은 인증 시도로 인해 잠겼습니다. 10분 동안 기다려 주십시오.", - "invalid-password": "유효하지 않은 비밀번호", - "user-already-registered": "사용자는 이미 {0}로 등록되어 있습니다", - "password-updated": "비밀번호 업데이트됨", - "not-accessible-password": "서버에 액세스할 수 없습니다. 비밀번호 재설정 링크는 로그에 있습니다", - "not-accessible": "외부에서 서버에 액세스할 수 없습니다", + "locked-out": "인증 시도가 너무 많아 계정이 잠겼습니다. 10분 후에 다시 시도하세요.", + "invalid-password": "잘못된 비밀번호입니다", + "user-already-registered": "사용자는 이미 {0}(으)로 등록되어 있습니다", + "password-updated": "비밀번호가 업데이트되었습니다", + "not-accessible-password": "서버에 접근할 수 없습니다. 비밀번호 재설정 링크는 로그에 있습니다", + "not-accessible": "서버가 외부에서 접근할 수 없습니다", "chapter-doesnt-exist": "챕터가 존재하지 않습니다", "file-missing": "책에서 파일을 찾을 수 없습니다", - "generic-error": "문제가 발생했습니다, 다시 시도하십시오", - "generic-device-delete": "장치를 삭제하는 중에 오류가 발생했습니다", - "greater-0": "{0}는 0보다 커야 합니다", - "send-to-device-status": "장치로 파일 전송", - "generic-send-to": "파일을 장치로 보내는 중 오류가 발생했습니다", + "generic-error": "문제가 발생했습니다. 다시 시도해주세요", + "generic-device-delete": "장치 삭제 중 오류가 발생했습니다", + "greater-0": "{0}은(는) 0보다 커야 합니다", + "send-to-device-status": "장치로 파일을 전송 중입니다", + "generic-send-to": "장치로 파일 전송 중 오류가 발생했습니다", "volume-doesnt-exist": "볼륨이 존재하지 않습니다", - "generic-favicon": "도메인의 파비콘을 가져오는 중에 문제가 발생했습니다", - "no-library-access": "사용자는 이 라이브러리에 액세스할 수 없습니다", + "generic-favicon": "도메인의 파비콘 가져오기 중 문제가 발생했습니다", + "no-library-access": "사용자는 이 라이브러리에 접근할 수 없습니다", "user-doesnt-exist": "사용자가 존재하지 않습니다", "library-doesnt-exist": "라이브러리가 존재하지 않습니다", "duplicate-bookmark": "중복된 북마크 항목이 이미 존재합니다", "reading-list-position": "위치를 업데이트할 수 없습니다", - "reading-list-deleted": "읽기 목록이 삭제되었습니다", - "generic-reading-list-delete": "읽기 목록을 삭제하는 중에 문제가 발생했습니다", - "reading-list-doesnt-exist": "읽기 목록이 존재하지 않습니다", + "reading-list-deleted": "독서 목록이 삭제되었습니다", + "generic-reading-list-delete": "독서 목록 삭제 중 문제가 발생했습니다", + "reading-list-doesnt-exist": "독서 목록이 존재하지 않습니다", "no-series": "라이브러리에 대한 시리즈를 가져올 수 없습니다", "age-restriction-not-applicable": "제한 없음", - "generic-relationship": "관계를 업데이트하는 중에 문제가 발생했습니다", - "job-already-running": "이미 실행 중인 작업", + "generic-relationship": "관계 업데이트 중 문제가 발생했습니다", + "job-already-running": "작업이 이미 실행 중입니다", "url-required": "사용할 URL을 전달해야 합니다", - "reading-list-title-required": "읽기 목록 제목은 비워둘 수 없습니다", + "reading-list-title-required": "독서 목록 제목은 비워둘 수 없습니다", "progress-must-exist": "사용자에게 진행 상황이 있어야 합니다", "volume-num": "볼륨 {0}", "chapter-num": "챕터 {0}", "disabled-account": "계정이 비활성화되었습니다. 서버 관리자에게 문의하세요.", - "validate-email": "이메일을 확인하는 중에 문제가 발생했습니다: {0}", - "register-user": "사용자를 등록하는 중에 문제가 발생했습니다", - "confirm-token-gen": "확인 토큰을 생성하는 중에 문제가 발생했습니다", - "permission-denied": "이 작업을 수행할 수 없습니다", - "denied": "허용되지 않음", + "validate-email": "이메일 확인 중 문제가 발생했습니다: {0}", + "register-user": "사용자 등록 중에 문제가 발생했습니다", + "confirm-token-gen": "확인 토큰 생성 중 문제가 발생했습니다", + "permission-denied": "이 작업을 수행할 권한이 없습니다", + "denied": "허용되지 않습니다", "password-required": "관리자가 아닌 경우 계정을 변경하려면 기존 비밀번호를 입력해야 합니다", - "invalid-payload": "유효하지 않은 페이로드", - "nothing-to-do": "할 것이 없음", - "share-multiple-emails": "여러 계정에서 이메일을 공유할 수 없습니다", - "invalid-token": "유효하지 않은 토큰", + "invalid-payload": "잘못된 페이로드입니다", + "nothing-to-do": "할 일이 없습니다", + "share-multiple-emails": "여러 계정에 걸쳐 이메일을 공유할 수 없습니다", + "invalid-token": "잘못된 토큰입니다", "unable-to-reset-key": "문제가 발생하여 키를 재설정할 수 없습니다", - "generate-token": "확인 이메일 토큰을 생성하는 중에 문제가 발생했습니다. 로그 보기", + "generate-token": "확인 이메일 토큰 생성 중 문제가 발생했습니다. 로그를 확인하세요", "no-user": "사용자가 존재하지 않습니다", - "age-restriction-update": "연령 제한을 업데이트 하는 중에 오류가 발생했습니다", - "username-taken": "이미 사용중인 이름입니다", + "age-restriction-update": "연령 제한 업데이트 중 오류가 발생했습니다", + "username-taken": "이미 사용 중인 사용자 이름입니다", "user-already-confirmed": "사용자가 이미 확인되었습니다", - "generic-user-update": "사용자를 업데이트 중에 예외가 발생했습니다", - "manual-setup-fail": "수동 설정을 완료할 수 없습니다. 초대를 취소하고 다시 만드십시오", + "generic-user-update": "사용자 업데이트 중 예외가 발생했습니다", + "manual-setup-fail": "수동 설정을 완료할 수 없습니다. 초대를 취소하고 다시 생성하세요", "user-already-invited": "사용자는 이미 이 이메일로 초대되었으며 아직 초대를 수락하지 않았습니다.", - "generic-invite-user": "사용자를 초대하는 중에 문제가 발생했습니다. 로그를 확인하십시오.", - "invalid-email-confirmation": "잘못된 이메일 확인", - "generic-user-email-update": "사용자의 이메일을 업데이트할 수 없습니다. 로그를 확인하십시오.", - "generic-password-update": "새 비밀번호를 확인하는 중에 예상치 못한 오류가 발생했습니다", - "email-sent": "이메일을 보냈습니다", + "generic-invite-user": "사용자 초대 중 문제가 발생했습니다. 로그를 확인하세요.", + "invalid-email-confirmation": "잘못된 이메일 확인입니다", + "generic-user-email-update": "사용자의 이메일을 업데이트할 수 없습니다. 로그를 확인하세요.", + "generic-password-update": "새 비밀번호 확인 중 예기치 않은 오류가 발생했습니다", + "email-sent": "이메일이 발송되었습니다", "admin-already-exists": "관리자가 이미 존재합니다", - "user-migration-needed": "이 사용자는 이전해야 합니다. 로그아웃하고 로그인하여 마이그레이션 흐름을 트리거하도록 합니다", - "forgot-password-generic": "이메일이 데이터베이스에 존재하는 경우 이메일이 이메일로 전송됩니다", - "generic-invite-email": "초대 이메일을 다시 보내는 중에 문제가 발생했습니다", - "invalid-username": "유효하지 않은 아이디", - "critical-email-migration": "이메일 이전 중에 문제가 발생했습니다. 연락처 지원", + "user-migration-needed": "이 사용자는 마이그레이션이 필요합니다. 로그아웃 후 로그인하여 마이그레이션을 진행하세요", + "forgot-password-generic": "해당 이메일이 데이터베이스에 존재하면 이메일이 발송됩니다", + "generic-invite-email": "초대 이메일 재발송 중 문제가 발생했습니다", + "invalid-username": "잘못된 사용자 이름입니다", + "critical-email-migration": "이메일 마이그레이션 중 문제가 발생했습니다. 지원팀에 연락하세요", "collection-updated": "컬렉션이 성공적으로 업데이트되었습니다", "collection-doesnt-exist": "컬렉션이 존재하지 않습니다", - "generic-device-create": "장치를 생성하는 중에 오류가 발생했습니다", + "generic-device-create": "장치 생성 중 오류가 발생했습니다", "device-doesnt-exist": "장치가 존재하지 않습니다", - "generic-device-update": "장치를 업데이트 하는 중에 오류가 발생했습니다", - "send-to-kavita-email": "이메일 설정 없이는 기기로 전송할 수 없습니다.", - "no-cover-image": "표지 이미지 없음", + "generic-device-update": "장치 업데이트 중 오류가 발생했습니다", + "send-to-kavita-email": "이메일 설정 없이 장치로 전송할 수 없습니다", + "no-cover-image": "커버 이미지가 없습니다", "series-doesnt-exist": "시리즈가 존재하지 않습니다", "bookmarks-empty": "북마크는 비워둘 수 없습니다", "bookmark-doesnt-exist": "북마크가 존재하지 않습니다", - "must-be-defined": "{0}을(를) 정의해야 합니다", - "generic-library": "심각한 문제가 있었습니다. 다시 시도해 주세요.", - "invalid-filename": "유효하지 않은 파일 이름", - "file-doesnt-exist": "파일이 없습니다", - "library-name-exists": "라이브러리 이름이 이미 존재합니다. 서버에 고유한 이름을 선택하십시오.", - "invalid-path": "유효하지 않은 경로", - "delete-library-while-scan": "스캔이 진행 중인 동안에는 라이브러리를 삭제할 수 없습니다. 스캔이 완료될 때까지 기다리거나 Kavita를 다시 시작한 다음 삭제를 시도하십시오", - "pdf-doesnt-exist": "PDF가 있어야 할 때 존재하지 않음", - "no-image-for-page": "페이지 {0}에 해당 이미지가 없습니다. 재캐시를 허용하려면 새로고침해 보세요.", + "must-be-defined": "{0}은(는) 정의되어야 합니다", + "generic-library": "심각한 문제가 발생했습니다. 다시 시도해주세요.", + "invalid-filename": "잘못된 파일 이름입니다", + "file-doesnt-exist": "파일이 존재하지 않습니다", + "library-name-exists": "라이브러리 이름이 이미 존재합니다. 서버에서 고유한 이름을 선택하세요.", + "invalid-path": "잘못된 경로입니다", + "delete-library-while-scan": "스캔이 진행 중일 때는 라이브러리를 삭제할 수 없습니다. 스캔이 완료되거나 Kavita를 재시작한 후 삭제를 시도하세요", + "pdf-doesnt-exist": "존재해야 할 PDF가 없습니다", + "no-image-for-page": "{0} 페이지에 해당하는 이미지가 없습니다. 다시 캐시할 수 있도록 새로 고침하세요.", "generic-clear-bookmarks": "북마크를 지울 수 없습니다", - "invalid-access": "유효하지 않은 액세스", - "generic-library-update": "라이브러리를 업데이트하는 중 심각한 문제가 발생했습니다.", - "bookmark-permission": "북마크/북마크해제 권한이 없습니다", - "perform-scan": "이 시리즈 또는 라이브러리에서 스캔을 수행하고 다시 시도하십시오", + "invalid-access": "잘못된 접근입니다", + "generic-library-update": "라이브러리 업데이트 중 심각한 문제가 발생했습니다.", + "bookmark-permission": "북마크 추가/제거 권한이 없습니다", + "perform-scan": "이 시리즈나 라이브러리에 대해 스캔을 수행하고 다시 시도하세요", "bookmark-save": "북마크를 저장할 수 없습니다", - "cache-file-find": "캐시된 이미지를 찾을 수 없습니다. 새로고침하고 다시 시도하세요.", + "cache-file-find": "캐시된 이미지를 찾을 수 없습니다. 다시 로드하고 시도하세요.", "name-required": "이름은 비워둘 수 없습니다", - "reading-list-permission": "이 읽기 목록에 대한 권한이 없거나 목록이 존재하지 않습니다", - "generic-read-progress": "진행 상황을 저장하는 중에 문제가 발생했습니다", + "reading-list-permission": "이 독서 목록에 대한 권한이 없거나 목록이 존재하지 않습니다", + "generic-read-progress": "진행 상황 저장 중 문제가 발생했습니다", "valid-number": "유효한 페이지 번호여야 합니다", - "reading-list-updated": "업데이트됨", + "reading-list-updated": "업데이트되었습니다", "reading-list-item-delete": "항목을 삭제할 수 없습니다", - "series-restricted": "사용자는 이 시리즈에 액세스할 수 없습니다", - "generic-reading-list-update": "읽기 목록을 업데이트하는 중에 문제가 발생했습니다", - "generic-reading-list-create": "읽기 목록을 생성하는 중에 문제가 발생했습니다", - "generic-scrobble-hold": "보류를 추가하는 중에 오류가 발생했습니다", - "libraries-restricted": "사용자는 라이브러리에 액세스할 수 없습니다", + "series-restricted": "사용자는 이 시리즈에 접근할 수 없습니다", + "generic-reading-list-update": "독서 목록 업데이트 중 문제가 발생했습니다", + "generic-reading-list-create": "독서 목록 생성 중 문제가 발생했습니다", + "generic-scrobble-hold": "보류 추가 중 오류가 발생했습니다", + "libraries-restricted": "사용자는 어떤 라이브러리에도 접근할 수 없습니다", "no-series-collection": "컬렉션에 대한 시리즈를 가져올 수 없습니다", - "generic-series-delete": "시리즈를 삭제하는 중에 문제가 발생했습니다", - "series-updated": "성공적으로 업데이트됨", + "generic-series-delete": "시리즈 삭제 중 문제가 발생했습니다", + "series-updated": "성공적으로 업데이트되었습니다", "update-metadata-fail": "메타데이터를 업데이트할 수 없습니다", - "encode-as-warning": "PNG로 변환할 수 없습니다. 표지의 경우 표지 새로 고침을 사용하십시오. 북마크와 파비콘은 다시 인코딩할 수 없습니다.", - "ip-address-invalid": "IP 주소 '{0}'이(가) 잘못되었습니다", + "encode-as-warning": "PNG로 변환할 수 없습니다. 커버의 경우 '커버 새로 고침'을 사용하세요. 북마크와 파비콘은 다시 인코딩할 수 없습니다.", + "ip-address-invalid": "IP 주소 '{0}'가 유효하지 않습니다", "bookmark-dir-permissions": "북마크 디렉토리에 Kavita가 사용할 수 있는 올바른 권한이 없습니다", - "generic-series-update": "시리즈를 업데이트하는 중에 오류가 발생했습니다", - "total-backups": "총 백업은 1에서 30 사이여야 합니다", + "generic-series-update": "시리즈 업데이트 중 오류가 발생했습니다", + "total-backups": "총 백업 수는 1에서 30 사이여야 합니다", "stats-permission-denied": "다른 사용자의 통계를 볼 권한이 없습니다", - "total-logs": "총 로그는 1에서 30 사이여야 합니다", - "url-not-valid": "URL이 유효한 이미지를 반환하지 않거나 승인이 필요합니다", - "generic-cover-series-save": "표지 이미지를 시리즈에 저장할 수 없습니다", - "generic-cover-collection-save": "컬렉션에 표지 이미지를 저장할 수 없습니다", - "generic-user-pref": "환경설정을 저장하는 중에 문제가 발생했습니다", - "generic-cover-reading-list-save": "읽기 목록에 표지 이미지를 저장할 수 없습니다", - "generic-cover-chapter-save": "표지 이미지를 챕터에 저장할 수 없습니다", - "generic-cover-library-save": "표지 이미지를 라이브러리에 저장할 수 없습니다", - "opds-disabled": "이 서버에서 OPDS를 사용할 수 없습니다", - "access-denied": "액세스 권한이 없습니다", - "reset-chapter-lock": "챕터에 대한 표지 잠금을 재설정할 수 없습니다", - "on-deck": "계속 읽기", - "browse-on-deck": "계속 읽기에서 찾아보기", - "reading-lists": "읽기 목록", + "total-logs": "총 로그 수는 1에서 30 사이여야 합니다", + "url-not-valid": "URL이 유효한 이미지를 반환하지 않거나 인증이 필요합니다", + "generic-cover-series-save": "시리즈에 커버 이미지를 저장할 수 없습니다", + "generic-cover-collection-save": "컬렉션에 커버 이미지를 저장할 수 없습니다", + "generic-user-pref": "환경 설정 저장 중 문제가 발생했습니다", + "generic-cover-reading-list-save": "독서 목록에 커버 이미지를 저장할 수 없습니다", + "generic-cover-chapter-save": "챕터에 커버 이미지를 저장할 수 없습니다", + "generic-cover-library-save": "라이브러리에 커버 이미지를 저장할 수 없습니다", + "opds-disabled": "이 서버에서 OPDS가 활성화되어 있지 않습니다", + "access-denied": "접근 권한이 없습니다", + "reset-chapter-lock": "챕터의 커버 잠금 재설정에 실패했습니다", + "on-deck": "다음에 읽을 것", + "browse-on-deck": "다음에 읽을 것 탐색", + "reading-lists": "독서 목록", "libraries": "모든 라이브러리", "generic-user-delete": "사용자를 삭제할 수 없습니다", - "recently-added": "최근에 추가됨", + "recently-added": "최근 추가됨", "collections": "모든 컬렉션", - "browse-collections": "컬렉션에서 찾아보기", - "reading-list-restricted": "읽기 목록이 없거나 액세스 권한이 없습니다", + "browse-collections": "컬렉션으로 탐색", + "reading-list-restricted": "독서 목록이 존재하지 않거나 접근 권한이 없습니다", "query-required": "쿼리 매개변수를 전달해야 합니다", - "search-description": "시리즈, 컬렉션 또는 읽기 목록 검색", + "search-description": "시리즈, 컬렉션 또는 독서 목록 검색", "favicon-doesnt-exist": "파비콘이 존재하지 않습니다", "not-authenticated": "사용자가 인증되지 않았습니다", "anilist-cred-expired": "AniList 자격 증명이 만료되었거나 설정되지 않았습니다", - "scrobble-bad-payload": "스크로블 공급자의 잘못된 페이로드", - "bad-copy-files-for-download": "임시 디렉토리 아카이브 다운로드에 파일을 복사할 수 없습니다.", + "scrobble-bad-payload": "스크로블 제공자로부터 잘못된 페이로드를 받았습니다", + "bad-copy-files-for-download": "다운로드를 위한 임시 디렉토리에 파일을 복사할 수 없습니다.", "search": "검색", - "theme-doesnt-exist": "테마 파일이 없거나 유효하지 않음", - "generic-create-temp-archive": "임시 보관 파일을 만드는 중에 문제가 발생했습니다", + "theme-doesnt-exist": "테마 파일이 없거나 유효하지 않습니다", + "generic-create-temp-archive": "임시 아카이브 생성 중 문제가 발생했습니다", "epub-html-missing": "해당 페이지에 적합한 HTML을 찾을 수 없습니다", - "epub-malformed": "파일 형식이 잘못되었습니다! 읽을 수 없습니다.", + "epub-malformed": "파일이 손상되었습니다! 읽을 수 없습니다.", "collection-tag-title-required": "컬렉션 제목은 비워둘 수 없습니다", - "collection-tag-duplicate": "이 이름을 가진 컬렉션이 이미 존재합니다", - "device-not-created": "이 장치는 아직 존재하지 않습니다. 먼저 생성해주세요", - "device-duplicate": "이 이름을 가진 장치가 이미 존재합니다", - "send-to-permission": "Kindle에서 지원되지 않는 비 EPUB 또는 PDF를 장치로 보낼 수 없음", - "user-no-access-library-from-series": "사용자는 이 시리즈가 속한 라이브러리에 액세스할 수 없습니다", - "reading-list-name-exists": "이 이름의 읽기 목록이 이미 있습니다", + "collection-tag-duplicate": "이 이름의 컬렉션이 이미 존재합니다", + "device-not-created": "이 장치는 아직 존재하지 않습니다. 먼저 생성하세요", + "device-duplicate": "이 이름의 장치가 이미 존재합니다", + "send-to-permission": "EPUB 또는 PDF가 아닌 파일은 Kindle에서 지원되지 않으므로 장치로 보낼 수 없습니다", + "user-no-access-library-from-series": "사용자는 이 시리즈가 속한 라이브러리에 접근할 수 없습니다", + "reading-list-name-exists": "이 이름의 독서 목록이 이미 존재합니다", "series-restricted-age-restriction": "사용자는 연령 제한으로 인해 이 시리즈를 볼 수 없습니다", "book-num": "책 {0}", "issue-num": "이슈 {0}{1}", - "browse-recently-added": "최근 추가된 항목에서 찾아보기", - "browse-reading-lists": "읽기 목록에서 찾아보기", - "browse-libraries": "라이브러리에서 찾아보기", - "unable-to-register-k+": "오류로 인해 라이선스를 등록할 수 없습니다. Kavita+ 지원 문의", - "want-to-read": "읽고 싶어요", - "browse-want-to-read": "읽고 싶어요에서 찾아보기", + "browse-recently-added": "최근 추가된 항목 탐색", + "browse-reading-lists": "독서 목록으로 탐색", + "browse-libraries": "라이브러리로 탐색", + "unable-to-register-k+": "오류로 인해 라이센스를 등록할 수 없습니다. Kavita+ 지원팀에 연락하세요", + "want-to-read": "읽고 싶은 책", + "browse-want-to-read": "읽고 싶은 책 탐색", "collection-deleted": "컬렉션이 삭제되었습니다", "smart-filters": "스마트 필터", - "browse-smart-filters": "스마트 필터로 찾아보기", + "browse-smart-filters": "스마트 필터로 탐색", "smart-filter-doesnt-exist": "스마트 필터가 존재하지 않습니다", - "browse-external-sources": "외부 소스 찾아보기", - "external-source-already-in-use": "이 외부 소스가 포함된 기존 스트림이 있습니다", + "browse-external-sources": "외부 소스 탐색", + "external-source-already-in-use": "이 외부 소스로 이미 스트림이 존재합니다", "dashboard-stream-doesnt-exist": "대시보드 스트림이 존재하지 않습니다", "external-source-already-exists": "외부 소스가 이미 존재합니다", - "sidenav-stream-doesnt-exist": "사이드 내비게이션 스트림이 존재하지 않습니다", + "sidenav-stream-doesnt-exist": "사이드바 스트림이 존재하지 않습니다", "external-source-doesnt-exist": "외부 소스가 존재하지 않습니다", "external-sources": "외부 소스", - "external-source-required": "ApiKey 및 호스트가 필요합니다", - "smart-filter-already-in-use": "이 스마트 필터가 포함된 기존 스트림이 있습니다", - "invalid-email": "등록된 사용자의 이메일은 유효한 이메일이 아닙니다. 링크를 확인하려면 로그를 참조하세요.", - "browse-more-in-genre": "{0}에서 더 찾아보기", - "more-in-genre": "장르 {0}에서 더 보기", + "external-source-required": "ApiKey와 호스트가 필요합니다", + "smart-filter-already-in-use": "이 스마트 필터로 이미 스트림이 존재합니다", + "invalid-email": "사용자의 이메일이 유효하지 않습니다. 링크는 로그를 확인하세요.", + "browse-more-in-genre": "{0}의 더 많은 항목 탐색", + "more-in-genre": "{0} 장르의 더 많은 항목", "recently-updated": "최근 업데이트됨", - "browse-recently-updated": "최근에 업데이트된 내용을 찾아보기", - "email-not-enabled": "이 서버에서는 이메일이 활성화되어 있지 않습니다. 이 작업을 수행할 수 없습니다.", - "send-to-size-limit": "보내려고 하는 파일은 이메일 전송 용량을 초과했습니다", - "send-to-unallowed": "본인 이외의 기기로는 전송할 수 없습니다", - "unable-to-reset-k+": "오류로 인해 Kavita+ 라이선스를 재설정할 수 없습니다. Kavita+ 지원팀에 문의하십시오" + "browse-recently-updated": "최근 업데이트된 항목 탐색", + "email-not-enabled": "이 서버에서는 이메일이 활성화되지 않았습니다. 이 작업을 수행할 수 없습니다.", + "send-to-size-limit": "보내려는 파일이 이메일 제공업체에 비해 너무 큽니다", + "send-to-unallowed": "본인 소유가 아닌 장치로 보낼 수 없습니다", + "unable-to-reset-k+": "오류로 인해 Kavita+ 라이센스를 재설정할 수 없습니다. Kavita+ 지원팀에 연락하세요", + "check-updates": "업데이트 확인", + "license-check": "라이센스 확인", + "process-scrobbling-events": "스크로블링 이벤트 처리", + "report-stats": "통계 보고", + "check-scrobbling-tokens": "스크로블링 토큰 확인", + "cleanup": "정리", + "remove-from-want-to-read": "읽고 싶은 목록에서 제거", + "scan-libraries": "라이브러리 스캔", + "kavita+-data-refresh": "Kavita+ 데이터 새로 고침", + "backup": "백업", + "update-yearly-stats": "연간 통계 업데이트", + "process-processed-scrobbling-events": "처리된 스크로블링 이벤트 처리", + "account-email-invalid": "관리자 계정의 이메일이 유효하지 않습니다. 테스트 이메일을 보낼 수 없습니다.", + "email-settings-invalid": "이메일 설정에 누락된 정보가 있습니다. 모든 이메일 설정이 저장되었는지 확인하세요.", + "collection-already-exists": "컬렉션이 이미 존재합니다", + "error-import-stack": "MAL 스택 가져오기 중 문제가 발생했습니다", + "generic-cover-person-save": "인물에 커버 이미지를 저장할 수 없습니다", + "generic-cover-volume-save": "볼륨에 커버 이미지를 저장할 수 없습니다", + "person-doesnt-exist": "사람이 존재하지 않습니다", + "person-name-required": "개인 이름은 필수 항목이며 null일 수 없습니다", + "person-name-unique": "개인 이름은 고유해야 합니다", + "person-image-doesnt-exist": "CoversDB에 사람이 존재하지 않습니다", + "kavitaplus-restricted": "Kavita+만 해당", + "email-taken": "이미 사용중인 이메일" } diff --git a/API/I18N/ms.json b/API/I18N/ms.json index c4f9371a1..a7191f037 100644 --- a/API/I18N/ms.json +++ b/API/I18N/ms.json @@ -24,7 +24,6 @@ "generic-invite-user": "Terdapat masalah jemputan pengguna. Sila semak log.", "invalid-email-confirmation": "Pengesahan e-mel tidak sah", "generic-user-email-update": "Pengguna e-mel ini tidak dapat di kemas kinikan. Semak log.", - "bad-credentials": "Bukti kelayakan anda tidak betul", "locked-out": "Anda telah di sekat kerana terlalu banyak membuat percubaan kebenaran. Sila tunggu 10 minit.", "disabled-account": "Akaun anda telah di sekat. Hubungi pentadbir server.", "register-user": "Sesuatu telah berlaku kesilapan semasa pendaftaran pengguna", diff --git a/API/I18N/nl.json b/API/I18N/nl.json index 7d9d2a348..e9597a6cb 100644 --- a/API/I18N/nl.json +++ b/API/I18N/nl.json @@ -7,7 +7,6 @@ "generic-password-update": "Er is een onverwachte fout opgetreden bij het bevestigen van het nieuwe wachtwoord", "locked-out": "U bent uitgesloten door te veel autorisatiepogingen. Wacht alsjeblieft 10 minuten.", "register-user": "Er is iets misgegaan bij het registreren van de gebruiker", - "bad-credentials": "Uw inloggegevens zijn niet correct", "disabled-account": "Uw account is uitgeschakeld. Neem contact op met de serverbeheerder.", "validate-email": "Er is een probleem opgetreden bij het valideren van uw e-mailadres: {0}", "confirm-token-gen": "Er is een probleem opgetreden bij het genereren van een bevestigingstoken", diff --git a/API/I18N/pl.json b/API/I18N/pl.json index f51991dda..68a4a1a4f 100644 --- a/API/I18N/pl.json +++ b/API/I18N/pl.json @@ -1,5 +1,4 @@ { - "bad-credentials": "Twoje dane uwierzytelniające są nieprawidłowe", "disabled-account": "Twoje konto jest wyłączone. Skontaktuj się z administratorem serwera.", "register-user": "Coś poszło nie tak podczas rejestracji użytkownika", "validate-email": "Wystąpił problem z weryfikacją adresu e-mail: {0}", @@ -53,8 +52,160 @@ "collection-doesnt-exist": "Kolekcja nie istnieje", "generic-device-create": "Wystąpił błąd podczas tworzenia urządzenia", "generic-device-update": "Wystąpił błąd podczas aktualizacji urządzenia", - "send-to-kavita-email": "Funkcja Wyślij do urządzenia nie może być używana z usługą e-mail Kavita. Należy skonfigurować własną.", + "send-to-kavita-email": "Funkcja Wyślij do urządzenia nie może być używana bez konfiguracji poczty e-mail", "bookmark-doesnt-exist": "Zakładka nie istnieje", "series-doesnt-exist": "Seria nie istnieje", - "must-be-defined": "{0} musi być zdefiniowane" + "must-be-defined": "{0} musi być zdefiniowane", + "email-not-enabled": "E-mail nie jest włączony na tym serwerze. Nie można wykonać tej akcji.", + "send-to-size-limit": "Plik(i), które próbujesz wysłać, są zbyt duże dla twojego dostawcy poczty e-mail", + "generic-favicon": "Wystąpił problem z pobieraniem favicon dla domeny", + "invalid-filename": "Nieprawidłowa nazwa pliku", + "file-doesnt-exist": "Plik nie istnieje", + "library-name-exists": "Nazwa biblioteki już istnieje. Wybierz unikalną nazwę dla serwera.", + "no-library-access": "Użytkownik nie ma dostępu do tej biblioteki", + "user-doesnt-exist": "Użytkownik nie istnieje", + "library-doesnt-exist": "Biblioteka nie istnieje", + "invalid-path": "Nieprawidłowa ścieżka", + "generic-library-update": "Pojawił się krytyczny problem z aktualizacją biblioteki.", + "bookmark-save": "Nie udało się zapisać zakładki", + "generic-library": "Wystąpił błąd krytyczny. Spróbuj ponownie.", + "no-image-for-page": "Brak takiego obrazu dla strony {0}. Spróbuj odświeżyć, aby zezwolić na ponowne buforowanie.", + "send-to-unallowed": "Nie można wysyłać na urządzenie, które nie należy do Ciebie", + "delete-library-while-scan": "Nie można usunąć biblioteki podczas skanowania. Poczekaj na zakończenie skanowania lub uruchom ponownie Kavitę, a następnie spróbuj usunąć bibliotekę", + "generic-clear-bookmarks": "Nie udało się wyczyścić zakładek", + "bookmark-permission": "Nie masz uprawnień do dodawania/usuwania zakładek", + "cache-file-find": "Nie można znaleźć obrazu z pamięci podręcznej. Przeładuj i spróbuj ponownie.", + "collection-deleted": "Kolekcja usunięta", + "invalid-access": "Nieprawidłowy dostęp", + "pdf-doesnt-exist": "Plik PDF nie istnieje, gdy powinien", + "perform-scan": "Przeprowadź skanowanie tej serii lub biblioteki i spróbuj ponownie", + "generic-read-progress": "Wystąpił problem z zapisywaniem postępu", + "smart-filters": "Inteligentne filtry", + "browse-smart-filters": "Przeglądaj według inteligentnych filtrów", + "smart-filter-doesnt-exist": "Inteligentny filtr nie istnieje", + "send-to-permission": "Nie można wysłać plików innych niż EPUB lub PDF na urządzenia, ponieważ nie są one obsługiwane przez Kindle", + "book-num": "Książka {0}", + "no-series": "Nie udało się uzyskać serii dla biblioteki", + "no-series-collection": "Nie udało się uzyskać serii dla kolekcji", + "generic-relationship": "Wystąpił problem z aktualizacją powiązań", + "ip-address-invalid": "Adres IP \"{0}\" jest nieprawidłowy", + "generic-cover-series-save": "Nie można zapisać obrazu okładki do serii", + "generic-cover-collection-save": "Nie można zapisać obrazu okładki dla kolekcji", + "generic-cover-chapter-save": "Nie można zapisać obrazu okładki dla rozdziału", + "generic-user-pref": "Wystąpił problem z zapisaniem preferencji", + "opds-disabled": "OPDS nie jest włączony na tym serwerze", + "want-to-read": "Chcę przeczytać", + "browse-libraries": "Przeglądanie według bibliotek", + "collections": "Wszystkie Kolekcje", + "browse-collections": "Przeglądaj według Kolekcji", + "external-source-required": "Klucz API i Host jest wymagany", + "bad-copy-files-for-download": "Nie można skopiować plików do pobieranego archiwum katalogu tymczasowego.", + "generic-create-temp-archive": "Wystąpił problem z tworzeniem archiwum tymczasowego", + "epub-html-missing": "Nie można znaleźć odpowiedniego html dla tej strony", + "check-updates": "Sprawdź aktualizacje", + "license-check": "Sprawdź licencje", + "report-stats": "Statystyki raportów", + "cleanup": "Wyczyść", + "kavita+-data-refresh": "Odśwież dane Kavita+", + "backup": "Kopia zapasowa", + "more-in-genre": "Więcej w gatunku {0}", + "browse-more-in-genre": "Przeglądaj więcej w {0}", + "external-sources": "Zewnętrzne źródła", + "browse-external-sources": "Przeglądaj zewnętrzne źródła", + "recently-updated": "Niedawno zaktualizowane", + "browse-recently-updated": "Przeglądaj niedawno zaktualizowane", + "external-source-already-exists": "Zewnętrzne źródło już istnieje", + "collection-tag-duplicate": "Kolekcja o tej nazwie już istnieje", + "device-not-created": "To urządzenie jeszcze nie istnieje. Utwórz je najpierw", + "series-restricted-age-restriction": "Użytkownik nie może wyświetlić tej serii ze względu na ograniczenia wiekowe", + "series-updated": "Pomyślnie zaktualizowano", + "update-metadata-fail": "Nie udało się zaktualizować metadanych", + "total-logs": "Łączna liczba logów musi mieścić się w przedziale od 1 do 30", + "stats-permission-denied": "Nie masz uprawnień do wyświetlania statystyk innego użytkownika", + "url-not-valid": "Adres URL nie zwraca prawidłowego obrazu lub wymaga autoryzacji", + "generic-user-delete": "Nie można usunąć użytkownika", + "access-denied": "Nie masz dostępu", + "browse-recently-added": "Przeglądaj Ostatnio dodane", + "recently-added": "Ostatnio dodane", + "search": "Szukaj", + "volume-num": "Tom {0}", + "device-duplicate": "Urządzenie o tej nazwie już istnieje", + "unable-to-reset-k+": "Nie można zresetować licencji Kavita+ z powodu błędu. Skontaktuj się z pomocą techniczną Kavita+", + "issue-num": "Wydanie {0}{1}", + "chapter-num": "Rozdział {0}", + "libraries-restricted": "Użytkownik nie ma dostępu do żadnych bibliotek", + "generic-series-delete": "Wystąpił problem z usunięciem serii", + "generic-series-update": "Wystąpił błąd podczas aktualizacji serii", + "age-restriction-not-applicable": "Bez ograniczeń", + "bookmark-dir-permissions": "Folder zakładek nie ma prawidłowych uprawnień do użycia przez Kavitę", + "epub-malformed": "Plik jest zniekształcony! Nie można odczytać.", + "total-backups": "Łączna liczba kopii zapasowych musi mieścić się w przedziale od 1 do 30", + "user-no-access-library-from-series": "Użytkownik nie ma dostępu do biblioteki, do której należy ta seria", + "generic-cover-library-save": "Nie można zapisać obrazu okładki dla biblioteki", + "browse-want-to-read": "Przeglądaj Chcę przeczytać", + "scan-libraries": "Skanuj Biblioteki", + "external-source-doesnt-exist": "Zewnętrzne źródło nie istnieje", + "update-yearly-stats": "Aktualizuj roczne statystyki", + "not-authenticated": "Użytkownik nie jest uwierzytelniony", + "unable-to-register-k+": "Nie można zarejestrować licencji z powodu błędu. Skontaktuj się z pomocą techniczną Kavita+", + "anilist-cred-expired": "Poświadczenia AniList wygasły lub nie zostały ustawione", + "collection-tag-title-required": "Tytuł Kolekcji nie może być pusty", + "job-already-running": "Zadanie już uruchomione", + "encode-as-warning": "Nie można konwertować do formatu PNG. W przypadku okładek należy użyć opcji Odśwież okładki. Zakładek i ikon ulubionych nie można zakodować z powrotem.", + "favicon-doesnt-exist": "Favicon nie istnieje", + "theme-doesnt-exist": "Brak pliku motywu lub jest on nieprawidłowy", + "libraries": "Wszystkie biblioteki", + "email-settings-invalid": "Brak informacji o ustawieniach poczty e-mail. Upewnij się, że wszystkie ustawienia poczty e-mail zostały zapisane.", + "series-restricted": "Użytkownik nie ma dostępu do tej serii", + "generic-cover-reading-list-save": "Nie można zapisać obrazu okładki na liście czytelniczej", + "browse-on-deck": "Przeglądaj Czytaj dalej", + "reading-list-name-exists": "Lista czytelnicza o tej nazwie już istnieje", + "name-required": "Nazwa nie może być pusta", + "valid-number": "Wprowadź poprawny numer strony", + "duplicate-bookmark": "Duplikat zakładki już istnieje", + "reading-list-position": "Nie można zaktualizować położenia", + "reading-list-deleted": "Lista czytelnicza została usunięta", + "generic-reading-list-delete": "Wystąpił problem z usunięciem listy czytelniczej", + "url-required": "Musisz podać adres URL, aby użyć", + "on-deck": "Czytaj dalej", + "reading-list-doesnt-exist": "Lista czytelnicza nie istnieje", + "reading-list-updated": "Zaktualizowano", + "generic-reading-list-create": "Wystąpił problem z utworzeniem listy czytelniczej", + "generic-scrobble-hold": "Wystąpił błąd podczas oznaczania jako wstrzymane", + "reset-chapter-lock": "Nie można zresetować blokady okładki dla rozdziału", + "reading-lists": "Listy czytelnicze", + "browse-reading-lists": "Przeglądaj według list czytelniczych", + "reading-list-restricted": "Lista czytelnicza nie istnieje lub nie masz do niej dostępu", + "search-description": "Szukaj serii, kolekcji lub list czytelniczych", + "reading-list-title-required": "Tytuł listy czytelniczej nie może być pusty", + "progress-must-exist": "Postęp musi istnieć u użytkownika", + "reading-list-permission": "Nie masz uprawnień do tej listy czytelniczej lub lista nie istnieje", + "reading-list-item-delete": "Nie można usunąć elementu(ów)", + "generic-reading-list-update": "Wystąpił problem z aktualizacją listy czytelniczej", + "collection-already-exists": "Kolekcja już istnieje", + "generic-cover-person-save": "Nie udało się zapisać obrazu dla Osoby", + "generic-cover-volume-save": "Nie udało się zapisać obrazu okładki dla Tomu", + "external-source-already-in-use": "Istnieje już strumień z tym zewnętrznym źródłem", + "scrobble-bad-payload": "Nieprawidłowy payloadod dostawcy Scrobblowania", + "process-scrobbling-events": "Zdarzenia związane z procesem Scrobblowania", + "process-processed-scrobbling-events": "Przetworzone zdarzenia Scrobblowania", + "error-import-stack": "Wystąpił problem z importowaniem MAL Stack", + "account-email-invalid": "Adres e-mail dla konta administratora nie jest prawidłowy. Nie można wysłać testowej wiadomości e-mail.", + "query-required": "Należy przekazać parametr zapytania", + "smart-filter-already-in-use": "Istnieje strumień z tym inteligentnym filtrem", + "dashboard-stream-doesnt-exist": "Strumień pulpitu nawigacyjnego nie istnieje", + "sidenav-stream-doesnt-exist": "Strumień SideNav nie istnieje", + "remove-from-want-to-read": "Wyczyść Chcesz przeczytać", + "check-scrobbling-tokens": "Sprawdź tokeny Scrobblowania", + "invalid-email": "Adres e-mail użytkownika w pliku nie jest prawidłowy. Zobacz logi dla wszystkich linków.", + "person-doesnt-exist": "Osoba nie istnieje", + "person-name-required": "Nazwa osoby jest wymagana i nie może mieć wartości null", + "person-name-unique": "Nazwa osoby musi być unikatowa", + "person-image-doesnt-exist": "Osoba nie istnieje w CoversDB", + "email-taken": "Adres e-mail jest już używany", + "kavitaplus-restricted": "Jest to dostępne tylko dla Kavita+", + "smart-filter-name-required": "Inteligentny filtr wymaga nazwy", + "sidenav-stream-only-delete-smart-filter": "Tylko inteligentne filtry mogą zostać usunięte z panelu bocznego", + "dashboard-stream-only-delete-smart-filter": "Tylko inteligentne strumienie filtrów może zostać usunięte z głównego panelu", + "smart-filter-system-name": "Nie można użyć nazwy systemu dostarczanego strumieniem" } diff --git a/API/I18N/pt.json b/API/I18N/pt.json index b414f7ab2..d0dd3345f 100644 --- a/API/I18N/pt.json +++ b/API/I18N/pt.json @@ -1,5 +1,4 @@ { - "bad-credentials": "As credenciais não estão corretas", "password-required": "Se não for administrador, tem de introduzir a sua palavra passe para alterar a sua conta", "confirm-token-gen": "Ocorreu um problema a gerar um token de confirmação", "age-restriction-update": "Ocorreu um erro ao atualizar a restrição de idade", @@ -180,5 +179,33 @@ "unable-to-reset-k+": "Não foi possível redefinir a licença do Kavita+ devido a um erro. Entre em contacto com o suporte Kavita +", "email-not-enabled": "O email não está habilitado neste servidor. Não pode executar esta ação.", "send-to-unallowed": "Não pode enviar para um dispositivo que não é seu", - "send-to-size-limit": "Os ficheiros que está a tentar enviar são demasiado grandes para o seu remetente" + "send-to-size-limit": "Os ficheiros que está a tentar enviar são demasiado grandes para o seu serviço de email", + "backup": "Backup", + "check-scrobbling-tokens": "Verificar Tokens de Scrobbling", + "cleanup": "Limpar", + "process-processed-scrobbling-events": "Processar Eventos de Scrobbling Processados", + "remove-from-want-to-read": "Limpeza de Leituras Desejadas", + "account-email-invalid": "O e-mail registado para a conta admin não é um e-mail válido. Não pode enviar e-mail de teste.", + "email-settings-invalid": "As definições de e-mail têm informação em falta. Certifique-se de que todas as definições de e-mail estão gravadas.", + "report-stats": "Relatório de Estatísticas", + "check-updates": "Verificar Atualizações", + "license-check": "Verificação de Licença", + "process-scrobbling-events": "Processar Eventos de Scrobbling", + "scan-libraries": "Analisar Bibliotecas", + "kavita+-data-refresh": "Atualização de dados do Kavita+", + "update-yearly-stats": "Atualizar estatísticas anuais", + "collection-already-exists": "Coleção já existente", + "error-import-stack": "Ocorreu um problema a importar uma pilha do MAL", + "generic-cover-person-save": "Não foi possível gravar a imagem de capa na Pessoa", + "generic-cover-volume-save": "Não foi possível gravar a imagem de capa no Volume", + "person-doesnt-exist": "Pessoa não existe", + "person-name-required": "O nome da pessoa é obrigatório e não pode nulo", + "person-name-unique": "O nome da pessoa tem de ser único", + "person-image-doesnt-exist": "A pessoa não existe na CoversDB", + "email-taken": "Email já em uso", + "kavitaplus-restricted": "Ação restrita ao Kavita+", + "sidenav-stream-only-delete-smart-filter": "Apenas os filtros inteligentes podem ser removidos da Navegação Lateral", + "dashboard-stream-only-delete-smart-filter": "Apenas os filtros inteligentes podem ser removidos do painel", + "smart-filter-system-name": "Não pode usar o nome de um fluxo disponibilizado pelo sistema", + "smart-filter-name-required": "Nome requerido para o filtro inteligente" } diff --git a/API/I18N/pt_BR.json b/API/I18N/pt_BR.json index 7550dc9f8..7180b3404 100644 --- a/API/I18N/pt_BR.json +++ b/API/I18N/pt_BR.json @@ -11,7 +11,6 @@ "delete-library-while-scan": "Você não pode excluir uma biblioteca enquanto uma verificação estiver em andamento. Aguarde a conclusão da verificação ou reinicie o Kavita e tente excluir", "generic-library-update": "Ocorreu um problema crítico ao atualizar a biblioteca.", "confirm-email": "Você deve confirmar seu e-mail primeiro", - "bad-credentials": "Suas credenciais não estão corretas", "locked-out": "Você foi bloqueado por muitas tentativas de autorização. Aguarde 10 minutos.", "validate-email": "Ocorreu um problema ao validar seu e-mail: {0}", "denied": "Não permitido", @@ -180,5 +179,33 @@ "unable-to-reset-k+": "Não foi possível redefinir a licença Kavita+ devido a um erro. Entre em contato com o suporte Kavita +", "send-to-unallowed": "Você não pode enviar para um dispositivo que não seja seu", "email-not-enabled": "O e-mail não está ativado neste servidor. Você não pode executar esta ação.", - "send-to-size-limit": "Os arquivos que você está tentando enviar são muito grandes para o seu e-mail" + "send-to-size-limit": "Os arquivos que você está tentando enviar são muito grandes para o seu provedor de e-mail", + "check-updates": "Verificar por Atualizações", + "license-check": "Verificar Licença", + "process-scrobbling-events": "Eventos de Scrobbling de Processo", + "report-stats": "Estatísticas do Relatório", + "process-processed-scrobbling-events": "Processar eventos de Scrobbling processados", + "remove-from-want-to-read": "Limpar Quero Ler", + "scan-libraries": "Escanear Bibliotecas", + "backup": "Backup", + "update-yearly-stats": "Atualizar estatísticas anuais", + "check-scrobbling-tokens": "Verificar os Tokens de Scrobbling", + "cleanup": "Limpar", + "kavita+-data-refresh": "Atualização de dados Kavita+", + "account-email-invalid": "O e-mail registrado para a conta de administrador não é um e-mail válido. Não é possível enviar e-mail de teste.", + "email-settings-invalid": "Faltam informações nas configurações de e-mail. Certifique-se de que todas as configurações de e-mail estejam salvas.", + "error-import-stack": "Ocorreu um problema ao importar a pilha MAL", + "collection-already-exists": "A coleção já existe", + "generic-cover-person-save": "Não foi possível salvar a imagem da capa em Pessoa", + "generic-cover-volume-save": "Não foi possível salvar a imagem da capa no Volume", + "person-doesnt-exist": "Pessoa não existe", + "person-image-doesnt-exist": "A pessoa não existe no CoversDB", + "person-name-required": "O nome da pessoa é obrigatório e não deve ser nulo", + "person-name-unique": "O nome da pessoa deve ser exclusivo", + "email-taken": "E-mail já em uso", + "kavitaplus-restricted": "Isso é restrito apenas ao Kavita+", + "smart-filter-name-required": "Nome do Filtro Inteligente obrigatório", + "dashboard-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do painel", + "smart-filter-system-name": "Você não pode usar o nome de um fluxo fornecido pelo sistema", + "sidenav-stream-only-delete-smart-filter": "Somente fluxos de filtros inteligentes podem ser excluídos do Navegador Lateral" } diff --git a/API/I18N/ru.json b/API/I18N/ru.json index b5b8a8ca6..c75f58fb1 100644 --- a/API/I18N/ru.json +++ b/API/I18N/ru.json @@ -1,6 +1,5 @@ { - "confirm-email": "Сначала вы должны подтвердить свой адрес электронной почты", - "bad-credentials": "Ваши учетные данные неверны", + "confirm-email": "Вы обязаны сначала подтвердить свою почту", "generate-token": "Возникла проблема с генерацией токена подтверждения электронной почты. Смотрите журналы", "invalid-password": "Неверный пароль", "invalid-email-confirmation": "Неверное подтверждение электронной почты", @@ -13,7 +12,7 @@ "user-migration-needed": "Этот пользователь нуждается в миграции. Пусть они выйдут из системы и войдут в нее, чтобы запустить поток миграции", "generic-user-update": "При обновлении пользователя возникало исключение", "disabled-account": "Ваша учетная запись отключена. Обратитесь к администратору сервера.", - "locked-out": "Вы были заблокированы из-за слишком большого количества попыток входа. Пожалуйста, подождите 10 минут.", + "locked-out": "Вы были заблокированы из-за слишком большого количества попыток входа. Пожалуйста, повторите через 10 минут.", "invalid-token": "Неверный токен", "generic-user-email-update": "Невозможно обновить электронную почту пользователя. Проверьте журналы.", "password-updated": "Обновление пароля", @@ -176,5 +175,24 @@ "browse-more-in-genre": "Посмотреть больше в {0}", "more-in-genre": "Больше в жанре {0}", "recently-updated": "Недавно обновленный", - "browse-recently-updated": "Просмотреть недавно обновленные" + "browse-recently-updated": "Просмотреть недавно обновленные", + "email-not-enabled": "На данном сервере отключена почта. Вы не можете совершить это действие.", + "send-to-unallowed": "Вы не можете отправить на чужое устроиство", + "send-to-size-limit": "Файл(ы) имеют слишком большой вес для отправки по почте", + "check-updates": "Проверьте обновления", + "license-check": "Проверка лицензии", + "process-scrobbling-events": "События, связанные со скроблингом процессов", + "report-stats": "Статистика отчетов", + "cleanup": "Очистка", + "update-yearly-stats": "Обновление статистики за год", + "collection-already-exists": "Коллекция уже существует", + "error-import-stack": "Возникла проблема с импортом стека MAL", + "unable-to-reset-k+": "Невозможно сбросить лицензию Kavita+ из-за ошибки. Обратитесь в службу поддержки Kavita+", + "account-email-invalid": "Адрес электронной почты, указанный в файле для учетной записи администратора, не является действительным. Не удается отправить тестовое электронное письмо.", + "email-settings-invalid": "В настройках электронной почты отсутствует информация. Убедитесь, что все настройки электронной почты сохранены.", + "check-scrobbling-tokens": "Проверьте токены скроблинга", + "backup": "Резервное копирование", + "process-processed-scrobbling-events": "Обработка обработанных событий скроблинга", + "scan-libraries": "Сканирование библиотек", + "kavita+-data-refresh": "Обновление данных Kavita+" } diff --git a/API/I18N/ar.json b/API/I18N/sl.json similarity index 100% rename from API/I18N/ar.json rename to API/I18N/sl.json diff --git a/API/I18N/sv.json b/API/I18N/sv.json new file mode 100644 index 000000000..64004a7a8 --- /dev/null +++ b/API/I18N/sv.json @@ -0,0 +1,211 @@ +{ + "disabled-account": "Ditt konto är inaktiverat. Kontakta serveradministratören.", + "register-user": "Någonting gick fel vid registrering av användare", + "validate-email": "Det uppstod ett problem vid bekräftelsen av din e-post: {0}", + "denied": "Inte tillåtet", + "permission-denied": "Du har inte tillåtelse att utföra den här operationen", + "age-restriction-update": "Det uppstod ett fel vid uppdatering av åldersbegränsningen", + "no-user": "Användaren finns inte", + "username-taken": "Användarnamnet är redan upptaget", + "user-already-confirmed": "Användaren är redan bekräftad", + "generic-user-update": "Det var en avvikelse vid uppdatering av användaren", + "manual-setup-fail": "Manuell inställning kan inte bli slutföras. Vänligen avbryt och återskapa inbjudan", + "user-already-registered": "Användare är redan registrerad som {0}", + "forgot-password-generic": "Ett mail skickas till e-posten om det finns i vår databas", + "not-accessible-password": "Din server är inte tillgänglig. Länken för att återställa ditt lösenord finns i loggarna", + "invalid-email": "E-posten i arkivet för användaren är inte en giltig e-post. Se loggar för länkar.", + "not-accessible": "Din server är inte tillgänglig externt", + "email-sent": "Mail skickat", + "user-migration-needed": "Denna användare behöver migreras. Se till att de loggar ut och in igen för att trigga migrationsprocessen", + "generic-invite-email": "Det uppstod ett problem med att skicka inbjudningsmail igen", + "admin-already-exists": "Administratör finns redan", + "invalid-username": "Ogiltigt användarnamn", + "critical-email-migration": "Det uppstod ett problem vid e-postmigration. Kontakta supporten", + "email-not-enabled": "E-post är inte aktiverat på denna server. Du kan inte utföra den här åtgärden.", + "account-email-invalid": "E-posten i arkivet för användaren är inte en giltig e-post. Kan inte skicka testmail.", + "email-settings-invalid": "E-postinställningarna saknar information. Se till att alla e-postinställningar är sparade.", + "chapter-doesnt-exist": "Kapitel saknas", + "collection-updated": "Samlingen har uppdaterats", + "collection-deleted": "Samling borttagen", + "collection-doesnt-exist": "Samling finns inte", + "collection-already-exists": "Samling finns redan", + "error-import-stack": "Det uppstod ett fel vid import av MAL stack", + "generic-device-create": "Det uppstod ett fel vid skapandet av enheten", + "generic-device-update": "Det uppstod ett fel vid uppdatering av enheten", + "generic-device-delete": "Det uppstod ett fel vid borttagning av enheten", + "greater-0": "{0} måste vara större än 0", + "send-to-kavita-email": "Skicka till enhet kan inte användas utan inställning av e-post", + "send-to-size-limit": "Filerna du försöker skicka är för stora för din e-postleverantör", + "generic-send-to": "Det uppstod ett fel vid sändningen av dina filer till enheten", + "bookmarks-empty": "Bokmärken kan inte vara tomma", + "series-doesnt-exist": "Serie saknas", + "volume-doesnt-exist": "Volym saknas", + "must-be-defined": "{0} måste vara definierad", + "generic-favicon": "Det uppstod ett problem vid hämtning av favikon för domän", + "invalid-filename": "Ogiltigt filnamn", + "file-doesnt-exist": "Fil saknas", + "library-name-exists": "Biblioteksnamn finns redan. Vänligen välj ett unikt namn för servern.", + "generic-library": "Det uppstod ett kritiskt fel. Vänligen försök igen.", + "no-library-access": "Användaren har inte tillgång till detta bibliotek", + "user-doesnt-exist": "Användaren finns inte", + "library-doesnt-exist": "Biblioteket finns inte", + "invalid-path": "Ogiltig Sökväg", + "generic-library-update": "Det uppstod ett kritiskt fel vid uppdatering av biblioteket.", + "pdf-doesnt-exist": "PDF saknas när den borde finnas", + "invalid-access": "Ogiltig Åtkomst", + "no-image-for-page": "Ingen bild för sidan {0}. Försök att uppdatera för att tillåta återcache.", + "generic-read-progress": "Ett problem uppstod vid sparandet av historik", + "generic-clear-bookmarks": "Kunde inte rensa bokmärken", + "bookmark-save": "Kunde inte spara bokmärke", + "cache-file-find": "Kunde inte hitta cachad bild. Ladda om och försök igen.", + "name-required": "Namn kan inte vara tomt", + "valid-number": "Måste vara ett giltigt sidnummer", + "duplicate-bookmark": "Bokmärkespost med samma namn finns redan", + "reading-list-position": "Kunde inte uppdatera position", + "reading-list-item-delete": "Kunde inte ta bort objekt", + "reading-list-deleted": "Läslista borttagen", + "generic-reading-list-delete": "Ett problem uppstod vid borttagning av läslistan", + "generic-reading-list-update": "Ett problem uppstod vid uppdatering av läslistan", + "reading-list-doesnt-exist": "Läslista saknas", + "series-restricted": "Användaren har inte behörighet för denna Serien", + "generic-scrobble-hold": "Ett fel uppstod när uppehållet lades till", + "libraries-restricted": "Användaren har inte behörighet till några bibliotek", + "no-series": "Kunde inte skaffa serier för Bibliotek", + "generic-series-delete": "Det uppstod ett problem vid borttagning av serien", + "generic-series-update": "Det uppstod ett fel vid uppdatering av serien", + "series-updated": "Uppdatering lyckades", + "update-metadata-fail": "Kunde inte uppdatera metadata", + "age-restriction-not-applicable": "Ingen Begränsning", + "job-already-running": "Jobb körs redan", + "ip-address-invalid": "IP Adress '{0}' är ogiltig", + "bookmark-dir-permissions": "Bokmärkeskatalogen har inte rätt behörigheter för Kavita att använda", + "stats-permission-denied": "Du har inte behörighet att se andra användares statistik", + "url-not-valid": "Url returnerar inte en giltig bild eller kräver auktorisering", + "url-required": "Du måste skicka en url att använda", + "generic-cover-series-save": "Det gick inte att spara omslagsbilden till Serier", + "generic-cover-collection-save": "Det gick inte att spara omslagsbilden till Samling", + "generic-cover-reading-list-save": "Det gick inte att spara omslagsbilden till Läslista", + "generic-cover-chapter-save": "Det gick inte att spara omslagsbilden till Kapitel", + "generic-cover-library-save": "Det gick inte att spara omslagsbilden till Bibliotek", + "access-denied": "Du saknar behörighet", + "reset-chapter-lock": "Det gick inte att återställa omslagsbilden för Kapitel", + "generic-user-delete": "Kunde inte ta bort användaren", + "generic-user-pref": "Det uppstod ett problem vid sparandet av inställningar", + "recently-added": "Nyligen Tillagt", + "want-to-read": "Vill Läsa", + "browse-want-to-read": "Bläddra i Vill Läsa", + "browse-recently-added": "Bläddra i Nyligen Tillagt", + "reading-lists": "Läslistor", + "libraries": "Alla Bibliotek", + "browse-libraries": "Bläddra efter Bibliotek", + "recently-updated": "Nyligen Uppdaterat", + "browse-recently-updated": "Bläddra i Nyligen Uppdaterat", + "smart-filters": "Smarta Filter", + "external-sources": "Externa Källor", + "browse-external-sources": "Bläddra i Externa Källor", + "browse-smart-filters": "Bläddra efter Smarta Filter", + "reading-list-restricted": "Läslista saknas eller så saknar du behörighet", + "query-required": "Du måste ange en sökterm", + "search": "Sök", + "search-description": "Sök efter Serier, Samlingar, eller Läslistor", + "favicon-doesnt-exist": "Favikon saknas", + "smart-filter-doesnt-exist": "Smart Filter saknas", + "smart-filter-already-in-use": "Det finns redan en ström med detta Smarta Filter", + "external-source-already-exists": "Extern Källa finns redan", + "external-source-required": "ApiKey och Host krävs", + "external-source-doesnt-exist": "Extern Källa saknas", + "external-source-already-in-use": "Det finns redan en stream med denna Externa Källa", + "not-authenticated": "Användaren är inte autentiserad", + "unable-to-reset-k+": "Det gick inte att återställa Kavita+-licensen på grund av ett fel. Kontakta Kavita+ Support", + "anilist-cred-expired": "AniList Autentiseringsuppgifter har gått ut eller är inte angivna", + "scrobble-bad-payload": "Felaktig payload från Scrobble utgivare", + "theme-doesnt-exist": "Temafil saknas eller är ogiltig", + "generic-create-temp-archive": "Det uppstod ett problem vid skapandet av tillfällig katalog", + "epub-malformed": "Filen är felaktigt utformad! Kan inte läsa.", + "collection-tag-title-required": "Samlingstitel kan inte vara tom", + "reading-list-title-required": "Läsliststitel kan inte vara tom", + "collection-tag-duplicate": "En samling med detta namn finns redan", + "device-duplicate": "En enhet med detta namn finns redan", + "reading-list-name-exists": "En läslista med detta namn finns redan", + "user-no-access-library-from-series": "Användaren har inte tillgång till biblioteket som denna serie tillhör", + "volume-num": "Volym {0}", + "book-num": "Bok {0}", + "issue-num": "Utgåva {0}{1}", + "chapter-num": "Kapitel {0}", + "check-updates": "Kontrollera Uppdateringar", + "license-check": "Licenskontroll", + "kavita+-data-refresh": "Uppdatera Kavita+ Data", + "backup": "Backup", + "update-yearly-stats": "Uppdatera Årlig Statistik", + "confirm-email": "Du måste bekräfta din e-post först", + "locked-out": "Du har blivit utelåst på grund av för många auktoriseringsförsök. Vänligen vänta 10 minuter.", + "confirm-token-gen": "Det uppstod ett problem med att generera en bekräftelsetoken", + "password-required": "Du måste ange ditt existerande lösenord för att kunna ändra ditt konto om du inte är administratör", + "invalid-password": "Ogiltigt Lösenord", + "invalid-token": "Ogiltig token", + "unable-to-reset-key": "Någonting gick fel, kan inte återställa nyckeln", + "nothing-to-do": "Inget att göra", + "invalid-payload": "Ogiltig payload", + "share-multiple-emails": "Du kan inte dela e-post mellan flera konton", + "generate-token": "Det uppstod ett problem med att generera en e-postbekräftelsetoken. Se loggar", + "user-already-invited": "Användaren är redan inbjuden med denna e-post och har ännu inte accepterat inbjudan.", + "generic-invite-user": "Det uppstod ett problem vid inbjudan av användaren. Vänligen kontrollera loggarna.", + "invalid-email-confirmation": "Ogiltig e-postbekräftelse", + "generic-user-email-update": "Kan inte uppdatera e-post för användaren. Kontrollera loggarna.", + "generic-password-update": "Det uppstod ett oväntat fel vid bekräftelse av nytt lösenord", + "password-updated": "Lösenord Uppdaterat", + "file-missing": "Fil kunde ej hittas i bok", + "generic-error": "Någonting gick fel, vänligen försök igen", + "device-doesnt-exist": "Enheten finns inte", + "send-to-unallowed": "Du kan inte skicka till en enhet som inte är din", + "send-to-device-status": "Överför filer till din enhet", + "no-cover-image": "Omslagsbild saknas", + "bookmark-doesnt-exist": "Bokmärke saknas", + "delete-library-while-scan": "Du kan inte ta bort ett bibliotek medan en skanning pågår. Vänligen vänta tills skanningen är färdig eller starta om Kavita för att försöka ta bort igen", + "perform-scan": "Vänligen utför en skanning av denna serie eller bibliotek och försök igen", + "bookmark-permission": "Du har inte behörighet att bokmärka/ta bort bokmärke", + "reading-list-permission": "Du har inte behörighet för den här läslistan eller så finns listan inte", + "reading-list-updated": "Uppdaterat", + "generic-reading-list-create": "Ett problem uppstod vid skapandet av läslistan", + "encode-as-warning": "Du kan inte konvertera till PNG. För omslag, använd Uppdatera Omslag. Bokmärken och favikoner kan inte kodas tillbaka.", + "no-series-collection": "Kunde inte skaffa serier för Samling", + "generic-relationship": "Det uppstod ett problem vid uppdatering av förhållanden", + "total-backups": "Totala Säkerhetskopior måste vara mellan 1 och 30", + "browse-reading-lists": "Bläddra i Läslistor", + "opds-disabled": "OPDS är inte aktiverat på denna server", + "total-logs": "Totala Loggar måste vara mellan 1 och 30", + "collections": "Alla Samlingar", + "browse-collections": "Bläddra efter Samlingar", + "more-in-genre": "Mer i Genre {0}", + "browse-more-in-genre": "Bläddra mer i {0}", + "unable-to-register-k+": "Kan inte registrera licens på grund av ett fel. Kontakta Kavita+ Support", + "bad-copy-files-for-download": "Kan inte kopiera filer till temp-katalogen för arkivnedladdning.", + "epub-html-missing": "Kunde inte hitta lämplig html för den sidan", + "device-not-created": "Denna enhet finns inte än. Vänligen skapa den först", + "series-restricted-age-restriction": "Användaren saknar behörighet att se denna serien på grund av åldersbegränsning", + "send-to-permission": "Kan inte skicka icke-EPUB eller PDF till enheter som inte stöds av Kindle", + "process-processed-scrobbling-events": "Processera Processerade Scrobbling Event", + "remove-from-want-to-read": "Vill Läsa Städning", + "process-scrobbling-events": "Processera Scrobbling Event", + "scan-libraries": "Skanna Bibliotek", + "report-stats": "Rapportera Statistik", + "progress-must-exist": "Historik måste finnas på användare", + "check-scrobbling-tokens": "Kontrollera Scrobbling Tokens", + "cleanup": "Städning", + "on-deck": "Fortsätt Läsa", + "browse-on-deck": "Bläddra i Fortsätt Läsa", + "dashboard-stream-doesnt-exist": "Dashboard Ström saknas", + "sidenav-stream-doesnt-exist": "SideNav Ström saknas", + "person-doesnt-exist": "Personen existerar inte", + "person-name-required": "Personnamn är obligatorisk", + "person-name-unique": "Personnamn måste vara unikt", + "person-image-doesnt-exist": "Personen existerar inte i CoversDB", + "generic-cover-person-save": "Kan inte spara omslagsbilden till Personen", + "generic-cover-volume-save": "Kan inte spara omslagsbilden till Volymen", + "email-taken": "E-postadressen används redan", + "kavitaplus-restricted": "Detta är enbart tillgängligt med Kavita+", + "dashboard-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från panelen", + "smart-filter-name-required": "Smart Filter namn krävs", + "sidenav-stream-only-delete-smart-filter": "Bara smarta-filter-strömmar kan tas bort från sidomenyn", + "smart-filter-system-name": "Du kan inte använda namnet från en ström som systemet tillhandahåller" +} diff --git a/API/I18N/ta.json b/API/I18N/ta.json new file mode 100644 index 000000000..83d6bd12f --- /dev/null +++ b/API/I18N/ta.json @@ -0,0 +1,206 @@ +{ + "generic-invite-user": "பயனரை அழைக்கும் சிக்கல் இருந்தது. பதிவுகளை சரிபார்க்கவும்.", + "user-already-invited": "பயனர் ஏற்கனவே இந்த மின்னஞ்சலின் கீழ் அழைக்கப்படுகிறார், மேலும் அழைப்பை இன்னும் ஏற்கவில்லை.", + "invalid-email-confirmation": "தவறான மின்னஞ்சல் உறுதிப்படுத்தல்", + "generic-user-email-update": "பயனருக்கான மின்னஞ்சலைப் புதுப்பிக்க முடியவில்லை. பதிவுகளை சரிபார்க்கவும்.", + "generic-password-update": "புதிய கடவுச்சொல்லை உறுதிப்படுத்தும்போது எதிர்பாராத பிழை ஏற்பட்டது", + "password-updated": "கடவுச்சொல் புதுப்பிக்கப்பட்டது", + "forgot-password-generic": "எங்கள் தரவுத்தளத்தில் இருந்தால் மின்னஞ்சல் அனுப்பப்படும்", + "bad-copy-files-for-download": "கோப்புகளை தற்காலிக அடைவு காப்பக பதிவிறக்கத்திற்கு நகலெடுக்க முடியவில்லை.", + "generic-create-temp-archive": "தற்காலிக காப்பகத்தை உருவாக்கும் சிக்கல் இருந்தது", + "epub-malformed": "கோப்பு தவறாக உள்ளது! படிக்க முடியாது.", + "epub-html-missing": "அந்தப் பக்கத்திற்கு பொருத்தமான உஉகுமொ ஐக் கண்டுபிடிக்க முடியவில்லை", + "collection-tag-title-required": "சேகரிப்பு தலைப்பு காலியாக இருக்க முடியாது", + "collection-tag-duplicate": "இந்த பெயருடன் ஒரு தொகுப்பு ஏற்கனவே உள்ளது", + "device-duplicate": "இந்த பெயரைக் கொண்ட ஒரு சாதனம் ஏற்கனவே உள்ளது", + "not-accessible-password": "உங்கள் சேவையகம் அணுக முடியாது. உங்கள் கடவுச்சொல்லை மீட்டமைப்பதற்கான இணைப்பு பதிவுகளில் உள்ளது", + "device-not-created": "இந்த சாதனம் இன்னும் இல்லை. முதலில் உருவாக்கவும்", + "send-to-permission": "கின்டலில் ஆதரிக்கப்படாத சாதனங்களுக்கு எபப் அல்லாத அல்லது பி.டி.எஃப் அனுப்ப முடியாது", + "progress-must-exist": "பயனரில் முன்னேற்றம் இருக்க வேண்டும்", + "reading-list-name-exists": "இந்த பெயரின் வாசிப்பு பட்டியல் ஏற்கனவே உள்ளது", + "user-no-access-library-from-series": "பயனருக்கு நூலகத்திற்கு அணுகல் இல்லை இந்த தொடர் சொந்தமானது", + "series-restricted-age-restriction": "அகவை கட்டுப்பாடுகள் காரணமாக இந்தத் தொடரைப் பார்க்க பயனருக்கு இசைவு இல்லை", + "volume-num": "தொகுதி {0}", + "book-num": "நூல் {0}", + "issue-num": "வெளியீடு {0} {1}", + "chapter-num": "அத்தியாயம் {0}", + "check-updates": "புதுப்பிப்புகளை சரிபார்க்கவும்", + "license-check": "உரிம சோதனை", + "process-scrobbling-events": "செயலாக்க நிகழ்வுகளை செயலாக்கவும்", + "report-stats": "புள்ளிவிவரங்களைப் புகாரளிக்கவும்", + "check-scrobbling-tokens": "ச்க்ரோப்ளிங் டோக்கன்களை சரிபார்க்கவும்", + "cleanup": "தூய்மைப்படுத்துதல்", + "process-processed-scrobbling-events": "செயல்முறை செயலாக்கப்பட்ட ச்க்ரோப்லிங் நிகழ்வுகள்", + "remove-from-want-to-read": "தூய்மைப்படுத்தலைப் படிக்க விரும்புகிறேன்", + "scan-libraries": "நூலகங்களை ச்கேன் செய்யுங்கள்", + "kavita+-data-refresh": "கவிதா+ தரவு புதுப்பிப்பு", + "backup": "காப்புப்பிரதி", + "update-yearly-stats": "ஆண்டு புள்ளிவிவரங்களைப் புதுப்பிக்கவும்", + "invalid-email": "பயனருக்கான கோப்பில் உள்ள மின்னஞ்சல் சரியான மின்னஞ்சல் அல்ல. எந்த இணைப்புகளுக்கான பதிவுகளையும் காண்க.", + "not-accessible": "உங்கள் சேவையகம் வெளிப்புறமாக அணுக முடியாது", + "email-sent": "மின்னஞ்சல் அனுப்பப்பட்டது", + "user-migration-needed": "இந்த பயனர் இடம்பெயர வேண்டும். இடம்பெயர்வு ஓட்டத்தைத் தூண்டுவதற்கு அவை வெளியேறி உள்நுழைய வேண்டும்", + "generic-invite-email": "அழைப்பு மின்னஞ்சல் மீண்டும் ஒரு சிக்கல் இருந்தது", + "admin-already-exists": "நிர்வாகி ஏற்கனவே உள்ளது", + "invalid-username": "தவறான பயனர்பெயர்", + "critical-email-migration": "மின்னஞ்சல் இடம்பெயர்வு போது ஒரு சிக்கல் இருந்தது. தொடர்பு உதவி", + "email-not-enabled": "இந்த சேவையகத்தில் மின்னஞ்சல் இயக்கப்படவில்லை. இந்த செயலை நீங்கள் செய்ய முடியாது.", + "must-be-defined": "{0} வரையறுக்கப்பட வேண்டும்", + "generic-favicon": "டொமைனுக்கு ஃபேவிகானைப் பெறுவதில் சிக்கல் இருந்தது", + "invalid-filename": "தவறான கோப்பு பெயர்", + "file-doesnt-exist": "கோப்பு இல்லை", + "library-name-exists": "நூலக பெயர் ஏற்கனவே உள்ளது. சேவையகத்திற்கு ஒரு தனிப்பட்ட பெயரைத் தேர்வுசெய்க.", + "generic-library": "ஒரு முக்கியமான சிக்கல் இருந்தது. மீண்டும் முயற்சிக்கவும்.", + "no-library-access": "பயனருக்கு இந்த நூலகத்திற்கு அணுகல் இல்லை", + "library-doesnt-exist": "நூலகம் இல்லை", + "invalid-path": "தவறான பாதை", + "no-series-collection": "சேகரிப்புக்கான தொடர்களைப் பெற முடியவில்லை", + "generic-series-delete": "தொடரை நீக்குவதில் சிக்கல் இருந்தது", + "generic-series-update": "தொடரைப் புதுப்பிப்பதில் பிழை ஏற்பட்டது", + "series-updated": "வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "update-metadata-fail": "மெட்டாடேட்டாவை புதுப்பிக்க முடியவில்லை", + "age-restriction-not-applicable": "கட்டுப்பாடு இல்லை", + "generic-relationship": "உறவுகளைப் புதுப்பிப்பதில் சிக்கல் இருந்தது", + "job-already-running": "ஏற்கனவே இயங்கும் வேலை", + "browse-reading-lists": "பட்டியல்களைப் படிப்பதன் மூலம் உலாவுக", + "libraries": "அனைத்து நூலகங்களும்", + "browse-libraries": "நூலகங்களால் உலாவுக", + "collections": "அனைத்து சேகரிப்புகளும்", + "browse-collections": "வசூல் மூலம் உலாவுக", + "more-in-genre": "{0} வகைகளில் மேலும்", + "browse-more-in-genre": "{0} இல் மேலும் உலாவுக", + "recently-updated": "அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது", + "browse-recently-updated": "உலாவு அண்மைக் காலத்தில் புதுப்பிக்கப்பட்டது", + "smart-filters": "அறிவுள்ள வடிப்பான்கள்", + "external-sources": "வெளிப்புற ஆதாரங்கள்", + "browse-external-sources": "வெளிப்புற ஆதாரங்களை உலாவுக", + "browse-smart-filters": "அறிவுள்ள வடிப்பான்களால் உலாவுக", + "reading-list-restricted": "வாசிப்பு பட்டியல் இல்லை அல்லது உங்களுக்கு அணுகல் இல்லை", + "query-required": "நீங்கள் ஒரு வினவல் அளவுருவை அனுப்ப வேண்டும்", + "search-description": "தொடர், தொகுப்புகள் அல்லது வாசிப்பு பட்டியல்களைத் தேடுங்கள்", + "favicon-doesnt-exist": "ஃபாவிகான் இல்லை", + "smart-filter-doesnt-exist": "அறிவுள்ள வடிகட்டி இல்லை", + "smart-filter-already-in-use": "இந்த அறிவுள்ள வடிகட்டியுடன் ஏற்கனவே ச்ட்ரீம் உள்ளது", + "dashboard-stream-doesnt-exist": "டாச்போர்டு ச்ட்ரீம் இல்லை", + "sidenav-stream-doesnt-exist": "சிடெனவ் ச்ட்ரீம் இல்லை", + "external-source-already-exists": "வெளிப்புற மூலமானது ஏற்கனவே உள்ளது", + "external-source-required": "அப்பிகி மற்றும் புரவலன் தேவை", + "external-source-doesnt-exist": "வெளிப்புற மூல இல்லை", + "external-source-already-in-use": "இந்த வெளிப்புற மூலத்துடன் ஏற்கனவே ச்ட்ரீம் உள்ளது", + "not-authenticated": "பயனர் அங்கீகரிக்கப்படவில்லை", + "unable-to-register-k+": "பிழை காரணமாக உரிமத்தை பதிவு செய்ய முடியவில்லை. கவிதா+ ஆதரவை அணுகவும்", + "unable-to-reset-k+": "Unable பெறுநர் மீட்டமை Kavita+ உரிமம் due பெறுநர் error. கவிதா+ ஆதரவை அணுகவும்", + "anilist-cred-expired": "அனிலிச்ட் நற்சான்றிதழ்கள் காலாவதியானன அல்லது அமைக்கப்படவில்லை", + "scrobble-bad-payload": "ச்க்ரோபல் வழங்குநரிடமிருந்து மோசமான பேலோட்", + "theme-doesnt-exist": "கருப்பொருள் கோப்பு காணவில்லை அல்லது தவறானது", + "search": "தேடல்", + "age-restriction-update": "அகவை கட்டுப்பாட்டை புதுப்பிப்பதில் பிழை ஏற்பட்டது", + "bookmark-doesnt-exist": "புக்மார்க்கு இல்லை", + "user-doesnt-exist": "பயனர் இல்லை", + "reading-list-item-delete": "உருப்படி (களை) நீக்க முடியவில்லை", + "libraries-restricted": "பயனருக்கு எந்த நூலகங்களுக்கும் அணுகல் இல்லை", + "reading-list-title-required": "பட்டியல் தலைப்பு காலியாக இருக்க முடியாது", + "confirm-email": "முதலில் உங்கள் மின்னஞ்சலை உறுதிப்படுத்த வேண்டும்", + "locked-out": "பல அங்கீகார முயற்சிகளிலிருந்து நீங்கள் பூட்டப்பட்டுள்ளீர்கள். தயவுசெய்து 10 நிமிடங்கள் காத்திருக்கவும்.", + "disabled-account": "உங்கள் கணக்கு முடக்கப்பட்டுள்ளது. சேவையக நிர்வாகியைத் தொடர்பு கொள்ளுங்கள்.", + "register-user": "பயனரை பதிவு செய்யும் போது ஏதோ தவறு ஏற்பட்டது", + "validate-email": "உங்கள் மின்னஞ்சலை உறுதிப்படுத்தும் சிக்கல் இருந்தது: {0}", + "confirm-token-gen": "உறுதிப்படுத்தல் கிள்ளாக்கை உருவாக்கும் சிக்கல் இருந்தது", + "denied": "அனுமதிக்கப்படவில்லை", + "permission-denied": "இந்த நடவடிக்கைக்கு நீங்கள் அனுமதிக்கப்படவில்லை", + "password-required": "நீங்கள் ஒரு நிர்வாகி இல்லையென்றால் உங்கள் கணக்கை மாற்ற உங்கள் ஏற்கனவே உள்ள கடவுச்சொல்லை உள்ளிட வேண்டும்", + "invalid-password": "தவறான கடவுச்சொல்", + "invalid-token": "தவறான கிள்ளாக்கு", + "unable-to-reset-key": "விசையை மீட்டமைக்க முடியாமல் ஏதோ தவறு நடந்தது", + "invalid-payload": "தவறான பேலோட்", + "nothing-to-do": "செய்ய எதுவும் இல்லை", + "share-multiple-emails": "பல கணக்குகளில் மின்னஞ்சல்களைப் பகிர முடியாது", + "generate-token": "உறுதிப்படுத்தல் மின்னஞ்சல் கிள்ளாக்கை உருவாக்கும் சிக்கல் இருந்தது. பதிவுகள் பார்க்கவும்", + "no-user": "பயனர் இல்லை", + "username-taken": "ஏற்கனவே எடுக்கப்பட்ட பயனர்பெயர்", + "email-taken": "ஏற்கனவே பயன்பாட்டில் உள்ள மின்னஞ்சல்", + "user-already-confirmed": "பயனர் ஏற்கனவே உறுதிப்படுத்தப்பட்டுள்ளது", + "generic-user-update": "பயனரைப் புதுப்பிக்கும்போது விதிவிலக்கு இருந்தது", + "manual-setup-fail": "கையேடு அமைப்பை முடிக்க முடியவில்லை. தயவுசெய்து அழைப்பை ரத்து செய்து மீண்டும் உருவாக்கவும்", + "user-already-registered": "பயனர் ஏற்கனவே {0 அச் என பதிவு செய்யப்பட்டுள்ளார்", + "account-email-invalid": "நிர்வாகக் கணக்கிற்கான கோப்பில் உள்ள மின்னஞ்சல் சரியான மின்னஞ்சல் அல்ல. சோதனை மின்னஞ்சலை அனுப்ப முடியாது.", + "email-settings-invalid": "மின்னஞ்சல் அமைப்புகள் காணவில்லை. அனைத்து மின்னஞ்சல் அமைப்புகளும் சேமிக்கப்படுவதை உறுதிசெய்க.", + "chapter-doesnt-exist": "அத்தியாயம் இல்லை", + "file-missing": "கோப்பு புத்தகத்தில் காணப்படவில்லை", + "collection-updated": "சேகரிப்பு வெற்றிகரமாக புதுப்பிக்கப்பட்டது", + "collection-deleted": "சேகரிப்பு நீக்கப்பட்டது", + "generic-error": "ஏதோ தவறு நடந்தது, தயவுசெய்து மீண்டும் முயற்சிக்கவும்", + "collection-doesnt-exist": "சேகரிப்பு இல்லை", + "collection-already-exists": "சேகரிப்பு ஏற்கனவே உள்ளது", + "error-import-stack": "மால் ச்டேக்கை இறக்குமதி செய்வதில் சிக்கல் இருந்தது", + "person-doesnt-exist": "நபர் இல்லை", + "person-name-required": "நபரின் பெயர் தேவை மற்றும் பூச்யமாக இருக்கக்கூடாது", + "person-name-unique": "நபரின் பெயர் தனித்துவமாக இருக்க வேண்டும்", + "person-image-doesnt-exist": "கவர்ச்டிபியில் நபர் இல்லை", + "device-doesnt-exist": "சாதனம் இல்லை", + "generic-device-create": "சாதனத்தை உருவாக்கும் போது பிழை ஏற்பட்டது", + "generic-device-update": "சாதனத்தைப் புதுப்பிக்கும்போது பிழை ஏற்பட்டது", + "generic-device-delete": "சாதனத்தை நீக்கும்போது பிழை ஏற்பட்டது", + "greater-0": "{0} 0 ஐ விட அதிகமாக இருக்க வேண்டும்", + "send-to-kavita-email": "மின்னஞ்சல் அமைவு இல்லாமல் சாதனத்திற்கு அனுப்பு பயன்படுத்த முடியாது", + "send-to-unallowed": "உங்களுடையது இல்லாத சாதனத்திற்கு நீங்கள் அனுப்ப முடியாது", + "send-to-size-limit": "நீங்கள் அனுப்ப முயற்சிக்கும் கோப்பு (கள்) உங்கள் மின்னஞ்சல் வழங்குநருக்கு மிகப் பெரியது", + "send-to-device-status": "உங்கள் சாதனத்திற்கு கோப்புகளை மாற்றுகிறது", + "generic-send-to": "சாதனத்திற்கு கோப்பு (களை) அனுப்புவதில் பிழை ஏற்பட்டது", + "series-doesnt-exist": "தொடர் இல்லை", + "volume-doesnt-exist": "தொகுதி இல்லை", + "bookmarks-empty": "புக்மார்க்குகள் காலியாக இருக்க முடியாது", + "no-cover-image": "கவர் படம் இல்லை", + "delete-library-while-scan": "ச்கேன் நடந்து கொண்டிருக்கும்போது நீங்கள் ஒரு நூலகத்தை நீக்க முடியாது. கவிதாவை ச்கேன் முடிக்க அல்லது மறுதொடக்கம் செய்ய காத்திருங்கள்", + "generic-library-update": "நூலகத்தைப் புதுப்பிப்பதில் ஒரு முக்கியமான சிக்கல் இருந்தது.", + "pdf-doesnt-exist": "பி.டி.எஃப் எப்போது இருக்க வேண்டும்", + "invalid-access": "தவறான அணுகல்", + "no-image-for-page": "பக்கத்திற்கு அத்தகைய படம் இல்லை {0}. மறு கேச் அனுமதிக்க புத்துணர்ச்சியை முயற்சிக்கவும்.", + "perform-scan": "தயவுசெய்து இந்த தொடர் அல்லது நூலகத்தில் ச்கேன் செய்து மீண்டும் முயற்சிக்கவும்", + "generic-read-progress": "முன்னேற்றத்தை மிச்சப்படுத்தும் சிக்கல் இருந்தது", + "generic-clear-bookmarks": "புக்மார்க்குகளை அழிக்க முடியவில்லை", + "bookmark-permission": "புக்மார்க்கு/புத்தகமார்க்குக்கு உங்களுக்கு இசைவு இல்லை", + "bookmark-save": "புத்தகக்குறியை சேமிக்க முடியவில்லை", + "cache-file-find": "தற்காலிக சேமிப்பு படத்தைக் கண்டுபிடிக்க முடியவில்லை. மீண்டும் ஏற்றவும் மீண்டும் முயற்சிக்கவும்.", + "name-required": "பெயர் காலியாக இருக்க முடியாது", + "valid-number": "செல்லுபடியாகும் பக்க எண்ணாக இருக்க வேண்டும்", + "duplicate-bookmark": "நகல் புத்தகக்குறி நுழைவு ஏற்கனவே உள்ளது", + "reading-list-permission": "இந்த வாசிப்பு பட்டியலில் உங்களிடம் இசைவு இல்லை அல்லது பட்டியல் இல்லை", + "reading-list-position": "நிலையை புதுப்பிக்க முடியவில்லை", + "reading-list-updated": "புதுப்பிக்கப்பட்டது", + "reading-list-deleted": "வாசிப்பு பட்டியல் நீக்கப்பட்டது", + "generic-reading-list-delete": "வாசிப்பு பட்டியலை நீக்குவதில் சிக்கல் இருந்தது", + "generic-reading-list-update": "வாசிப்பு பட்டியலைப் புதுப்பிப்பதில் சிக்கல் இருந்தது", + "generic-reading-list-create": "வாசிப்பு பட்டியலை உருவாக்கும் சிக்கல் இருந்தது", + "reading-list-doesnt-exist": "வாசிப்பு பட்டியல் இல்லை", + "series-restricted": "பயனருக்கு இந்த தொடருக்கான அணுகல் இல்லை", + "generic-scrobble-hold": "பிடியைச் சேர்க்கும்போது பிழை ஏற்பட்டது", + "no-series": "நூலகத்திற்கான தொடர்களைப் பெற முடியவில்லை", + "encode-as-warning": "நீங்கள் பி.என்.சி.க்கு மாற்ற முடியாது. அட்டைகளுக்கு, புதுப்பிப்பு அட்டைகளைப் பயன்படுத்தவும். புக்மார்க்குகள் மற்றும் ஃபாவிகான்களை மீண்டும் குறியாக்கம் செய்ய முடியாது.", + "ip-address-invalid": "ஐபி முகவரி '{0}' தவறானது", + "bookmark-dir-permissions": "கவிதாவைப் பயன்படுத்த புக்மார்க்கு கோப்பகத்திற்கு சரியான அனுமதிகள் இல்லை", + "total-backups": "மொத்த காப்புப்பிரதிகள் 1 முதல் 30 வரை இருக்க வேண்டும்", + "total-logs": "மொத்த பதிவுகள் 1 முதல் 30 வரை இருக்க வேண்டும்", + "stats-permission-denied": "மற்றொரு பயனரின் புள்ளிவிவரங்களைக் காண உங்களுக்கு ஏற்பு இல்லை", + "url-not-valid": "முகவரி சரியான படத்தை திருப்பித் தராது அல்லது ஏற்பு தேவைப்படுகிறது", + "url-required": "பயன்படுத்த நீங்கள் ஒரு முகவரி ஐ அனுப்ப வேண்டும்", + "generic-cover-series-save": "கவர் படத்தை தொடருக்கு சேமிக்க முடியவில்லை", + "generic-cover-collection-save": "கவர் படத்தை சேகரிப்புக்கு சேமிக்க முடியவில்லை", + "generic-cover-reading-list-save": "கவர் படத்தை வாசிப்பு பட்டியலுக்கு சேமிக்க முடியவில்லை", + "generic-cover-chapter-save": "கவர் படத்தை அத்தியாயத்தில் சேமிக்க முடியவில்லை", + "generic-cover-library-save": "கவர் படத்தை நூலகத்தில் சேமிக்க முடியவில்லை", + "generic-cover-person-save": "கவர் படத்தை நபருக்கு சேமிக்க முடியவில்லை", + "generic-cover-volume-save": "கவர் படத்தை தொகுதிக்கு சேமிக்க முடியவில்லை", + "access-denied": "உங்களுக்கு அணுகல் இல்லை", + "reset-chapter-lock": "அத்தியாயத்திற்கான கவர் பூட்டை மீட்டமைக்க முடியவில்லை", + "generic-user-delete": "பயனரை நீக்க முடியவில்லை", + "generic-user-pref": "விருப்பங்களை சேமிக்கும் சிக்கல் இருந்தது", + "opds-disabled": "இந்த சேவையகத்தில் OPDS இயக்கப்படவில்லை", + "on-deck": "டெக்கில்", + "browse-on-deck": "டெக்கில் உலாவுக", + "recently-added": "அண்மைக் காலத்தில் சேர்க்கப்பட்டது", + "want-to-read": "படிக்க விரும்புகிறேன்", + "browse-want-to-read": "உலாவு படிக்க விரும்புகிறது", + "browse-recently-added": "உலாவு அண்மைக் காலத்தில் சேர்க்கப்பட்டது", + "reading-lists": "பட்டியல்களைப் படித்தல்" +} diff --git a/API/I18N/th.json b/API/I18N/th.json index 9750001da..1988bcad9 100644 --- a/API/I18N/th.json +++ b/API/I18N/th.json @@ -29,7 +29,7 @@ "generic-clear-bookmarks": "ไม่สามารถล้างบุ๊กมาร์ก", "cache-file-find": "ไม่พบรูปภาพที่เก็บไว้ โหลดใหม่และลองอีกครั้ง", "url-required": "คุณต้องส่ง url เพื่อใช้งาน", - "send-to-kavita-email": "ส่งไปยังอุปกรณ์ใช้กับบริการอีเมลของ Kavita ไม่ได้ โปรดกำหนดค่าของคุณเอง", + "send-to-kavita-email": "ส่งไปยังอุปกรณ์ไม่สามารถใช้งานได้หากไม่มีการตั้งค่าอีเมล", "favicon-doesnt-exist": "ไม่มีไอคอน Favicon", "library-name-exists": "ชื่อไลบรารีมีอยู่แล้ว โปรดเลือกชื่อเฉพาะสำหรับเซิร์ฟเวอร์", "library-doesnt-exist": "ไม่มีไลบรารี", @@ -78,7 +78,6 @@ "series-restricted-age-restriction": "ผู้ใช้ไม่ได้รับอนุญาตให้ดูซีรีส์นี้เนื่องจากการจำกัดอายุ", "issue-num": "ฉบับ {0}{1}", "confirm-email": "คุณต้องยืนยันอีเมลของคุณก่อน", - "bad-credentials": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง", "locked-out": "ไม่สามารถเข้าสู่ระบบได้เนื่องจากเข้าสู่ระบบล้มเหลวมากเกินไป กรุณารอ 10 นาที", "disabled-account": "บัญชีของคุณถูกระงับ กรุณาติดต่อผู้ดูแลระบบ", "denied": "ไม่อนุญาต", @@ -158,5 +157,19 @@ "epub-html-missing": "ไม่พบ html ที่เหมาะสมสำหรับหน้านั้น", "collection-tag-title-required": "ชื่อคอลเลกชันต้องไม่ว่างเปล่า", "want-to-read": "ต้องการอ่าน", - "browse-want-to-read": "ดูรายการต้องการอ่าน" + "browse-want-to-read": "ดูรายการต้องการอ่าน", + "email-not-enabled": "ไม่ได้เปิดใช้งานอีเมลบนเซิร์ฟเวอร์นี้ คุณไม่สามารถดำเนินการนี้ได้", + "send-to-unallowed": "คุณไม่สามารถส่งไปยังอุปกรณ์ที่ไม่ใช่ของคุณ", + "send-to-size-limit": "ไฟล์ที่คุณพยายามส่งมีขนาดใหญ่เกินไปสำหรับอีเมลของคุณ", + "browse-more-in-genre": "ค้นหาเพิ่มเติมใน {0}", + "recently-updated": "อัปเดตล่าสุด", + "more-in-genre": "เพิ่มเติมในประเภท {0}", + "browse-recently-updated": "เรียกดูอัปเดตล่าสุด", + "external-sources": "แหล่งข้อมูลภายนอก", + "browse-external-sources": "เรียกดูแหล่งข้อมูลภายนอก", + "smart-filters": "ตัวกรองอัจฉริยะ", + "browse-smart-filters": "เรียกดูตามตัวกรองอัจฉริยะ", + "smart-filter-doesnt-exist": "ไม่มีตัวกรองอัจฉริยะ", + "collection-deleted": "ลบคอลเล็กชั่นแล้ว", + "invalid-email": "อีเมลในไฟล์สำหรับผู้ใช้ไม่ใช่อีเมลที่ถูกต้อง ดูบันทึกสำหรับลิงก์ต่างๆ" } diff --git a/API/I18N/tr.json b/API/I18N/tr.json index eb39e7192..370f13f20 100644 --- a/API/I18N/tr.json +++ b/API/I18N/tr.json @@ -1,10 +1,9 @@ { "denied": "İzin verilmedi", "permission-denied": "Bu operasyona izniniz yok", - "bad-credentials": "Kimlik bilgileriniz doğru değil", "confirm-email": "İlk olarak E-Posta'nı onaylaman gerek", "register-user": "Kullanıcıyı kayıt ederken bir şeyler yanlış gitti", - "disabled-account": "Hesabınız devre dışı bırakıldı. Sunucu yöneticisiyle iletişime geçin.", + "disabled-account": "Hesabınız devre dışı. Sunucu yöneticisiyle iletişime geçin.", "validate-email": "E-Posta'yı doğrularken bir hata oluştu: {0}", "confirm-token-gen": "Doğrulama tokeni oluşturulurken bir sorun oluştu", "password-required": "Yönetici değilseniz, hesabınızı değiştirmek için mevcut şifrenizi girmelisiniz", diff --git a/API/I18N/uk.json b/API/I18N/uk.json new file mode 100644 index 000000000..62f514a04 --- /dev/null +++ b/API/I18N/uk.json @@ -0,0 +1,24 @@ +{ + "confirm-email": "Ви маєте спершу підтвердити свій email", + "locked-out": "Забагато спроб авторизації. Доступ тимчасово неможливий. Почекайте 10 хвилин.", + "disabled-account": "Ваш акаунт заблоковано. Звʼяжіться з адміністратором сервера.", + "register-user": "Щось пішло не так під час реєстрації користувача", + "validate-email": "Сталася помилка під час підтвердження вашого email: {0}", + "denied": "Заборонено", + "permission-denied": "У вас нема дозволу для цієї операції", + "password-required": "Ви маєте ввести свій чинний пароль, щоб змінити обліковий запис (якщо тільки ви не адміністратор)", + "invalid-token": "Неправильний код", + "unable-to-reset-key": "Щось пішло не так – скинути ключ не вдалося", + "invalid-payload": "Некоректний зміст", + "nothing-to-do": "Тут нема роботи", + "share-multiple-emails": "Не можна користуватися одним email з кількох облікових записів", + "confirm-token-gen": "Щось пішло не так під час генерації коду підтвердження", + "invalid-password": "Неправильний пароль", + "generic-user-update": "Сталася помилка при спробі оновлення інформації користувача", + "generate-token": "Відбулась помилка при спробі підтвердження токену. Перегляньте логи", + "age-restriction-update": "Сталася помилка під час оновлення обмежень віку", + "no-user": "Користувача не існує", + "username-taken": "Ім'я користувача уже зайняте", + "user-already-confirmed": "Користувач уже підтверджений", + "email-taken": "Адреса електронної пошти уже зайнята" +} diff --git a/API/I18N/vi.json b/API/I18N/vi.json new file mode 100644 index 000000000..2fcd16154 --- /dev/null +++ b/API/I18N/vi.json @@ -0,0 +1,205 @@ +{ + "generic-user-email-update": "Không thể cập nhật email cho người dùng này. Vui lòng kiểm tra nhật ký.", + "forgot-password-generic": "Một email sẽ được gửi đến bạn nếu email đó tồn tại trong cơ sở dữ liệu của chúng tôi", + "not-accessible": "Máy chủ của bạn không thể được truy cập từ bên ngoài", + "generic-invite-email": "Đã xảy ra sự cố khi gửi lại email lời mời", + "admin-already-exists": "Tài khoản quản trị viên đã tồn tại", + "file-missing": "Không tìm thấy tập tin trong sách", + "generic-error": "Đã xảy ra lỗi. Vui lòng thử lại sau", + "device-doesnt-exist": "Thiết bị không tồn tại", + "send-to-kavita-email": "Không thể sử dụng tính năng chia sẻ tới thiết bị nếu không thiết lập chức năng Email", + "generic-device-create": "Đã xảy ra lỗi khi tạo thiết bị này", + "generic-invite-user": "Đã xảy ra sự cố khi mời người dùng này. Vui lòng kiểm tra nhật ký.", + "email-not-enabled": "Chức năng email không được kích hoạt trên máy chủ. Bạn không thể thực hiện hành động này.", + "register-user": "Đã xảy ra lỗi khi đăng ký tài khoản", + "confirm-token-gen": "Đã xảy ra sự cố khi tạo token xác minh", + "denied": "Không có quyền", + "permission-denied": "Bạn có quyền để thực hiện hành động này", + "invalid-password": "Mật khẩu không hợp lệ", + "invalid-token": "Token không hợp lệ", + "invalid-payload": "Payload không hợp lệ", + "username-taken": "Tên người dùng này đã được sử dụng", + "age-restriction-update": "Đã xảy ra lỗi khi cập nhật giới hạn độ tuổi", + "invalid-email-confirmation": "Email xác nhận không hợp lệ", + "user-already-registered": "Người dùng này đã được đăng ký với tên {0}", + "manual-setup-fail": "Không thể hoàn thành thiết lập thủ công. Vui lòng hủy và tạo lại lời mời", + "generic-password-update": "Đã xảy ra sự cố khi xác nhận mật khẩu mới", + "password-updated": "Mật khẩu đã được cập nhật", + "email-sent": "Đã gửi email", + "invalid-username": "Tên người dùng không hợp lệ", + "chapter-doesnt-exist": "Chương không tồn tại", + "collection-updated": "Bộ sưu tập đã cập nhật thành công", + "collection-deleted": "Đã xoá bộ sưu tập", + "collection-doesnt-exist": "Bộ sưu tập không tồn tại", + "generic-device-update": "Đã xảy ra lỗi khi cập nhật thông tin của thiết bị", + "generic-device-delete": "Đã xảy ra lỗi khi xóa thiết bị", + "greater-0": "Giá trị {0} phải lớn hơn 0", + "confirm-email": "Bạn cần phải xác minh email của mình trước", + "disabled-account": "Tài khoản của bạn đã bị vô hiệu hóa. Vui lòng liên hệ với quản trị viên.", + "validate-email": "Đã xảy ra sự cố khi xác thực email của bạn: {0}", + "password-required": "Bạn phải nhập mật khẩu hiện tại để đổi thông tin tài khoản của mình trừ khi bạn là quản trị viên", + "nothing-to-do": "Không có gì để thực hiện", + "no-user": "Người dùng không tồn tại", + "user-already-confirmed": "Người dùng này đã xác minh", + "generic-user-update": "Có sự cố đã xảy ra khi cập nhật thông tin người dùng", + "user-already-invited": "Người dùng đã được mời qua email này nhưng chưa chấp nhận lời mời.", + "generate-token": "Đã xảy ra sự cố khi tạo mã xác nhận email. Xem bản ghi", + "locked-out": "Bạn đã bị khóa do quá nhiều lần thử đăng nhập. Vui lòng chờ 10 phút.", + "unable-to-reset-key": "Có sự cố xảy ra, không thể đặt lại khóa", + "share-multiple-emails": "Một Email không thể được dùng chung cho nhiều tải khoản", + "file-doesnt-exist": "Tập tin không tồn tại", + "invalid-email": "Email được lưu cho người dùng không hợp lệ. Xem nhật ký để nhận link.", + "send-to-unallowed": "Bạn không thể gửi cho thiết bị của người khác", + "account-email-invalid": "Email được lưu cho tài khoản quản trị viên không hợp lệ. Không gửi được email thử.", + "not-accessible-password": "Máy chủ không truy cập được. Đường dẫn đặt lại mật khẩu nằm ở trong nhật ký", + "user-doesnt-exist": "Người dùng không tồn tại", + "library-doesnt-exist": "Thư viện không tồn tại", + "invalid-path": "Đường dẫn không hợp lệ", + "delete-library-while-scan": "Không thể xóa thư viện trong lúc đang quét kiểm tra sách. Xin đợi quá trình quét hoàn tất hoặc khởi động lại Kavita để xóa", + "collection-already-exists": "Bộ sưu tập đã tồn tại", + "send-to-size-limit": "Tệp tin bạn đang cố gắng gửi quá lớn đối với nhà cung cấp email của bạn", + "send-to-device-status": "Đang chuyển tập tin qua thiết bị", + "invalid-filename": "Tên tập tin không hợp lệ", + "library-name-exists": "Tên thư viện đã tồn tại. Xin chọn một tên có 1-0-2 trong nội bộ máy chủ này.", + "pdf-doesnt-exist": "PDF không tồn tại", + "critical-email-migration": "Có vấn đề trong việc chuyển email. Hãy liên hệ nhóm hỗ trợ", + "generic-send-to": "Đã xảy ra lỗi khi gửi tập tin đến thiết bị", + "email-settings-invalid": "Cài đặt email bị thiếu thông tin. Hãy đảm bảo tất cả cài đặt email đã được lưu lại.", + "must-be-defined": "Giá trị {0} phải được xác định", + "generic-favicon": "Đã xảy ra lỗi khi lấy favicon cho tên miền", + "no-library-access": "Người dùng không có quyền truy cập thư viện này", + "invalid-access": "Truy cập không hợp lệ", + "no-image-for-page": "Không có hình ảnh cho trang {0}. Thử làm mới trang để chạy cache lại.", + "bookmarks-empty": "Thẻ đánh dấu sách không được để trống", + "reading-list-doesnt-exist": "Danh sách đọc không tồn tại", + "reading-list-permission": "Bạn không có quyền truy cập danh sách đọc này hoặc danh sách không tồn tại", + "generic-library": "Có sự cố nghiêm trọng xảy ra. Xin thử lại sau.", + "generic-library-update": "Có sự cố nghiêm trọng xảy ra khi cập nhật thư viện.", + "bookmark-save": "Không thể lưu thẻ đánh dấu sách", + "cache-file-find": "Không thể tìm thấy hình ảnh được lưu trong bộ nhớ đệm. Tải trang và thử lại.", + "name-required": "Tên không được để trống", + "valid-number": "Phải là một số trang hợp lệ", + "duplicate-bookmark": "Trùng thẻ dánh dấu sách đã tồn tại", + "reading-list-position": "Không cập nhật được vị trí", + "reading-list-updated": "Đã cập nhật", + "reading-list-item-delete": "Không thể xóa các mục", + "reading-list-deleted": "Danh sách đọc đã bị xóa", + "generic-reading-list-delete": "Có lỗi xảy ra khi xóa danh sách đọc", + "generic-reading-list-update": "Có lỗi xảy ra khi cập nhật danh sách đọc", + "generic-reading-list-create": "Có lỗi xảy ra khi tạo danh sách đọc", + "libraries-restricted": "Người dùng không có quyền truy cập vào thư viện nào", + "generic-clear-bookmarks": "Không thể xóa thẻ đánh dấu sách", + "bookmark-permission": "Bạn không được cấp quyền đánh dấu/gỡ đánh dấu sách", + "no-cover-image": "Không ảnh bìa", + "bookmark-doesnt-exist": "Thẻ đánh dấu sách không tồn tại", + "generic-read-progress": "Có trục trặc xảy ra khi lưu tiến độ đọc", + "user-migration-needed": "Người dùng này cần di chuyển. Mời họ đăng xuất và đăng nhập lại để khởi động quá trình di chuyển", + "search": "Tìm kiếm", + "error-import-stack": "Có trục trặc khi nhập danh sách MAL", + "recently-added": "Mới thêm vào", + "update-metadata-fail": "Không thể cập nhật metadata", + "age-restriction-not-applicable": "Không giới hạn", + "generic-relationship": "Có vấn đề khi cập nhật mối quan hệ", + "job-already-running": "Tác vụ đang chạy", + "ip-address-invalid": "Địa chỉ IP '{0}' không hợp lệ", + "bookmark-dir-permissions": "Thư mục Dấu trang không được cấp đúng quyền cho Kavita sử dụng", + "total-backups": "Tổng số Sao lưu phải từ 1 đến 30", + "total-logs": "Tổng số Log phải từ 1 đến 30", + "generic-cover-reading-list-save": "Không thể lưu ảnh bìa vào Danh sách đọc", + "generic-cover-chapter-save": "Không thể lưu ảnh bìa vào Chương", + "reset-chapter-lock": "Không thể đặt lại khóa ảnh bìa cho Chương", + "device-not-created": "Thiết bị chưa tồn tại. Vui lòng tạo thiết bị trước", + "generic-cover-collection-save": "Không thể lưu ảnh bìa vào Bộ sưu tập", + "generic-cover-person-save": "Không thể lưu ảnh bìa vào Người dùng", + "device-duplicate": "Đã có một thiết bị với tên này", + "generic-cover-volume-save": "Không thể lưu ảnh bìa vào Tập", + "browse-on-deck": "Duyệt sách Đang đọc", + "want-to-read": "Muốn đọc", + "browse-recently-updated": "Lướt Mới cập nhật", + "browse-smart-filters": "Lướt theo Bộ lọc thông minh", + "search-description": "Tìm kiếm Bộ Truyện, Bộ sưu tập, hoặc Danh sách đọc", + "external-source-already-exists": "Đã có Nguồn bên ngoài này rồi", + "epub-html-missing": "Không tìm được html thích hợp cho trang", + "collection-tag-title-required": "Tiêu đề Bộ sưu tập không được để trống", + "reading-list-title-required": "Tiêu đề Danh sách đọc không được để trống", + "collection-tag-duplicate": "Trùng tên với bộ sưu tập hiện có", + "generic-cover-library-save": "Không thể lưu ảnh bìa vào Thư viện", + "access-denied": "Bạn không có quyền truy cập", + "generic-user-pref": "Có sự cố khi lưu tùy chọn", + "browse-reading-lists": "Lướt theo Danh sách đọc", + "browse-more-in-genre": "Lướt thêm trong {0}", + "reading-list-restricted": "Danh sách đọc không tồn tại hoặc bạn không có quyền truy cập", + "query-required": "Bạn phải truyền một tham số truy vấn", + "favicon-doesnt-exist": "Favicon không tồn tại", + "smart-filter-doesnt-exist": "Bộ lọc thông minh không tồn tại", + "external-source-doesnt-exist": "Nguồn bên ngoài không tồn tại", + "not-authenticated": "Người dùng không được cấp quyền", + "theme-doesnt-exist": "Tập tin hủ đề bị thiếu hoặc không hợp lệ", + "series-restricted-age-restriction": "Người dùng không được xem chuỗi vì giới hạn độ tuổi", + "chapter-num": "Chương {0}", + "reading-list-name-exists": "Trùng tên với một danh sách đọc hiện có", + "user-no-access-library-from-series": "Người dùng không có quyền truy cập vào thư viện chứa bộ truyện này", + "check-updates": "Kiểm tra cập nhật", + "scan-libraries": "Quét Thư viện", + "kavita+-data-refresh": "Tải lại dữ liệu Kavita+", + "series-updated": "Cập nhật thành công", + "stats-permission-denied": "Bạn không được phép xem số liệu thống kê của người dùng khác", + "url-not-valid": "Đường dẫn không hiển thị một hình ảnh hợp lệ hoặc yêu cầu xác minh", + "url-required": "Bạn cần nhập một đường dẫn để sử dụng", + "on-deck": "Đang đọc", + "browse-libraries": "Lướt theo Thư viện", + "collections": "Tất cả Bộ sưu tập", + "smart-filters": "Bộ lọc thông minh", + "report-stats": "Báo cáo Thống kê", + "cleanup": "Dọn sạch", + "backup": "Sao lưu", + "generic-user-delete": "Không thể xóa người dùng", + "opds-disabled": "OPDS chưa được bật trên máy chủ này", + "browse-want-to-read": "Duyệt Sách Muốn Đọc", + "browse-recently-added": "Duyệt Sách Mới thêm vào", + "reading-lists": "Danh sách đọc", + "libraries": "Tất cả Thư viện", + "browse-collections": "Lướt theo Bộ sưu tập", + "more-in-genre": "Xem thêm Thể loại {0}", + "recently-updated": "Mới cập nhật", + "external-sources": "Nguồn bên ngoài", + "browse-external-sources": "Lướt Nguồn bên ngoài", + "smart-filter-already-in-use": "Có một luồng đã tồn tại với Bộ lọc thông minh này", + "external-source-required": "Cần API Key và Host", + "unable-to-register-k+": "Không đăng ký bản quyền này được vì có lỗi. Hãy liên hệ Hỗ trợ Kavita+", + "unable-to-reset-k+": "Không đặt lại bản quyền Kavita+ được vì có lỗi. Hãy liên hệ Hỗ trợ Kavita+", + "anilist-cred-expired": "Thông tin xác thực AniList đã hết hạn hoặc chưa được thiết lập", + "epub-malformed": "Tập tin bị lỗi! Không đọc được.", + "send-to-permission": "Không thể gửi tệp tin FDF hoặc định dạng khác EPUB đến Kindle vì thiết bị này không hỗ trợ", + "volume-num": "Tập {0}", + "book-num": "Sách {0}", + "issue-num": "Kỳ {0}{1}", + "license-check": "Kiểm tra bản quyền", + "remove-from-want-to-read": "Dọn sạch Muốn đọc", + "update-yearly-stats": "Cập nhật Thống kê hằng năm", + "generic-cover-series-save": "Không thể lưu ảnh bìa vào Series", + "external-source-already-in-use": "Có một luồng hiện có với Nguồn bên ngoài này", + "generic-scrobble-hold": "Đã xảy ra lỗi khi thêm lệnh giữ", + "generic-series-delete": "Đã có sự cố khi xóa Series này", + "encode-as-warning": "Bạn không thể chuyển đổi sang PNG. Đối với bìa, hãy sử dụng \"Làm mới Trang Bìa\". Không thể mã hóa lại dấu trang và biểu tượng yêu thích.", + "person-doesnt-exist": "Người dùng không tồn tại", + "series-restricted": "Người dùng không có quyền truy cập vào Series này", + "no-series": "Không thể lấy được series cho Thư viện", + "bad-copy-files-for-download": "Không thể sao chép tệp vào thư mục lưu trữ tạm thời.", + "process-processed-scrobbling-events": "Xử lý sự kiện Scrobbling đã xử lý", + "generic-series-update": "Đã xảy ra lỗi khi cập nhật series", + "dashboard-stream-doesnt-exist": "Bảng điều khiển Stream không tồn tại", + "sidenav-stream-doesnt-exist": "SideNav Stream không tồn tại", + "scrobble-bad-payload": "Bad payload từ Nhà cung cấp Scrobble", + "progress-must-exist": "Tiến trình phải tồn tại trên người dùng", + "generic-create-temp-archive": "Đã xảy ra sự cố khi tạo kho lưu trữ tạm thời", + "process-scrobbling-events": "Xử lý Sự kiện Scrobbling", + "check-scrobbling-tokens": "Kiểm tra Scrobbling Tokens", + "series-doesnt-exist": "Series không tồn tại", + "volume-doesnt-exist": "Tập không tồn tại", + "perform-scan": "Vui lòng thực hiện quét trên Series này hoặc thư viện và thử lại", + "no-series-collection": "Không thể lấy được series cho Collection", + "person-name-required": "Tên người là bắt buộc và không được để trống", + "person-name-unique": "Tên người phải là duy nhất", + "person-image-doesnt-exist": "Người không tồn tại trong CoversDB" +} diff --git a/API/I18N/zh_Hans.json b/API/I18N/zh_Hans.json index 2210b40a6..92d1751a8 100644 --- a/API/I18N/zh_Hans.json +++ b/API/I18N/zh_Hans.json @@ -1,5 +1,4 @@ { - "bad-credentials": "您的登录信息不正确", "validate-email": "验证你的邮件时出了点问题: {0}", "confirm-token-gen": "生成认证令牌时出现问题", "denied": "未被允许", @@ -117,7 +116,7 @@ "collection-tag-title-required": "收藏标题不能为空", "reading-list-title-required": "阅读清单标题不能为空", "collection-tag-duplicate": "收藏的名称已存在", - "chapter-num": "第{0}话", + "chapter-num": "{0}话", "not-accessible-password": "您的服务器无法访问,重置密码的链接位于日志中", "invalid-payload": "无效的数据", "nothing-to-do": "没有需要处理的任务", @@ -143,9 +142,9 @@ "device-not-created": "设备不存在,请先创建", "send-to-permission": "无法向设备发送Kindel不支持的非EPUB格式或者PDF格式文件", "reading-list-name-exists": "此名称的阅读清单已存在", - "volume-num": "第{0}卷", - "issue-num": "期号 {0}{1}", - "book-num": "第{0}本", + "volume-num": "{0}卷", + "issue-num": "{0}{1}期号", + "book-num": "{0}本", "user-migration-needed": "该用户需要进行迁移。通知他们注销并重新登录,以触发迁移流程", "generic-relationship": "更新关系时发生了问题", "encode-as-warning": "无法转换为PNG格式。对于封面,请使用刷新封面功能。书签和网站图标无法再进行编码。", @@ -180,5 +179,33 @@ "unable-to-reset-k+": "因为一些错误导致无法重置 Kavita+ 许可证。请联系 Kavita+ 支持人员", "email-not-enabled": "此服务器上未启用电子邮件。您无法执行此操作。", "send-to-unallowed": "您无法发送到不属于您的设备", - "send-to-size-limit": "您尝试发送的文件对于您的电子邮件来说太大" + "send-to-size-limit": "您尝试发送的文件对于您的电子邮件提供商来说太大", + "process-scrobbling-events": "处理 Scrobbling 事件", + "report-stats": "报告统计", + "check-updates": "检查更新", + "license-check": "许可证检查", + "cleanup": "清理", + "process-processed-scrobbling-events": "处理已处理的 Scrobbling 事件", + "check-scrobbling-tokens": "检查 Scrobbling Tokens", + "remove-from-want-to-read": "想读清理", + "scan-libraries": "扫描资料库", + "kavita+-data-refresh": "Kavita+ 数据刷新", + "backup": "备份", + "update-yearly-stats": "更新年度统计数据", + "email-settings-invalid": "电子邮件设置缺少信息。确保保存所有电子邮件设置。", + "account-email-invalid": "管理员帐户存档的电子邮件不是有效的电子邮件。无法发送测试电子邮件。", + "collection-already-exists": "收藏已存在", + "error-import-stack": "导入 MAL stack 时出现问题", + "generic-cover-volume-save": "无法将封面图片保存至卷", + "generic-cover-person-save": "无法将封面图片保存到人物", + "person-doesnt-exist": "人员不存在", + "person-name-required": "人员姓名为必填项,且不能为空", + "person-name-unique": "人名必须是唯一的", + "person-image-doesnt-exist": "CoversDB 中不存在此人", + "email-taken": "电子邮件已被使用", + "kavitaplus-restricted": "仅限 Kavita+", + "dashboard-stream-only-delete-smart-filter": "只能从仪表板中删除智能筛选器流", + "smart-filter-name-required": "需要智能筛选器名称", + "smart-filter-system-name": "您不能使用系统提供的流名称", + "sidenav-stream-only-delete-smart-filter": "只能从侧边栏删除智能筛选器流" } diff --git a/API/I18N/zh_Hant.json b/API/I18N/zh_Hant.json index 04cf77530..ce72ea451 100644 --- a/API/I18N/zh_Hant.json +++ b/API/I18N/zh_Hant.json @@ -17,7 +17,7 @@ "file-doesnt-exist": "檔案不存在", "admin-already-exists": "管理員已存在", "age-restriction-update": "更新年齡限制時發生錯誤", - "send-to-kavita-email": "無法將 Kavita 的電子郵件服務用於傳送到裝置。請設定您自己的電子郵件服務。", + "send-to-kavita-email": "未設置電子郵件時無法使用傳送到裝置功能", "not-accessible": "您的伺服器無法從外部存取", "collections": "所有收藏", "email-sent": "電子郵件已傳送", @@ -41,7 +41,6 @@ "generic-create-temp-archive": "建立暫存檔案時遇到問題", "bookmark-save": "無法儲存書籤", "bookmark-dir-permissions": "書籤目錄沒有 Kavita 可以使用的正確權限", - "bad-credentials": "您的帳號或密碼不正確", "total-backups": "總備份數量必須在 1 到 30 之間", "book-num": "書本 {0}", "generic-cover-series-save": "無法將封面圖片儲存到系列作品", @@ -162,5 +161,45 @@ "collection-deleted": "收藏已刪除", "permission-denied": "您不被允許進行此操作", "device-doesnt-exist": "裝置不存在", - "generic-series-delete": "刪除系列作品時發生問題" + "generic-series-delete": "刪除系列作品時發生問題", + "recently-updated": "最近更新", + "external-source-already-in-use": "已存在具有此外部來源的串流", + "report-stats": "統計報告", + "backup": "備份", + "more-in-genre": "更多關於類型 {0}", + "account-email-invalid": "管理員帳號檔案中的電子郵件無效。無法發送測試電子郵件。", + "email-not-enabled": "此伺服器未啟用電子郵件功能。您無法執行此操作。", + "error-import-stack": "匯入 MAL 時出現問題", + "send-to-unallowed": "您無法傳送到不是您自己的裝置", + "email-settings-invalid": "電子郵件設定缺少資訊。請確保所有電子郵件設定已保存。", + "collection-already-exists": "組合已存在", + "send-to-size-limit": "您嘗試傳送的文件過大,無法通過您的電子郵件服務提供商發送", + "external-sources": "外部來源", + "dashboard-stream-doesnt-exist": "儀表板串流不存在", + "unable-to-reset-k+": "發生錯誤,無法重置 Kavita+ 授權。請聯繫 Kavita+ 支援", + "check-scrobbling-tokens": "檢查 Scrobbling Tokens", + "cleanup": "清理", + "browse-more-in-genre": "在 {0} 中繼續瀏覽", + "browse-recently-updated": "瀏覽最近更新", + "external-source-required": "需要 API 金鑰和 Host", + "external-source-doesnt-exist": "外部來源不存在", + "check-updates": "檢查更新", + "license-check": "授權檢查", + "process-scrobbling-events": "處理 Scrobbling 事件", + "process-processed-scrobbling-events": "處理已處理的 Scrobbling 事件", + "remove-from-want-to-read": "清理閱讀清單", + "scan-libraries": "掃描資料庫", + "kavita+-data-refresh": "Kavita+ 資料更新", + "update-yearly-stats": "更新年度統計", + "invalid-email": "使用者檔案中的電子郵件無效。請查看日誌以獲得任何連結。", + "browse-external-sources": "瀏覽外部來源", + "sidenav-stream-doesnt-exist": "側邊導覽串流不存在", + "smart-filter-already-in-use": "已存在具有此智慧篩選器的串流", + "external-source-already-exists": "外部來源已存在", + "generic-cover-volume-save": "無法保存封面圖片", + "generic-cover-person-save": "無法保存封面圖片", + "person-doesnt-exist": "此人不存在", + "person-name-required": "名稱為必填欄位,且不得留空", + "person-name-unique": "名稱不得重複", + "person-image-doesnt-exist": "CoversDB 中不存在此人" } diff --git a/API/Program.cs b/API/Program.cs index 548e57859..77fac9e49 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -56,9 +56,6 @@ public class Program Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); } - Configuration.KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus.kavitareader.com"; - try { var host = CreateHostBuilder(args).Build(); @@ -88,25 +85,36 @@ public class Program } // Apply Before manual migrations that need to run before actual migrations - try + if (isDbCreated) { Task.Run(async () => { // Apply all migrations on startup - logger.LogInformation("Running Migrations"); + logger.LogInformation("Running Manual Migrations"); - // v0.7.14 - await MigrateWantToReadExport.Migrate(context, directoryService, logger); + try + { + // v0.7.14 + await MigrateWantToReadExport.Migrate(context, directoryService, logger); + + // v0.8.2 + await ManualMigrateSwitchToWal.Migrate(context, logger); + + // v0.8.4 + await ManualMigrateEncodeSettings.Migrate(context, logger); + } + catch (Exception ex) + { + /* Swallow */ + } await unitOfWork.CommitAsync(); - logger.LogInformation("Running Migrations - complete"); + logger.LogInformation("Running Manual Migrations - complete"); }).GetAwaiter() .GetResult(); } - catch (Exception ex) - { - logger.LogCritical(ex, "An error occurred during migration"); - } + + await context.Database.MigrateAsync(); @@ -117,6 +125,7 @@ public class Program await Seed.SeedDefaultStreams(unitOfWork); await Seed.SeedDefaultSideNavStreams(unitOfWork); await Seed.SeedUserApiKeys(context); + await Seed.SeedMetadataSettings(context); } catch (Exception ex) { diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 71dc0f3b6..74b6709fa 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -5,8 +5,10 @@ using System.Threading.Tasks; using System.Web; using API.Constants; using API.Data; +using API.DTOs.Account; using API.Entities; using API.Errors; +using API.Extensions; using Kavita.Common; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; @@ -46,7 +48,7 @@ public class AccountService : IAccountService public async Task> ChangeUserPassword(AppUser user, string newPassword) { var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); - if (passwordValidationIssues.Any()) return passwordValidationIssues; + if (passwordValidationIssues.Count != 0) return passwordValidationIssues; var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) @@ -55,15 +57,11 @@ public class AccountService : IAccountService return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } - result = await _userManager.AddPasswordAsync(user, newPassword); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } + if (result.Succeeded) return []; - return new List(); + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); } public async Task> ValidatePassword(AppUser user, string password) @@ -81,26 +79,28 @@ public class AccountService : IAccountService } public async Task> ValidateUsername(string username) { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + // Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535 + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null + && x.NormalizedUserName == username.ToUpper())) { - return new List() - { - new ApiException(400, "Username is already taken") - }; + return + [ + new(400, "Username is already taken") + ]; } - return Array.Empty(); + return []; } public async Task> ValidateEmail(string email) { var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); - if (user == null) return Array.Empty(); + if (user == null) return []; - return new List() - { + return + [ new ApiException(400, "Email is already registered") - }; + ]; } /// @@ -112,6 +112,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.BookmarkRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -124,6 +125,7 @@ public class AccountService : IAccountService { if (user == null) return false; var roles = await _userManager.GetRolesAsync(user); + return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole); } @@ -135,9 +137,10 @@ public class AccountService : IAccountService public async Task CanChangeAgeRestriction(AppUser? user) { if (user == null) return false; + var roles = await _userManager.GetRolesAsync(user); if (roles.Contains(PolicyConstants.ReadOnlyRole)) return false; + return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole); } - } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 1262c2cc8..335a5a74b 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -16,6 +16,7 @@ using Kavita.Common; using Microsoft.Extensions.Logging; using SharpCompress.Archives; using SharpCompress.Common; +using YamlDotNet.Core; namespace API.Services; @@ -127,9 +128,11 @@ public class ArchiveService : IArchiveService } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetNumberOfPagesFromArchive] This archive cannot be read: {ArchivePath}. Defaulting to 0 pages", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, "File format not supported", string.Empty); return 0; default: _logger.LogWarning("[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, "File format not supported", string.Empty); return 0; } } @@ -218,7 +221,7 @@ public class ArchiveService : IArchiveService /// public string GetCoverImage(string archivePath, string fileName, string outputDirectory, EncodeFormat format, CoverImageSize size = CoverImageSize.Default) { - if (archivePath == null || !IsValidArchive(archivePath)) return string.Empty; + if (string.IsNullOrEmpty(archivePath) || !IsValidArchive(archivePath)) return string.Empty; try { var libraryHandler = CanOpen(archivePath); @@ -352,8 +355,23 @@ public class ArchiveService : IArchiveService foreach (var path in files) { var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name)); + + // Image series need different handling + if (Tasks.Scanner.Parser.Parser.IsImage(path)) + { + var parentDirectory = _directoryService.FileSystem.DirectoryInfo.New(path).Parent?.Name; + tempPath = Path.Join(tempLocation, parentDirectory ?? _directoryService.FileSystem.FileInfo.New(path).Name); + } + + if (Tasks.Scanner.Parser.Parser.IsArchive(path)) + { + // Archives don't need to be put into a subdirectory of the same name + tempPath = _directoryService.GetParentDirectoryName(tempPath); + } + progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count)); - ExtractArchive(path, tempPath); + + _directoryService.CopyFileToDirectory(path, tempPath); count++; } } @@ -392,7 +410,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 19dedde5c..99fdd1400 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml; using API.Data.Metadata; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; +using API.Helpers; using Docnet.Core; using Docnet.Core.Converters; using Docnet.Core.Models; @@ -69,11 +71,50 @@ public class BookService : IBookService private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; private const string BookApiUrl = "book-resources?file="; + private readonly PdfComicInfoExtractor _pdfComicInfoExtractor; + + /// + /// Setup the most lenient book parsing options possible as people have some really bad epubs + /// public static readonly EpubReaderOptions BookReaderOptions = new() { PackageReaderOptions = new PackageReaderOptions { - IgnoreMissingToc = true + IgnoreMissingToc = true, + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = false + } + }; + + public static readonly EpubReaderOptions LenientBookReaderOptions = new() + { + PackageReaderOptions = new PackageReaderOptions + { + IgnoreMissingToc = true, + SkipInvalidManifestItems = true, + }, + Epub2NcxReaderOptions = new Epub2NcxReaderOptions + { + IgnoreMissingContentForNavigationPoints = false + }, + SpineReaderOptions = new SpineReaderOptions + { + IgnoreMissingManifestItems = false + }, + BookCoverReaderOptions = new BookCoverReaderOptions + { + Epub2MetadataIgnoreMissingManifestItem = true } }; @@ -83,6 +124,7 @@ public class BookService : IBookService _directoryService = directoryService; _imageService = imageService; _mediaErrorService = mediaErrorService; + _pdfComicInfoExtractor = new PdfComicInfoExtractor(_logger, _mediaErrorService); } private static bool HasClickableHrefPart(HtmlNode anchor) @@ -305,8 +347,16 @@ public class BookService : IBookService var imageFile = GetKeyForImage(book, image.Attributes[key].Value); image.Attributes.Remove(key); - // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx - image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile)); + + if (!imageFile.StartsWith("http")) + { + // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx + image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile)); + } + else + { + image.Attributes.Add(key, imageFile); + } // Add a custom class that the reader uses to ensure images stay within reader parent.AddClass("kavita-scale-width-container"); @@ -349,11 +399,14 @@ public class BookService : IBookService { // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); - if (htmlNode == null || !htmlNode.Attributes.Contains("class")) return body.InnerHtml; + if (htmlNode == null) return body.InnerHtml; var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; - var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; - body.Attributes.Add("class", $"{classes}"); + var htmlClasses = htmlNode.Attributes.Contains("class") ? htmlNode.Attributes["class"].Value : string.Empty; + + body.Attributes.Add("class", $"{htmlClasses} {bodyClasses}"); + + // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. return $"
{body.InnerHtml}
"; } @@ -382,7 +435,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) @@ -424,13 +477,14 @@ public class BookService : IBookService } } - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetEpubComicInfo(string filePath) { - if (!IsValidFile(filePath) || Parser.IsPdf(filePath)) return null; + EpubBookRef? epubBook = null; try { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + epubBook = OpenEpubWithFallback(filePath, epubBook); + var publicationDate = epubBook.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date; @@ -438,10 +492,11 @@ public class BookService : IBookService { publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date; } + var (year, month, day) = GetPublicationDate(publicationDate); var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault(); - var info = new ComicInfo + var info = new ComicInfo { Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description, Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)), @@ -449,7 +504,8 @@ public class BookService : IBookService Day = day, Year = year, Title = epubBook.Title, - Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), + Genre = string.Join(",", + epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())), LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages .Select(l => l.Language) .FirstOrDefault()) @@ -460,7 +516,8 @@ public class BookService : IBookService foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers) { if (string.IsNullOrEmpty(identifier.Identifier)) continue; - if (!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) + if (!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase)) { var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty); if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn)) @@ -468,11 +525,13 @@ public class BookService : IBookService _logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath); continue; } + info.Isbn = isbn; } - if ((!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || - identifier.Identifier.StartsWith("url:")) + if ((!string.IsNullOrEmpty(identifier.Scheme) && + identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) || + identifier.Identifier.StartsWith("url:")) { var url = identifier.Identifier.Replace("url:", string.Empty); weblinks.Add(url.Trim()); @@ -502,6 +561,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "calibre:series_index": info.Volume = metadataItem.Content; @@ -521,6 +581,7 @@ public class BookService : IBookService { info.SeriesSort = metadataItem.Content; } + break; case "collection-type": // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series" @@ -551,7 +612,8 @@ public class BookService : IBookService } // If this is a single book and not a collection, set publication status to Completed - if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath).Equals(Parser.DefaultVolume)) + if (string.IsNullOrEmpty(info.Volume) && + Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) { info.Count = 1; } @@ -560,28 +622,69 @@ public class BookService : IBookService info.Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.CleanAuthor(c.Creator))); - var hasVolumeInSeries = !Parser.ParseVolume(info.Title) - .Equals(Parser.DefaultVolume); + var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) + .Equals(Parser.LooseLeafVolume); - if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) + if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && + (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) { // This is likely a light novel for which we can set series from parsed title - info.Series = Parser.ParseSeries(info.Title); - info.Volume = Parser.ParseVolume(info.Title); + info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); + info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga); } return info; } catch (Exception ex) { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata"); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata: {FilePath}", filePath); _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, "There was an exception parsing metadata", ex); } + finally + { + epubBook?.Dispose(); + } return null; } + private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook) + { + try + { + epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "[GetComicInfo] There was an exception parsing metadata, falling back to a more lenient parsing method: {FilePath}", + filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing metadata", ex); + } + finally + { + epubBook ??= EpubReader.OpenBook(filePath, LenientBookReaderOptions); + } + + return epubBook; + } + + public ComicInfo? GetComicInfo(string filePath) + { + if (!IsValidFile(filePath)) return null; + + if (Parser.IsPdf(filePath)) + { + return _pdfComicInfoExtractor.GetComicInfo(filePath); + } + else + { + return GetEpubComicInfo(filePath); + } + } + private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info) { var titleId = metadataItem.Refines?.Replace("#", string.Empty); @@ -608,7 +711,6 @@ public class BookService : IBookService item.Property == "display-seq" && item.Refines == metadataItem.Refines); if (count == null || count.Content == "0") { - // TODO: Rewrite this to use a StringBuilder // Treat this as a Collection info.SeriesGroup += (string.IsNullOrEmpty(info.StoryArc) ? string.Empty : ",") + readingListElem.Title.Replace(',', '_'); @@ -670,7 +772,7 @@ public class BookService : IBookService var month = 0; var day = 0; if (string.IsNullOrEmpty(publicationDate)) return (year, month, day); - switch (DateTime.TryParse(publicationDate, out var date)) + switch (DateTime.TryParse(publicationDate, CultureInfo.InvariantCulture, out var date)) { case true: year = date.Year; @@ -685,7 +787,7 @@ public class BookService : IBookService return (year, month, day); } - private static string ValidateLanguage(string? language) + public static string ValidateLanguage(string? language) { if (string.IsNullOrEmpty(language)) return string.Empty; @@ -725,7 +827,7 @@ public class BookService : IBookService return docReader.GetPageCount(); } - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); return epubBook.GetReadingOrder().Count; } catch (Exception ex) @@ -740,8 +842,6 @@ public class BookService : IBookService private static string EscapeTags(string content) { - // content = StartingScriptTag().Replace(content, ""); - // content = StartingTitleTag().Replace(content, ""); content = Regex.Replace(content, @")", "", RegexOptions.None, Parser.RegexTimeout); content = Regex.Replace(content, @")", "", RegexOptions.None, Parser.RegexTimeout); return content; @@ -781,11 +881,11 @@ 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 { - using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions); // // @@ -848,8 +948,8 @@ public class BookService : IBookService Format = MangaFormat.Epub, Filename = Path.GetFileName(filePath), Title = specialName?.Trim() ?? string.Empty, - FullFilePath = filePath, - IsSpecial = false, + FullFilePath = Parser.NormalizePath(filePath), + IsSpecial = Parser.HasSpecialMarker(filePath), Series = series.Trim(), SeriesSort = series.Trim(), Volumes = seriesIndex @@ -870,10 +970,10 @@ public class BookService : IBookService Format = MangaFormat.Epub, Filename = Path.GetFileName(filePath), Title = epubBook.Title.Trim(), - FullFilePath = filePath, - IsSpecial = false, + FullFilePath = Parser.NormalizePath(filePath), + IsSpecial = Parser.HasSpecialMarker(filePath), Series = epubBook.Title.Trim(), - Volumes = Parser.DefaultVolume, + Volumes = Parser.LooseLeafVolume, }; } catch (Exception ex) @@ -887,7 +987,7 @@ public class BookService : IBookService } /// - /// Extracts a pdf into images to a target directory. Uses multi-threaded implementation since docnet is slow normally. + /// Extracts a pdf into images to a target directory. Uses multithreaded implementation since docnet is slow normally. /// /// /// @@ -989,7 +1089,7 @@ public class BookService : IBookService /// public async Task> GenerateTableOfContents(Chapter chapter) { - using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var navItems = await book.GetNavigationAsync(); @@ -1043,8 +1143,6 @@ public class BookService : IBookService // TODO: We may want to check if there is a toc.ncs file to better handle nested toc // We could do a fallback first with ol/lis - //var sections = doc.DocumentNode.SelectNodes("//ol"); - //if (sections == null) @@ -1119,7 +1217,7 @@ public class BookService : IBookService /// All exceptions throw this public async Task GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl) { - using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions); var mappings = await CreateKeyToPageMappingAsync(book); var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; @@ -1221,13 +1319,13 @@ public class BookService : IBookService return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size); } - using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions); + using var epubBook = EpubReader.OpenBook(fileFilePath, LenientBookReaderOptions); try { // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one. var coverImageContent = epubBook.Content.Cover - ?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) // FileName -> FilePath + ?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath)) ?? epubBook.Content.Images.Local.FirstOrDefault(); if (coverImageContent == null) return string.Empty; @@ -1239,7 +1337,7 @@ public class BookService : IBookService { _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); _mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService, - "There was a critical error and prevented thumbnail generation", ex); // TODO: Localize this + "There was a critical error and prevented thumbnail generation", ex); } return string.Empty; diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index f28ef9f74..4cd77ddd9 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -7,6 +7,7 @@ using API.Data; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; +using API.Extensions; using Hangfire; using Microsoft.Extensions.Logging; @@ -90,6 +91,13 @@ public class BookmarkService : IBookmarkService var bookmark = await _unitOfWork.UserRepository.GetBookmarkAsync(bookmarkId); if (bookmark == null) return; + // Validate the bookmark isn't already in target format + if (bookmark.FileName.EndsWith(encodeFormat.GetExtension())) + { + // Nothing to ddo + return; + } + bookmark.FileName = await _mediaConversionService.SaveAsEncodingFormat(bookmarkDirectory, bookmark.FileName, BookmarkStem(bookmark.AppUserId, bookmark.SeriesId, bookmark.ChapterId), encodeFormat); _unitOfWork.UserRepository.Update(bookmark); @@ -137,7 +145,7 @@ public class BookmarkService : IBookmarkService _unitOfWork.UserRepository.Add(bookmark); await _unitOfWork.CommitAsync(); - if (settings.EncodeMediaAs == EncodeFormat.WEBP) + if (settings.EncodeMediaAs != EncodeFormat.PNG) { // Enqueue a task to convert the bookmark to webP BackgroundJob.Enqueue(() => ConvertBookmarkToEncoding(bookmark.Id)); diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index c19797b22..283d4b1ac 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Reader; @@ -51,6 +53,8 @@ public class CacheService : ICacheService private readonly IReadingItemService _readingItemService; private readonly IBookmarkService _bookmarkService; + private static readonly ConcurrentDictionary ExtractLocks = new(); + public CacheService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, IReadingItemService readingItemService, IBookmarkService bookmarkService) @@ -76,7 +80,6 @@ public class CacheService : ICacheService /// public IEnumerable GetCachedFileDimensions(string cachePath) { - var sw = Stopwatch.StartNew(); var files = _directoryService.GetFilesWithExtension(cachePath, Tasks.Scanner.Parser.Parser.ImageFileExtensions) .OrderByNatural(Path.GetFileNameWithoutExtension) .ToArray(); @@ -114,7 +117,6 @@ public class CacheService : ICacheService Cache.MaxFiles = originalCacheSize; } - _logger.LogDebug("File Dimensions call for {Length} images took {Time}ms", dimensions.Count, sw.ElapsedMilliseconds); return dimensions; } @@ -166,11 +168,42 @@ public class CacheService : ICacheService var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var extractPath = GetCachePath(chapterId); - if (_directoryService.Exists(extractPath)) return chapter; - var files = chapter?.Files.ToList(); - ExtractChapterFiles(extractPath, files, extractPdfToImages); + var extractLock = ExtractLocks.GetOrAdd(chapterId, id => new SemaphoreSlim(1,1)); - return chapter; + await extractLock.WaitAsync(); + try { + if (_directoryService.Exists(extractPath)) + { + if (extractPdfToImages) + { + var pdfImages = _directoryService.GetFiles(extractPath, + Tasks.Scanner.Parser.Parser.ImageFileExtensions); + if (pdfImages.Any()) + { + return chapter; + } + } + else + { + // Do an explicit check for files since rarely a "permission denied" error on deleting + // the file can occur, thus leaving an empty folder and we would never re-cache the files. + if (_directoryService.GetFiles(extractPath).Any()) + { + return chapter; + } + + // Delete the extractPath as ExtractArchive will return if the directory already exists + _directoryService.ClearAndDeleteDirectory(extractPath); + } + } + + var files = chapter?.Files.ToList(); + ExtractChapterFiles(extractPath, files, extractPdfToImages); + } finally { + extractLock.Release(); + } + + return chapter; } /// @@ -183,16 +216,33 @@ public class CacheService : ICacheService /// public void ExtractChapterFiles(string extractPath, IReadOnlyList? files, bool extractPdfImages = false) { - if (files == null) return; + if (files == null || files.Count == 0) return; var removeNonImages = true; var fileCount = files.Count; var extraPath = string.Empty; var extractDi = _directoryService.FileSystem.DirectoryInfo.New(extractPath); - if (files.Count > 0 && files[0].Format == MangaFormat.Image) + if (files[0].Format == MangaFormat.Image) { - _readingItemService.Extract(files[0].FilePath, extractPath, MangaFormat.Image, files.Count); - _directoryService.Flatten(extractDi.FullName); + // Check if all the files are Images. If so, do a directory copy, else do the normal copy + if (files.All(f => f.Format == MangaFormat.Image)) + { + _directoryService.ExistOrCreate(extractPath); + _directoryService.CopyFilesToDirectory(files.Select(f => f.FilePath), extractPath); + } + else + { + foreach (var file in files) + { + if (fileCount > 1) + { + extraPath = file.Id + string.Empty; + } + _readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), MangaFormat.Image, files.Count); + } + _directoryService.Flatten(extractDi.FullName); + } + } foreach (var file in files) @@ -293,7 +343,7 @@ public class CacheService : ICacheService var path = GetCachePath(chapterId); // NOTE: We can optimize this by extracting and renaming, so we don't need to scan for the files and can do a direct access var files = _directoryService.GetFilesWithExtension(path, Tasks.Scanner.Parser.Parser.ImageFileExtensions) - .OrderByNatural(Path.GetFileNameWithoutExtension) + //.OrderByNatural(Path.GetFileNameWithoutExtension) // This is already done in GetPageFromFiles .ToArray(); return GetPageFromFiles(files, page); diff --git a/API/Services/CollectionTagService.cs b/API/Services/CollectionTagService.cs index b024d687a..a73c0cea2 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,42 +32,49 @@ 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 ??= []; + 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); // Check if Tag has updated (Summary) - var summary = dto.Summary.Trim(); + var summary = (dto.Summary ?? string.Empty).Trim(); if (existingTag.Summary == null || !existingTag.Summary.Equals(summary)) { existingTag.Summary = summary; @@ -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 ??= []; + 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/DeviceService.cs b/API/Services/DeviceService.cs index 13c51c8d5..ddaf93b64 100644 --- a/API/Services/DeviceService.cs +++ b/API/Services/DeviceService.cs @@ -108,7 +108,7 @@ public class DeviceService : IDeviceService public async Task SendTo(IReadOnlyList chapterIds, int deviceId) { var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!settings.IsEmailSetup()) + if (!settings.IsEmailSetupForSendToDevice()) throw new KavitaException("send-to-kavita-email"); var device = await _unitOfWork.DeviceRepository.GetDeviceById(deviceId); @@ -123,9 +123,16 @@ public class DeviceService : IDeviceService throw new KavitaException("send-to-size-limit"); - device.UpdateLastUsed(); - _unitOfWork.DeviceRepository.Update(device); - await _unitOfWork.CommitAsync(); + try + { + device.UpdateLastUsed(); + _unitOfWork.DeviceRepository.Update(device); + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue updating device last used time"); + } var success = await _emailService.SendFilesToEmail(new SendToDto() { diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index e3dede802..ae9383c7b 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using API.DTOs.System; using API.Entities.Enums; using API.Extensions; +using API.Services.Tasks.Scanner.Parser; using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; @@ -28,11 +29,20 @@ public interface IDirectoryService string LocalizationDirectory { get; } string CustomizedTemplateDirectory { get; } string TemplateDirectory { get; } + string PublisherDirectory { get; } + /// + /// Used for caching documents that may need to stay on disk for more than a day + /// + string LongTermCacheDirectory { get; } /// /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// string BookmarkDirectory { get; } /// + /// Used for random files needed, like images to check against, list of countries, etc + /// + string AssetsDirectory { get; } + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// /// Absolute path of directory to scan. @@ -53,6 +63,8 @@ public interface IDirectoryService bool CopyDirectoryToDirectory(string? sourceDirName, string destDirName, string searchPattern = ""); Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths); + string? FindLowestDirectoriesFromFiles(IList libraryFolders, + IList filePaths); IEnumerable GetFoldersTillRoot(string rootPath, string fullPath); IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); bool ExistOrCreate(string directoryPath); @@ -65,14 +77,13 @@ public interface IDirectoryService SearchOption searchOption = SearchOption.TopDirectoryOnly); IEnumerable GetDirectories(string folderPath); IEnumerable GetDirectories(string folderPath, GlobMatcher? matcher); + IEnumerable GetAllDirectories(string folderPath, GlobMatcher? matcher = null); string GetParentDirectoryName(string fileOrFolder); - IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null); + IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null, SearchOption searchOption = SearchOption.AllDirectories); DateTime GetLastWriteTime(string folderPath); - GlobMatcher? CreateMatcherFromFile(string filePath); } public class DirectoryService : IDirectoryService { - public const string KavitaIgnoreFile = ".kavitaignore"; public IFileSystem FileSystem { get; } public string CacheDirectory { get; } public string CoverImageDirectory { get; } @@ -80,21 +91,22 @@ public class DirectoryService : IDirectoryService public string TempDirectory { get; } public string ConfigDirectory { get; } public string BookmarkDirectory { get; } + public string AssetsDirectory { get; } public string SiteThemeDirectory { get; } public string FaviconDirectory { get; } public string LocalizationDirectory { get; } public string CustomizedTemplateDirectory { get; } public string TemplateDirectory { get; } + public string PublisherDirectory { get; } + public string LongTermCacheDirectory { get; } private readonly ILogger _logger; private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase; private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle", - MatchOptions, - Tasks.Scanner.Parser.Parser.RegexTimeout); + @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle|\.yacreaderlibrary", + MatchOptions, Parser.RegexTimeout); private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", - MatchOptions, - Tasks.Scanner.Parser.Parser.RegexTimeout); + MatchOptions, Parser.RegexTimeout); public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); public DirectoryService(ILogger logger, IFileSystem fileSystem) @@ -113,6 +125,8 @@ public class DirectoryService : IDirectoryService ExistOrCreate(TempDirectory); BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); ExistOrCreate(BookmarkDirectory); + AssetsDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "Assets"); + ExistOrCreate(AssetsDirectory); SiteThemeDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "themes"); ExistOrCreate(SiteThemeDirectory); FaviconDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "favicons"); @@ -122,6 +136,10 @@ public class DirectoryService : IDirectoryService ExistOrCreate(CustomizedTemplateDirectory); TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates"); ExistOrCreate(TemplateDirectory); + PublisherDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "images", "publishers"); + ExistOrCreate(PublisherDirectory); + LongTermCacheDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "cache-long"); + ExistOrCreate(LongTermCacheDirectory); } /// @@ -129,22 +147,38 @@ public class DirectoryService : IDirectoryService /// /// This will always exclude patterns /// Directory to search - /// Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files. + /// Regex version of search pattern (e.g., \.mp3|\.mp4). Defaults to * meaning all files. /// SearchOption to use, defaults to TopDirectoryOnly /// List of file paths public IEnumerable GetFilesWithCertainExtensions(string path, string searchPatternExpression = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) { - if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; - var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase, Tasks.Scanner.Parser.Parser.RegexTimeout); + // If directory doesn't exist, exit the iterator with no results + if (!FileSystem.Directory.Exists(path)) + yield break; - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) - .Where(file => - reSearchPattern.IsMatch(FileSystem.Path.GetExtension(file)) && !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); + // Compile the regex pattern for faster repeated matching + var reSearchPattern = new Regex(searchPatternExpression, + RegexOptions.IgnoreCase | RegexOptions.Compiled, + Parser.RegexTimeout); + + // Enumerate files in the directory and apply filters + foreach (var file in FileSystem.Directory.EnumerateFiles(path, "*", searchOption)) + { + var fileName = FileSystem.Path.GetFileName(file); + var fileExtension = FileSystem.Path.GetExtension(file); + + // Check if the file matches the pattern and exclude macOS metadata files + if (reSearchPattern.IsMatch(fileExtension) && !fileName.StartsWith(Parser.MacOsMetadataFileStartsWith)) + { + yield return file; + } + } } + /// /// Returns a list of folders from end of fullPath to rootPath. If a file is passed at the end of the fullPath, it will be ignored. /// @@ -166,8 +200,6 @@ public class DirectoryService : IDirectoryService rootPath = rootPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); } - - var path = fullPath.EndsWith(separator) ? fullPath.Substring(0, fullPath.Length - 1) : fullPath; var root = rootPath.EndsWith(separator) ? rootPath.Substring(0, rootPath.Length - 1) : rootPath; var paths = new List(); @@ -208,25 +240,34 @@ public class DirectoryService : IDirectoryService /// public IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) { - if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; + if (!FileSystem.Directory.Exists(path)) + yield break; // Use yield break to exit the iterator early - if (fileNameRegex != string.Empty) + Regex? reSearchPattern = null; + if (!string.IsNullOrEmpty(fileNameRegex)) { - var reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase, - Tasks.Scanner.Parser.Parser.RegexTimeout); - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) - .Where(file => - { - var fileName = FileSystem.Path.GetFileName(file); - return reSearchPattern.IsMatch(fileName) && - !fileName.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith); - }); + // Compile the regex for better performance when used frequently + reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled, Tasks.Scanner.Parser.Parser.RegexTimeout); } - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption).Where(file => - !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); + // Enumerate files lazily + foreach (var file in FileSystem.Directory.EnumerateFiles(path, "*", searchOption)) + { + var fileName = FileSystem.Path.GetFileName(file); + + // Exclude macOS metadata files + if (fileName.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)) + continue; + + // If a regex is provided, match the file name against it + if (reSearchPattern != null && !reSearchPattern.IsMatch(fileName)) + continue; + + yield return file; // Yield each matching file as it's found + } } + /// /// Copies a file into a directory. Does not maintain parent folder of file. /// Will create target directory if doesn't exist. Automatically overwrites what is there. @@ -322,7 +363,7 @@ public class DirectoryService : IDirectoryService return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray(); } - return !FileSystem.Directory.Exists(path) ? Array.Empty() : FileSystem.Directory.GetFiles(path); + return !FileSystem.Directory.Exists(path) ? [] : FileSystem.Directory.GetFiles(path); } /// @@ -384,10 +425,12 @@ public class DirectoryService : IDirectoryService { foreach (var file in di.EnumerateFiles()) { + if (!file.Exists) continue; file.Delete(); } foreach (var dir in di.EnumerateDirectories()) { + if (!dir.Exists) continue; dir.Delete(true); } } @@ -584,6 +627,63 @@ public class DirectoryService : IDirectoryService return dirs; } + /// + /// Finds the lowest directory from a set of file paths. Does not return the root path, will always select the lowest non-root path. + /// + /// If the file paths do not contain anything from libraryFolders, this returns null. + /// List of top level folders which files belong to + /// List of file paths that belong to libraryFolders + /// Lowest non-root path, or null if not found + public string? FindLowestDirectoriesFromFiles(IList libraryFolders, IList filePaths) + { + // Normalize the file paths only once + var normalizedFilePaths = filePaths.Select(Parser.NormalizePath).ToList(); + + // Use a list to store all directories for comparison + var dirs = new List(); + + // Iterate through each library folder and collect matching directories + foreach (var normalizedFolder in libraryFolders.Select(Parser.NormalizePath)) + { + foreach (var file in normalizedFilePaths) + { + // If the file path contains the folder path, get its directory + if (!file.Contains(normalizedFolder)) continue; + + var lowestPath = Path.GetDirectoryName(file); + if (!string.IsNullOrEmpty(lowestPath)) + { + dirs.Add(Parser.NormalizePath(lowestPath)); // Add to list + } + } + } + + if (dirs.Count == 0) + { + return null; // No directories found + } + + // Now find the deepest common directory among all paths + var commonPath = dirs.Aggregate(GetDeepestCommonPath); // Use new method to get deepest path + + // Return the common path if it exists and is not one of the root directories + return libraryFolders.Any(folder => commonPath == Parser.NormalizePath(folder)) ? null : commonPath; + } + + public static string GetDeepestCommonPath(string path1, string path2) + { + var parts1 = path1.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var parts2 = path2.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // Get the longest matching parts, ensuring that deeper parts in hierarchy are considered + var commonParts = parts1.Zip(parts2, (p1, p2) => p1 == p2 ? p1 : null) + .TakeWhile(part => part != null) + .ToArray(); + + return Parser.NormalizePath(string.Join(Path.DirectorySeparatorChar.ToString(), commonParts)); + } + + /// /// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope. /// @@ -615,17 +715,18 @@ public class DirectoryService : IDirectoryService /// Returns all directories, including subdirectories. Automatically excludes directories that shouldn't be in scope. /// /// + /// /// - public IEnumerable GetAllDirectories(string folderPath) + public IEnumerable GetAllDirectories(string folderPath, GlobMatcher? matcher = null) { if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; var directories = new List(); - var foundDirs = GetDirectories(folderPath); + var foundDirs = GetDirectories(folderPath, matcher); foreach (var foundDir in foundDirs) { directories.Add(foundDir); - directories.AddRange(GetAllDirectories(foundDir)); + directories.AddRange(GetAllDirectories(foundDir, matcher)); } return directories; @@ -649,93 +750,81 @@ public class DirectoryService : IDirectoryService } /// - /// Scans a directory by utilizing a recursive folder search. If a .kavitaignore file is found, will ignore matching patterns + /// Scans a directory by utilizing a recursive folder search. /// /// /// /// + /// Pass TopDirectories /// - public IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null) + public IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null, + SearchOption searchOption = SearchOption.AllDirectories) { - _logger.LogDebug("[ScanFiles] called on {Path}", folderPath); var files = new List(); + if (!Exists(folderPath)) return files; - var potentialIgnoreFile = FileSystem.Path.Join(folderPath, KavitaIgnoreFile); - if (matcher == null) + if (searchOption == SearchOption.AllDirectories) { - matcher = CreateMatcherFromFile(potentialIgnoreFile); + + // Stack to hold directories to process + var directoriesToProcess = new Stack(); + directoriesToProcess.Push(folderPath); + + while (directoriesToProcess.Count > 0) + { + var currentDirectory = directoriesToProcess.Pop(); + + // Get files from the current directory + var filesInCurrentDirectory = GetFilesWithCertainExtensions(currentDirectory, fileTypes); + files.AddRange(filesInCurrentDirectory); + + // Get subdirectories and add them to the stack + var subdirectories = GetDirectories(currentDirectory, matcher); + foreach (var subdirectory in subdirectories) + { + directoriesToProcess.Push(subdirectory); + } + } } else { - matcher.Merge(CreateMatcherFromFile(potentialIgnoreFile)); + // If TopDirectoryOnly is specified, only get files in the specified folder + var filesInCurrentDirectory = GetFilesWithCertainExtensions(folderPath, fileTypes); + files.AddRange(filesInCurrentDirectory); } - - var directories = GetDirectories(folderPath, matcher); - - foreach (var directory in directories) + // Filter out unwanted files based on matcher if provided + if (matcher != null) { - files.AddRange(ScanFiles(directory, fileTypes, matcher)); - } - - - // Get the matcher from either ignore or global (default setup) - if (matcher == null) - { - files.AddRange(GetFilesWithCertainExtensions(folderPath, fileTypes)); - } - else - { - var foundFiles = GetFilesWithCertainExtensions(folderPath, - fileTypes) - .Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.New(file).Name)); - files.AddRange(foundFiles); + files = files.Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.New(file).Name)).ToList(); } return files; } + /// /// Recursively scans a folder and returns the max last write time on any folders and files /// - /// If the folder is empty, this will return MaxValue for a DateTime + /// If the folder is empty or non-existent, this will return MaxValue for a DateTime /// /// Max Last Write Time public DateTime GetLastWriteTime(string folderPath) { - if (!FileSystem.Directory.Exists(folderPath)) throw new IOException($"{folderPath} does not exist"); + if (!FileSystem.Directory.Exists(folderPath)) return DateTime.MaxValue; + var fileEntries = FileSystem.Directory.GetFileSystemEntries(folderPath, "*.*", SearchOption.AllDirectories); if (fileEntries.Length == 0) return DateTime.MaxValue; - return fileEntries.Max(path => FileSystem.File.GetLastWriteTime(path)); - } - /// - /// Generates a GlobMatcher from a .kavitaignore file found at path. Returns null otherwise. - /// - /// - /// - public GlobMatcher? CreateMatcherFromFile(string filePath) - { - if (!FileSystem.File.Exists(filePath)) - { - return null; - } + // Find the max last write time of the files + var maxFiles = fileEntries.Max(path => FileSystem.File.GetLastWriteTime(path)); - // Read file in and add each line to Matcher - var lines = FileSystem.File.ReadAllLines(filePath); - if (lines.Length == 0) - { - return null; - } + // Get the last write time of the directory itself + var directoryLastWriteTime = FileSystem.Directory.GetLastWriteTime(folderPath); - GlobMatcher matcher = new(); - foreach (var line in lines.Where(s => !string.IsNullOrEmpty(s))) - { - matcher.AddExclude(line); - } - - return matcher; + // Use comparison to get the max DateTime value + return directoryLastWriteTime > maxFiles ? directoryLastWriteTime : maxFiles; } diff --git a/API/Services/DownloadService.cs b/API/Services/DownloadService.cs index a8dfd5d50..8a8cff1da 100644 --- a/API/Services/DownloadService.cs +++ b/API/Services/DownloadService.cs @@ -35,6 +35,12 @@ public class DownloadService : IDownloadService // Figures out what the content type should be based on the file name. if (!_fileTypeProvider.TryGetContentType(filepath, out var contentType)) { + if (contentType == null) + { + // Get extension + contentType = Path.GetExtension(filepath); + } + contentType = Path.GetExtension(filepath).ToLowerInvariant() switch { ".cbz" => "application/x-cbz", diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 5e54e2170..35cfa7b04 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -4,16 +4,22 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net; +using System.Text; using System.Threading.Tasks; using System.Web; using API.Data; using API.DTOs.Email; +using API.Entities; +using API.Services.Plus; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Extensions; using MailKit.Security; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using MimeKit; +using MimeTypes; namespace API.Services; #nullable enable @@ -29,6 +35,8 @@ internal class EmailOptionsDto /// Filenames to attach /// public IList? Attachments { get; set; } + public int? ToUserId { get; set; } + public required string Template { get; set; } } public interface IEmailService @@ -43,6 +51,10 @@ public interface IEmailService Task GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true); + + Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider); + Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider); + Task SendKavitaPlusDebug(); } public class EmailService : IEmailService @@ -51,16 +63,28 @@ 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 const string SendToDeviceTemplate = "SendToDevice"; + public const string EmailTestTemplate = "EmailTest"; + public const string EmailChangeTemplate = "EmailChange"; + public const string TokenExpirationTemplate = "TokenExpiration"; + public const string TokenExpiringSoonTemplate = "TokenExpiringSoon"; + public const string EmailConfirmTemplate = "EmailConfirm"; + public const string EmailPasswordResetTemplate = "EmailPasswordReset"; + public const string KavitaPlusDebugTemplate = "KavitaPlusDebug"; + + public EmailService(ILogger logger, IUnitOfWork unitOfWork, IDirectoryService directoryService, + IHostEnvironment environment, ILocalizationService localizationService) { _logger = logger; _unitOfWork = unitOfWork; _directoryService = directoryService; _environment = environment; + _localizationService = localizationService; } /// @@ -75,9 +99,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; } @@ -92,12 +125,13 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = "Kavita - Email Test", - Body = UpdatePlaceHolders(await GetEmailBody("EmailTest"), placeholders), + Template = EmailTestTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailTestTemplate), placeholders), Preheader = "Kavita - Email Test", ToEmails = new List() { adminEmail - } + }, }; await SendEmail(emailOptions); @@ -127,7 +161,8 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailChange"), placeholders), + Template = EmailChangeTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailChangeTemplate), placeholders), Preheader = UpdatePlaceHolders("Your email has been changed on {{InvitingUser}}'s Server", placeholders), ToEmails = new List() { @@ -143,9 +178,9 @@ public class EmailService : IEmailService /// /// /// - public bool IsValidEmail(string email) + public bool IsValidEmail(string? email) { - return new EmailAddressAttribute().IsValid(email); + return !string.IsNullOrEmpty(email) && new EmailAddressAttribute().IsValid(email); } public async Task GenerateEmailLink(HttpRequest request, string token, string routePart, string email, bool withHost = true) @@ -168,6 +203,100 @@ public class EmailService : IEmailService .Replace("//", "/"); } + public async Task SendTokenExpiredEmail(int userId, ScrobbleProvider provider) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user == null || !IsValidEmail(user.Email) || !settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{UserName}}", user.UserName!), + new ("{{Provider}}", provider.ToDescription()), + new ("{{Link}}", $"{settings.HostName}/settings#account" ), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita - Your {{Provider}} token has expired and scrobbling events have stopped", placeholders), + Template = TokenExpirationTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(TokenExpirationTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita - Your {{Provider}} token has expired and scrobbling events have stopped", placeholders), + ToEmails = new List() + { + user.Email + } + }; + + await SendEmail(emailOptions); + + return true; + } + + public async Task SendTokenExpiringSoonEmail(int userId, ScrobbleProvider provider) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (user == null || !IsValidEmail(user.Email) || !settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{UserName}}", user.UserName!), + new ("{{Provider}}", provider.ToDescription()), + new ("{{Link}}", $"{settings.HostName}/settings#account" ), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita - Your {{Provider}} token will expire soon!", placeholders), + Template = TokenExpiringSoonTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(TokenExpiringSoonTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita - Your {{Provider}} token will expire soon!", placeholders), + ToEmails = new List() + { + user.Email + } + }; + + await SendEmail(emailOptions); + + return true; + } + + /// + /// Sends information about Kavita install for Kavita+ registration + /// + /// Users in China can have issues subscribing, this flow will allow me to register their instance on their behalf + /// + public async Task SendKavitaPlusDebug() + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.IsEmailSetup()) return false; + + var placeholders = new List> + { + new ("{{InstallId}}", HashUtil.ServerToken()), + new ("{{Build}}", BuildInfo.Version.ToString()), + }; + + var emailOptions = new EmailOptionsDto() + { + Subject = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + Template = KavitaPlusDebugTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(KavitaPlusDebugTemplate), placeholders), + Preheader = UpdatePlaceHolders("Kavita+: A User needs manual registration", placeholders), + ToEmails = + [ + // My kavita email + Encoding.UTF8.GetString(Convert.FromBase64String("a2F2aXRhcmVhZGVyQGdtYWlsLmNvbQ==")) + ] + }; + + await SendEmail(emailOptions); + + return true; + } + /// /// Sends an invite email to a user to setup their account /// @@ -183,7 +312,8 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailConfirm"), placeholders), + Template = EmailConfirmTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailConfirmTemplate), placeholders), Preheader = UpdatePlaceHolders("You've been invited to join {{InvitingUser}}'s Server", placeholders), ToEmails = new List() { @@ -209,12 +339,13 @@ public class EmailService : IEmailService var emailOptions = new EmailOptionsDto() { Subject = UpdatePlaceHolders("A password reset has been requested", placeholders), - Body = UpdatePlaceHolders(await GetEmailBody("EmailPasswordReset"), placeholders), - Preheader = "A password reset has been requested", - ToEmails = new List() - { + Template = EmailPasswordResetTemplate, + Body = UpdatePlaceHolders(await GetEmailBody(EmailPasswordResetTemplate), placeholders), + Preheader = "Email confirmation is required for continued access. Click the button to confirm your email.", + ToEmails = + [ dto.EmailAddress - } + ] }; await SendEmail(emailOptions); @@ -230,11 +361,9 @@ public class EmailService : IEmailService { Subject = "Send file from Kavita", Preheader = "File(s) sent from Kavita", - ToEmails = new List() - { - data.DestinationEmail - }, - Body = await GetEmailBody("SendToDevice"), + ToEmails = [data.DestinationEmail], + Template = SendToDeviceTemplate, + Body = await GetEmailBody(SendToDeviceTemplate), Attachments = data.FilePaths.ToList() }; @@ -265,9 +394,21 @@ public class EmailService : IEmailService if (userEmailOptions.Attachments != null) { - foreach (var attachment in userEmailOptions.Attachments) + foreach (var attachmentPath in userEmailOptions.Attachments) { - await body.Attachments.AddAsync(attachment); + var mimeType = MimeTypeMap.GetMimeType(attachmentPath) ?? "application/octet-stream"; + var mediaType = mimeType.Split('/')[0]; + var mediaSubtype = mimeType.Split('/')[1]; + + var attachment = new MimePart(mediaType, mediaSubtype) + { + Content = new MimeContent(File.OpenRead(attachmentPath)), + ContentDisposition = new ContentDisposition(ContentDisposition.Attachment), + ContentTransferEncoding = ContentEncoding.Base64, + FileName = Path.GetFileName(attachmentPath) + }; + + body.Attachments.Add(attachment); } } @@ -290,21 +431,66 @@ public class EmailService : IEmailService ServicePointManager.SecurityProtocol = SecurityProtocolType.SystemDefault; + var emailAddress = userEmailOptions.ToEmails[0]; + AppUser? user; + if (userEmailOptions.Template == SendToDeviceTemplate) + { + user = await _unitOfWork.UserRepository.GetUserByDeviceEmail(emailAddress); + } + else + { + user = await _unitOfWork.UserRepository.GetUserByEmailAsync(emailAddress); + } + + try { await smtpClient.SendAsync(email); + if (user != null) + { + await LogEmailHistory(user.Id, userEmailOptions.Template, userEmailOptions.Subject, userEmailOptions.Body, "Sent"); + } } catch (Exception ex) { _logger.LogError(ex, "There was an issue sending the email"); + + if (user != null) + { + await LogEmailHistory(user.Id, userEmailOptions.Template, userEmailOptions.Subject, userEmailOptions.Body, "Failed", ex.Message); + } + _logger.LogError("Could not find user on file for email, {Template} email was not sent and not recorded into history table", userEmailOptions.Template); + throw; } finally { await smtpClient.DisconnectAsync(true); + } } + /// + /// Logs email history for the specified user. + /// + private async Task LogEmailHistory(int appUserId, string emailTemplate, string subject, string body, string deliveryStatus, string? errorMessage = null) + { + var emailHistory = new EmailHistory + { + AppUserId = appUserId, + EmailTemplate = emailTemplate, + Sent = deliveryStatus == "Sent", + Body = body, + Subject = subject, + SendDate = DateTime.UtcNow, + DeliveryStatus = deliveryStatus, + ErrorMessage = errorMessage + }; + + _unitOfWork.DataContext.EmailHistory.Add(emailHistory); + await _unitOfWork.CommitAsync(); + } + private async Task GetTemplatePath(string templateName) { if ((await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).SmtpConfig.CustomizedTemplates) diff --git a/API/Services/FileService.cs b/API/Services/FileService.cs index a4194b820..2cb34c601 100644 --- a/API/Services/FileService.cs +++ b/API/Services/FileService.cs @@ -1,5 +1,10 @@ using System; +using System.IO; using System.IO.Abstractions; +using System.Runtime.Intrinsics.Arm; +using System.Security.Cryptography; +using System.Text; +using System.Text.Unicode; using API.Extensions; namespace API.Services; @@ -9,6 +14,7 @@ public interface IFileService IFileSystem GetFileSystem(); bool HasFileBeenModifiedSince(string filePath, DateTime time); bool Exists(string filePath); + bool ValidateSha(string filepath, string sha); } public class FileService : IFileService @@ -43,4 +49,27 @@ public class FileService : IFileService { return _fileSystem.File.Exists(filePath); } + + /// + /// Validates the Sha256 hash matches + /// + /// + /// + /// + public bool ValidateSha(string filepath, string sha) + { + if (!Exists(filepath)) return false; + if (string.IsNullOrEmpty(sha)) throw new ArgumentException("Sha cannot be null"); + + using var fs = _fileSystem.File.OpenRead(filepath); + fs.Position = 0; + + using var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + var content = reader.ReadToEnd(); + + // Compute SHA hash + var checksum = SHA256.HashData(Encoding.UTF8.GetBytes(content)); + + return Convert.ToHexString(checksum).Equals(sha); + } } diff --git a/API/Services/HostedServices/StartupTasksHostedService.cs b/API/Services/HostedServices/StartupTasksHostedService.cs index 37b6effee..145fb8e2b 100644 --- a/API/Services/HostedServices/StartupTasksHostedService.cs +++ b/API/Services/HostedServices/StartupTasksHostedService.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using API.Data; using API.Services.Tasks.Scanner; +using Hangfire; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -45,7 +46,8 @@ public class StartupTasksHostedService : IHostedService if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching) { var libraryWatcher = scope.ServiceProvider.GetRequiredService(); - await libraryWatcher.StartWatching(); + // Push this off for a bit for people with massive libraries, as it can take up to 45 mins and blocks the thread + BackgroundJob.Enqueue(() => libraryWatcher.StartWatching()); } } catch (Exception) diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 36ba07ddc..0255b785d 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -2,17 +2,19 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Numerics; using System.Threading.Tasks; -using API.Constants; +using API.DTOs; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Extensions; -using EasyCaching.Core; -using Flurl; -using Flurl.Http; -using HtmlAgilityPack; -using Kavita.Common; using Microsoft.Extensions.Logging; using NetVips; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Processing.Processors.Quantization; +using Color = System.Drawing.Color; using Image = NetVips.Image; namespace API.Services; @@ -50,6 +52,7 @@ public interface IImageService /// /// string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); + /// /// Converts the passed image to encoding and outputs it in the same directory /// @@ -59,20 +62,23 @@ public interface IImageService /// File of written encoded image Task ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat); Task IsImage(string filePath); - Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); + void UpdateColorScape(IHasCoverImage entity); } public class ImageService : IImageService { - public const string Name = "BookmarkService"; + public const string Name = "ImageService"; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly IEasyCachingProviderFactory _cacheFactory; + public const string ChapterCoverImageRegex = @"v\d+_c\d+"; public const string SeriesCoverImageRegex = @"series\d+"; public const string CollectionTagCoverImageRegex = @"tag\d+"; public const string ReadingListCoverImageRegex = @"readinglist\d+"; + private const double WhiteThreshold = 0.95; // Colors with lightness above this are considered too close to white + private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black + /// /// Width of the Thumbnail generation @@ -88,26 +94,10 @@ public class ImageService : IImageService public const int LibraryThumbnailWidth = 32; - private static readonly string[] ValidIconRelations = { - "icon", - "apple-touch-icon", - "apple-touch-icon-precomposed", - "apple-touch-icon icon-precomposed" // ComicVine has it combined - }; - - /// - /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) - /// - private static readonly IDictionary FaviconUrlMapper = new Dictionary - { - ["https://app.plex.tv"] = "https://plex.tv" - }; - - public ImageService(ILogger logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory) + public ImageService(ILogger logger, IDirectoryService directoryService) { _logger = logger; _directoryService = directoryService; - _cacheFactory = cacheFactory; } public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1) @@ -125,14 +115,90 @@ public class ImageService : IImageService } } + /// + /// Tries to determine if there is a better mode for resizing + /// + /// + /// + /// + /// + public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight) + { + try + { + if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + { + return Enums.Size.Force; + } + } + catch (Exception) + { + /* Swallow */ + } + + return Enums.Size.Both; + } + + public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight) + { + try + { + if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + { + return null; + } + } catch (Exception) + { + /* Swallow */ + return null; + } + + return Enums.Interesting.Attention; + } + + public static bool WillScaleWell(Image sourceImage, int targetWidth, int targetHeight, double tolerance = 0.1) + { + // Calculate the aspect ratios + var sourceAspectRatio = (double) sourceImage.Width / sourceImage.Height; + var targetAspectRatio = (double) targetWidth / targetHeight; + + // Compare aspect ratios + if (Math.Abs(sourceAspectRatio - targetAspectRatio) > tolerance) + { + return false; // Aspect ratios differ significantly + } + + // Calculate scaling factors + var widthScaleFactor = (double) targetWidth / sourceImage.Width; + var heightScaleFactor = (double) targetHeight / sourceImage.Height; + + // Check resolution quality (example thresholds) + if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0) + { + return false; // Scaling factor too large + } + + return true; // Image will scale well + } + + private static bool IsLikelyWideImage(int width, int height) + { + var aspectRatio = (double) width / height; + return aspectRatio > 1.25; + } + public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size) { if (string.IsNullOrEmpty(path)) return string.Empty; try { - var dims = size.GetDimensions(); - using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force); + var (width, height) = size.GetDimensions(); + using var sourceImage = Image.NewFromFile(path, false, Enums.Access.SequentialUnbuffered); + + using var thumbnail = Image.Thumbnail(path, width, height: height, + size: GetSizeForDimensions(sourceImage, width, height), + crop: GetCropForDimensions(sourceImage, width, height)); var filename = fileName + encodeFormat.GetExtension(); thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); return filename; @@ -147,7 +213,7 @@ public class ImageService : IImageService /// /// Creates a thumbnail out of a memory stream and saves to with the passed - /// fileName and .png extension. + /// fileName and the appropriate extension. /// /// Stream to write to disk. Ensure this is rewinded. /// filename to save as without extension @@ -156,22 +222,54 @@ public class ImageService : IImageService /// File name with extension of the file. This will always write to public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - var dims = size.GetDimensions(); - using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force); + var (targetWidth, targetHeight) = size.GetDimensions(); + if (stream.CanSeek) stream.Position = 0; + using var sourceImage = Image.NewFromStream(stream); + + var scalingSize = GetSizeForDimensions(sourceImage, targetWidth, targetHeight); + var scalingCrop = GetCropForDimensions(sourceImage, targetWidth, targetHeight); + + using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight, + size: scalingSize, + crop: scalingCrop); + var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); + try { _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); } catch (Exception) {/* Swallow exception */} - thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); - return filename; + + try + { + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + + return filename; + } + catch (VipsException) + { + // NetVips Issue: https://github.com/kleisauke/net-vips/issues/234 + // Saving pdf covers from a stream can fail, so revert to old code + + if (stream.CanSeek) stream.Position = 0; + using var thumbnail2 = Image.ThumbnailStream(stream, targetWidth, height: targetHeight, + size: scalingSize, + crop: scalingCrop); + thumbnail2.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename)); + + return filename; + } } public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - var dims = size.GetDimensions(); - using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force); + var (width, height) = size.GetDimensions(); + using var sourceImage = Image.NewFromFile(sourceFile, false, Enums.Access.SequentialUnbuffered); + + using var thumbnail = Image.Thumbnail(sourceFile, width, height: height, + size: GetSizeForDimensions(sourceImage, width, height), + crop: GetCropForDimensions(sourceImage, width, height)); var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); try @@ -215,132 +313,274 @@ public class ImageService : IImageService return false; } - public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) + + + private static (Vector3?, Vector3?) GetPrimarySecondaryColors(string imagePath) { - // Parse the URL to get the domain (including subdomain) - var uri = new Uri(url); - var domain = uri.Host.Replace(Environment.NewLine, string.Empty); - var baseUrl = uri.Scheme + "://" + uri.Host; + using var image = Image.NewFromFile(imagePath); + // Resize the image to speed up processing + var resizedImage = image.Resize(0.1); + + var processedImage = PreProcessImage(resizedImage); - var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon); - var res = await provider.GetAsync(baseUrl); - if (res.HasValue) + // Convert image to RGB array + var pixels = processedImage.WriteToMemory().ToArray(); + + // Convert to list of Vector3 (RGB) + var rgbPixels = new List(); + for (var i = 0; i < pixels.Length - 2; i += 3) { - _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl); - throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check"); + rgbPixels.Add(new Vector3(pixels[i], pixels[i + 1], pixels[i + 2])); } - await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10)); - if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) + // Perform k-means clustering + var clusters = KMeansClustering(rgbPixels, 4); + + var sorted = SortByVibrancy(clusters); + + // Ensure white and black are not selected as primary/secondary colors + sorted = sorted.Where(c => !IsCloseToWhiteOrBlack(c)).ToList(); + + if (sorted.Count >= 2) { - url = value; + return (sorted[0], sorted[1]); + } + if (sorted.Count == 1) + { + return (sorted[0], null); } - var correctSizeLink = string.Empty; - - try - { - var htmlContent = url.GetStringAsync().Result; - var htmlDocument = new HtmlDocument(); - htmlDocument.LoadHtml(htmlContent); - var pngLinks = htmlDocument.DocumentNode.Descendants("link") - .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) - .Select(link => link.GetAttributeValue("href", string.Empty)) - .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase)) - .ToList(); - - correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain); - } - - try - { - if (string.IsNullOrEmpty(correctSizeLink)) - { - correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl); - } - if (string.IsNullOrEmpty(correctSizeLink)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl}"); - } - - var finalUrl = correctSizeLink; - - // If starts with //, it's coming usually from an offsite cdn - if (correctSizeLink.StartsWith("//")) - { - finalUrl = "https:" + correctSizeLink; - } - else if (!correctSizeLink.StartsWith(uri.Scheme)) - { - finalUrl = Url.Combine(baseUrl, correctSizeLink); - } - - _logger.LogTrace("Fetching favicon from {Url}", finalUrl); - // Download the favicon.ico file using Flurl - var faviconStream = await finalUrl - .AllowHttpStatus("2xx,304") - .GetStreamAsync(); - - // Create the destination file path - using var image = Image.PngloadStream(faviconStream); - var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); - switch (encodeFormat) - { - case EncodeFormat.PNG: - image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.WEBP: - image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - case EncodeFormat.AVIF: - image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); - } - - - _logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain); - return filename; - }catch (Exception ex) - { - _logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain); - throw; - } + return (null, null); } - private static string FallbackToKavitaReaderFavicon(string baseUrl) + private static (Vector3?, Vector3?) GetPrimaryColorSharp(string imagePath) { - var correctSizeLink = string.Empty; - var allOverrides = "https://kavitareader.com/assets/favicons/urls.txt".GetStringAsync().Result; - if (!string.IsNullOrEmpty(allOverrides)) - { - var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); - var externalFile = allOverrides - .Split("\n") - .FirstOrDefault(url => - cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || - cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) - )); - if (string.IsNullOrEmpty(externalFile)) - { - throw new KavitaException($"Could not grab favicon from {baseUrl}"); - } + using var image = SixLabors.ImageSharp.Image.Load(imagePath); - correctSizeLink = "https://kavitareader.com/assets/favicons/" + externalFile; + image.Mutate( + x => x + // Scale the image down preserving the aspect ratio. This will speed up quantization. + // We use nearest neighbor as it will be the fastest approach. + .Resize(new ResizeOptions() { Sampler = KnownResamplers.NearestNeighbor, Size = new SixLabors.ImageSharp.Size(100, 0) }) + + // Reduce the color palette to 1 color without dithering. + .Quantize(new OctreeQuantizer(new QuantizerOptions { MaxColors = 4 }))); + + Rgb24 dominantColor = image[0, 0]; + + // This will give you a dominant color in HEX format i.e #5E35B1FF + return (new Vector3(dominantColor.R, dominantColor.G, dominantColor.B), new Vector3(dominantColor.R, dominantColor.G, dominantColor.B)); + } + + private static Image PreProcessImage(Image image) + { + return image; + // Create a mask for white and black pixels + var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100); + var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100); + + // Create a replacement color (e.g., medium gray) + var replacementColor = new[] { 240.0, 240.0, 240.0 }; + + // Apply the masks to replace white and black pixels + var processedImage = image.Copy(); + processedImage = processedImage.Ifthenelse(whiteMask, replacementColor); + //processedImage = processedImage.Ifthenelse(blackMask, replacementColor); + + return processedImage; + } + + private static Dictionary GenerateColorHistogram(Image image) + { + var pixels = image.WriteToMemory().ToArray(); + var histogram = new Dictionary(); + + for (var i = 0; i < pixels.Length; i += 3) + { + var color = new Vector3(pixels[i], pixels[i + 1], pixels[i + 2]); + if (!histogram.TryAdd(color, 1)) + { + histogram[color]++; + } } - return correctSizeLink; + return histogram; } + private static bool IsColorCloseToWhiteOrBlack(Vector3 color) + { + var (_, _, lightness) = RgbToHsl(color); + return lightness is > WhiteThreshold or < BlackThreshold; + } + + private static List KMeansClustering(List points, int k, int maxIterations = 100) + { + var random = new Random(); + var centroids = points.OrderBy(x => random.Next()).Take(k).ToList(); + + for (var i = 0; i < maxIterations; i++) + { + var clusters = new List[k]; + for (var j = 0; j < k; j++) + { + clusters[j] = []; + } + + foreach (var point in points) + { + var nearestCentroidIndex = centroids + .Select((centroid, index) => new { Index = index, Distance = Vector3.DistanceSquared(centroid, point) }) + .OrderBy(x => x.Distance) + .First().Index; + clusters[nearestCentroidIndex].Add(point); + } + + var newCentroids = clusters.Select(cluster => + cluster.Count != 0 ? new Vector3( + cluster.Average(p => p.X), + cluster.Average(p => p.Y), + cluster.Average(p => p.Z) + ) : Vector3.Zero + ).ToList(); + + if (centroids.SequenceEqual(newCentroids)) + break; + + centroids = newCentroids; + } + + return centroids; + } + + public static List SortByBrightness(List colors) + { + return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList(); + } + + private static List SortByVibrancy(List colors) + { + return colors.OrderByDescending(c => + { + var max = Math.Max(c.X, Math.Max(c.Y, c.Z)); + var min = Math.Min(c.X, Math.Min(c.Y, c.Z)); + return (max - min) / max; + }).ToList(); + } + + private static bool IsCloseToWhiteOrBlack(Vector3 color) + { + var threshold = 30; + return (color.X > 255 - threshold && color.Y > 255 - threshold && color.Z > 255 - threshold) || + (color.X < threshold && color.Y < threshold && color.Z < threshold); + } + + private static string RgbToHex(Vector3 color) + { + return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}"; + } + + private static Vector3 GetComplementaryColor(Vector3 color) + { + // Convert RGB to HSL + var (h, s, l) = RgbToHsl(color); + + // Rotate hue by 180 degrees + h = (h + 180) % 360; + + // Convert back to RGB + return HslToRgb(h, s, l); + } + + private static (double H, double S, double L) RgbToHsl(Vector3 rgb) + { + double r = rgb.X / 255; + double g = rgb.Y / 255; + double b = rgb.Z / 255; + + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + var diff = max - min; + + double h = 0; + double s = 0; + var l = (max + min) / 2; + + if (Math.Abs(diff) > 0.00001) + { + s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min); + + if (max == r) + h = (g - b) / diff + (g < b ? 6 : 0); + else if (max == g) + h = (b - r) / diff + 2; + else if (max == b) + h = (r - g) / diff + 4; + + h *= 60; + } + + return (h, s, l); + } + + private static Vector3 HslToRgb(double h, double s, double l) + { + double r, g, b; + + if (Math.Abs(s) < 0.00001) + { + r = g = b = l; + } + else + { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = HueToRgb(p, q, h + 120); + g = HueToRgb(p, q, h); + b = HueToRgb(p, q, h - 120); + } + + return new Vector3((float)(r * 255), (float)(g * 255), (float)(b * 255)); + } + + private static double HueToRgb(double p, double q, double t) + { + if (t < 0) t += 360; + if (t > 360) t -= 360; + return t switch + { + < 60 => p + (q - p) * t / 60, + < 180 => q, + < 240 => p + (q - p) * (240 - t) / 60, + _ => p + }; + } + + /// + /// Generates the Primary and Secondary colors from a file + /// + /// This may use a second most common color or a complementary color. It's up to implemenation to choose what's best + /// + /// + public static ColorScape CalculateColorScape(string sourceFile) + { + if (!File.Exists(sourceFile)) return new ColorScape() {Primary = null, Secondary = null}; + + var colors = GetPrimarySecondaryColors(sourceFile); + + return new ColorScape() + { + Primary = colors.Item1 == null ? null : RgbToHex(colors.Item1.Value), + Secondary = colors.Item2 == null ? null : RgbToHex(colors.Item2.Value) + }; + } + + + /// public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth) { + // TODO: This code has no concept of cropping nor Thumbnail Size try { using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth); @@ -356,6 +596,7 @@ public class ImageService : IImageService return string.Empty; } + /// /// Returns the name format for a chapter cover image /// @@ -367,6 +608,16 @@ public class ImageService : IImageService return $"v{volumeId}_c{chapterId}"; } + /// + /// Returns the name format for a volume cover image (custom) + /// + /// + /// + public static string GetVolumeFormat(int volumeId) + { + return $"v{volumeId}"; + } + /// /// Returns the name format for a library cover image /// @@ -418,15 +669,30 @@ public class ImageService : IImageService return $"thumbnail{chapterId}"; } + /// + /// Returns the name format for a person cover + /// + /// + /// + public static string GetPersonFormat(int personId) + { + return $"person{personId}"; + } + public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat) { return $"{new Uri(url).Host.Replace("www.", string.Empty)}{encodeFormat.GetExtension()}"; } + public static string GetPublisherFormat(string publisher, EncodeFormat encodeFormat) + { + return $"{publisher}{encodeFormat.GetExtension()}"; + } + public static void CreateMergedImage(IList coverImages, CoverImageSize size, string dest) { - var dims = size.GetDimensions(); + var (width, height) = size.GetDimensions(); int rows, cols; if (coverImages.Count == 1) @@ -439,19 +705,14 @@ public class ImageService : IImageService rows = 1; cols = 2; } - else if (coverImages.Count == 3) - { - rows = 2; - cols = 2; - } else { - // Default to 2x2 layout for more than 3 images rows = 2; cols = 2; } - var image = Image.Black(dims.Width, dims.Height); + + var image = Image.Black(width, height); var thumbnailWidth = image.Width / cols; var thumbnailHeight = image.Height / rows; @@ -479,4 +740,42 @@ public class ImageService : IImageService image.WriteToFile(dest); } + + public void UpdateColorScape(IHasCoverImage entity) + { + var colors = CalculateColorScape( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, entity.CoverImage)); + entity.PrimaryColor = colors.Primary; + entity.SecondaryColor = colors.Secondary; + } + + + public static Color HexToRgb(string? hex) + { + if (string.IsNullOrEmpty(hex)) throw new ArgumentException("Hex cannot be null"); + + // Remove the leading '#' if present + hex = hex.TrimStart('#'); + + // Ensure the hex string is valid + if (hex.Length != 6 && hex.Length != 3) + { + throw new ArgumentException("Hex string should be 6 or 3 characters long."); + } + + if (hex.Length == 3) + { + // Expand shorthand notation to full form (e.g., "abc" -> "aabbcc") + hex = string.Concat(hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]); + } + + // Parse the hex string into RGB components + var r = Convert.ToInt32(hex.Substring(0, 2), 16); + var g = Convert.ToInt32(hex.Substring(2, 2), 16); + var b = Convert.ToInt32(hex.Substring(4, 2), 16); + + return Color.FromArgb(r, g, b); + } + + } diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index ab3ad3d89..7db35bb8e 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; using API.Data; +using API.DTOs; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Hosting; @@ -11,11 +12,13 @@ namespace API.Services; #nullable enable + + public interface ILocalizationService { Task Get(string locale, string key, params object[] args); Task Translate(int userId, string key, params object[] args); - IEnumerable GetLocales(); + IEnumerable GetLocales(); } public class LocalizationService : ILocalizationService @@ -134,14 +137,260 @@ public class LocalizationService : ILocalizationService /// Returns all available locales that exist on both the Frontend and the Backend /// /// - public IEnumerable GetLocales() + public IEnumerable GetLocales() { var uiLanguages = _directoryService - .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); + .GetFilesWithExtension(_directoryService.FileSystem.Path.GetFullPath(_localizationDirectoryUi), @"\.json"); var backendLanguages = _directoryService - .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json") - .Select(f => _directoryService.FileSystem.Path.GetFileName(f).Replace(".json", string.Empty)); - return uiLanguages.Intersect(backendLanguages).Distinct(); + .GetFilesWithExtension(_directoryService.LocalizationDirectory, @"\.json"); + + var locales = new Dictionary(); + var localeCounts = new Dictionary>(); // fileName -> (nonEmptyValues, totalKeys) + + // First pass: collect all files and count non-empty strings + + // Process UI language files + foreach (var file in uiLanguages) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file); + var fileContent = _directoryService.FileSystem.File.ReadAllText(file); + var hash = ComputeHash(fileContent); + + var counts = CalculateNonEmptyStrings(fileContent); + + if (localeCounts.TryGetValue(fileName, out var existingCount)) + { + // Update existing counts + localeCounts[fileName] = Tuple.Create( + existingCount.Item1 + counts.Item1, + existingCount.Item2 + counts.Item2 + ); + } + else + { + // Add new counts + localeCounts[fileName] = counts; + } + + if (!locales.TryGetValue(fileName, out var locale)) + { + locales[fileName] = new KavitaLocale + { + FileName = fileName, + RenderName = GetDisplayName(fileName), + TranslationCompletion = 0, // Will be calculated later + IsRtL = IsRightToLeft(fileName), + Hash = hash + }; + } + else + { + // Update existing locale hash + locale.Hash = CombineHashes(locale.Hash, hash); + } + } + + // Process backend language files + foreach (var file in backendLanguages) + { + var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(file); + var fileContent = _directoryService.FileSystem.File.ReadAllText(file); + var hash = ComputeHash(fileContent); + + var counts = CalculateNonEmptyStrings(fileContent); + + if (localeCounts.TryGetValue(fileName, out var existingCount)) + { + // Update existing counts + localeCounts[fileName] = Tuple.Create( + existingCount.Item1 + counts.Item1, + existingCount.Item2 + counts.Item2 + ); + } + else + { + // Add new counts + localeCounts[fileName] = counts; + } + + if (!locales.TryGetValue(fileName, out var locale)) + { + locales[fileName] = new KavitaLocale + { + FileName = fileName, + RenderName = GetDisplayName(fileName), + TranslationCompletion = 0, // Will be calculated later + IsRtL = IsRightToLeft(fileName), + Hash = hash + }; + } + else + { + // Update existing locale hash + locale.Hash = CombineHashes(locale.Hash, hash); + } + } + + // Second pass: calculate completion percentages based on English total + if (localeCounts.TryGetValue("en", out var englishCounts) && englishCounts.Item2 > 0) + { + var englishTotalKeys = englishCounts.Item2; + + foreach (var locale in locales.Values) + { + if (localeCounts.TryGetValue(locale.FileName, out var counts)) + { + // Calculate percentage based on English total keys + locale.TranslationCompletion = (float)counts.Item1 / englishTotalKeys * 100; + } + } + } + + return locales.Values; + } + + // Helper methods that would need to be implemented + private static string ComputeHash(string content) + { + // Implement a hashing algorithm (e.g., SHA256, MD5) to generate a hash for the content + using var md5 = System.Security.Cryptography.MD5.Create(); + var inputBytes = System.Text.Encoding.UTF8.GetBytes(content); + var hashBytes = md5.ComputeHash(inputBytes); + return Convert.ToBase64String(hashBytes); + } + + private static string CombineHashes(string hash1, string hash2) + { + // Combine two hashes, possibly by concatenating and rehashing + return ComputeHash(hash1 + hash2); + } + + private static string GetDisplayName(string fileName) + { + // Map the filename to a human-readable display name + // This could use a lookup table or follow a naming convention + try + { + var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-')); + return cultureInfo.NativeName; + } + catch + { + // Fall back to the file name if the culture isn't recognized + return fileName; + } + } + + private static bool IsRightToLeft(string fileName) + { + // Determine if the language is right-to-left + try + { + var cultureInfo = new System.Globalization.CultureInfo(fileName); + return cultureInfo.TextInfo.IsRightToLeft; + } + catch + { + return false; // Default to left-to-right + } + } + + private static float CalculateTranslationCompletion(string fileContent) + { + try + { + var jsonObject = System.Text.Json.JsonDocument.Parse(fileContent); + + int totalKeys = 0; + int nonEmptyValues = 0; + + // Count all keys and non-empty values + CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues); + + return totalKeys > 0 ? (nonEmptyValues * 1f) / totalKeys * 100 : 0; + } + catch (Exception ex) + { + // Consider logging the exception + return 0; // Return 0% completion if there's an error parsing + } + } + private static Tuple CalculateNonEmptyStrings(string fileContent) + { + try + { + var jsonObject = JsonDocument.Parse(fileContent); + + var totalKeys = 0; + var nonEmptyValues = 0; + + // Count all keys and non-empty values + CountNonEmptyValues(jsonObject.RootElement, ref totalKeys, ref nonEmptyValues); + + return Tuple.Create(nonEmptyValues, totalKeys); + } + catch (Exception) + { + // Consider logging the exception + return Tuple.Create(0, 0); // Return 0% completion if there's an error parsing + } + } + + private static void CountNonEmptyValues(JsonElement element, ref int totalKeys, ref int nonEmptyValues) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Value.ValueKind == System.Text.Json.JsonValueKind.String) + { + totalKeys++; + var value = property.Value.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + nonEmptyValues++; + } + } + else + { + // Recursively process nested objects + CountNonEmptyValues(property.Value, ref totalKeys, ref nonEmptyValues); + } + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CountNonEmptyValues(item, ref totalKeys, ref nonEmptyValues); + } + } + } + + private void CountEntries(System.Text.Json.JsonElement element, ref int total, ref int translated) + { + if (element.ValueKind == System.Text.Json.JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + CountEntries(property.Value, ref total, ref translated); + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + CountEntries(item, ref total, ref translated); + } + } + else if (element.ValueKind == System.Text.Json.JsonValueKind.String) + { + total++; + string value = element.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + translated++; + } + } } } diff --git a/API/Services/MediaConversionService.cs b/API/Services/MediaConversionService.cs index 095509676..fc3e5f318 100644 --- a/API/Services/MediaConversionService.cs +++ b/API/Services/MediaConversionService.cs @@ -197,7 +197,7 @@ public class MediaConversionService : IMediaConversionService foreach (var volume in nonCustomOrConvertedVolumeCovers) { if (string.IsNullOrEmpty(volume.CoverImage)) continue; - volume.CoverImage = volume.Chapters.MinBy(x => x.Number.AsDouble(), ChapterSortComparerZeroFirst.Default)?.CoverImage; + volume.CoverImage = volume.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage; _unitOfWork.VolumeRepository.Update(volume); await _unitOfWork.CommitAsync(); } @@ -222,6 +222,10 @@ public class MediaConversionService : IMediaConversionService { if (string.IsNullOrEmpty(series.CoverImage)) continue; series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } _unitOfWork.SeriesRepository.Update(series); await _unitOfWork.CommitAsync(); } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index f7ba8a4d7..e0e86f4dc 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -7,6 +7,7 @@ using API.Comparators; using API.Data; using API.Entities; using API.Entities.Enums; +using API.Entities.Interfaces; using API.Extensions; using API.Helpers; using API.SignalR; @@ -25,19 +26,22 @@ public interface IMetadataService /// [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false); + Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false); /// - /// Performs a forced refresh of cover images just for a series and it's nested entities + /// Performs a forced refresh of cover images just for a series, and it's nested entities /// /// /// /// Overrides any cache logic and forces execution - Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true); - Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false); + Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true, bool forceColorScape = true); + Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false, bool forceColorScape = true); Task RemoveAbandonedMetadataKeys(); } +/// +/// Handles everything around Cover/ColorScape management +/// public class MetadataService : IMetadataService { public const string Name = "MetadataService"; @@ -47,10 +51,13 @@ public class MetadataService : IMetadataService private readonly ICacheHelper _cacheHelper; private readonly IReadingItemService _readingItemService; private readonly IDirectoryService _directoryService; + private readonly IImageService _imageService; private readonly IList _updateEvents = new List(); + public MetadataService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, ICacheHelper cacheHelper, - IReadingItemService readingItemService, IDirectoryService directoryService) + IReadingItemService readingItemService, IDirectoryService directoryService, + IImageService imageService) { _unitOfWork = unitOfWork; _logger = logger; @@ -58,6 +65,7 @@ public class MetadataService : IMetadataService _cacheHelper = cacheHelper; _readingItemService = readingItemService; _directoryService = directoryService; + _imageService = imageService; } /// @@ -66,24 +74,40 @@ public class MetadataService : IMetadataService /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image /// Convert image to Encoding Format when extracting the cover - private Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) + /// Force colorscape gen + private bool UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) { + if (chapter == null) return false; + var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null) return Task.FromResult(false); + if (firstFile == null) return false; - if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), + if (!_cacheHelper.ShouldUpdateCoverImage( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) - return Task.FromResult(false); + { + if (NeedsColorSpace(chapter, forceColorScape)) + { + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); + } + return false; + } _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format, encodeFormat, coverImageSize); + + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter)); - return Task.FromResult(true); + return true; } private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate) @@ -94,27 +118,60 @@ public class MetadataService : IMetadataService firstFile.UpdateLastModified(); } + private static bool NeedsColorSpace(IHasCoverImage? entity, bool force) + { + if (entity == null) return false; + if (force) return true; + + return !string.IsNullOrEmpty(entity.CoverImage) && + (string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor)); + } + + + /// /// Updates the cover image for a Volume /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private Task UpdateVolumeCoverImage(Volume? volume, bool forceUpdate) + /// Force updating colorscape + private bool UpdateVolumeCoverImage(Volume? volume, bool forceUpdate, bool forceColorScape = false) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null || !_cacheHelper.ShouldUpdateCoverImage( + if (volume == null) return false; + + if (!_cacheHelper.ShouldUpdateCoverImage( _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), - null, volume.Created, forceUpdate)) return Task.FromResult(false); + null, volume.Created, forceUpdate)) + { + if (NeedsColorSpace(volume, forceColorScape)) + { + _imageService.UpdateColorScape(volume); + _unitOfWork.VolumeRepository.Update(volume); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); + } + return false; + } + if (!volume.CoverImageLocked) + { + // For cover selection, chapters need to try for issue 1 first, then fallback to first sort order + volume.Chapters ??= new List(); - volume.Chapters ??= new List(); - var firstChapter = volume.Chapters.MinBy(x => x.Number.AsDouble(), ChapterSortComparerZeroFirst.Default); - if (firstChapter == null) return Task.FromResult(false); + var firstChapter = volume.Chapters.FirstOrDefault(x => x.MinNumber.Is(1f)); + if (firstChapter == null) + { + firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default); + if (firstChapter == null) return false; + } + + volume.CoverImage = firstChapter.CoverImage; + } + _imageService.UpdateColorScape(volume); - volume.CoverImage = firstChapter.CoverImage; _updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume)); - return Task.FromResult(true); + return true; } /// @@ -122,19 +179,34 @@ public class MetadataService : IMetadataService /// /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate) + private void UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false) { - if (series == null) return Task.CompletedTask; + if (series == null) return; - if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), + if (!_cacheHelper.ShouldUpdateCoverImage( + _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked)) - return Task.CompletedTask; + { + // Check if we don't have a primary/seconary color + if (NeedsColorSpace(series, forceColorScape)) + { + _imageService.UpdateColorScape(series); + _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); + } - series.Volumes ??= new List(); - series.CoverImage = series.GetCoverImage(); // BUG: At this point the volume or chapter hasn't regenerated the cover + return; + } + + series.Volumes ??= []; + series.CoverImage = series.GetCoverImage(); + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null: {SeriesId}", series.Id); + } + + _imageService.UpdateColorScape(series); _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); - return Task.CompletedTask; } @@ -144,7 +216,7 @@ public class MetadataService : IMetadataService /// /// /// - private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) + private void ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false) { _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); try @@ -157,8 +229,8 @@ public class MetadataService : IMetadataService var index = 0; foreach (var chapter in volume.Chapters) { - var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize); - // If cover was update, either the file has changed or first scan and we should force a metadata update + var chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize, forceColorScape); + // If cover was update, either the file has changed or first scan, and we should force a metadata update UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); if (index == 0 && chapterUpdated) { @@ -168,7 +240,7 @@ public class MetadataService : IMetadataService index++; } - var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); + var volumeUpdated = UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape); if (volumeIndex == 0 && volumeUpdated) { firstVolumeUpdated = true; @@ -176,7 +248,7 @@ public class MetadataService : IMetadataService volumeIndex++; } - await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); + UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape); } catch (Exception ex) { @@ -191,9 +263,10 @@ public class MetadataService : IMetadataService /// This can be heavy on memory first run /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + /// Force updating colorscape [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false) + public async Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); if (library == null) return; @@ -241,7 +314,7 @@ public class MetadataService : IMetadataService try { - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize); + ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); } catch (Exception ex) { @@ -271,7 +344,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(); } @@ -282,7 +355,8 @@ public class MetadataService : IMetadataService /// /// /// Overrides any cache logic and forces execution - public async Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true) + /// Will ensure that the colorscape is regenerated + public async Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true, bool forceColorScape = true) { var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); if (series == null) @@ -291,10 +365,12 @@ public class MetadataService : IMetadataService return; } + // TODO: Cache this because it's called a lot during scans var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var encodeFormat = settings.EncodeMediaAs; var coverImageSize = settings.CoverImageSize; - await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate); + + await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate, forceColorScape); } /// @@ -303,13 +379,14 @@ public class MetadataService : IMetadataService /// A full Series, with metadata, chapters, etc /// When saving the file, what encoding should be used /// - public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false) + /// Forces just colorscape generation + public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false, bool forceColorScape = true) { var sw = Stopwatch.StartNew(); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); - await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize); + ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape); if (_unitOfWork.HasChanges()) diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index e0d169f60..aef97bdda 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1,57 +1,43 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.MetadataMatching; using API.Extensions; using API.Helpers; +using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; +using API.SignalR; using AutoMapper; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; namespace API.Services.Plus; #nullable enable -/// -/// Used for matching and fetching metadata on a series -/// -internal class ExternalMetadataIdsDto -{ - public long? MalId { get; set; } - public int? AniListId { get; set; } - public string? SeriesName { get; set; } - public string? LocalizedSeriesName { get; set; } - public MediaFormat? PlusMediaFormat { get; set; } = MediaFormat.Unknown; -} - -internal class SeriesDetailPlusApiDto -{ - public IEnumerable Recommendations { get; set; } - public IEnumerable Reviews { get; set; } - public IEnumerable Ratings { get; set; } - public int? AniListId { get; set; } - public long? MalId { get; set; } -} public interface IExternalMetadataService { Task GetExternalSeriesDetail(int? aniListId, long? malId, int? seriesId); - Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); - Task ForceKavitaPlusRefresh(int seriesId); + Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType); Task FetchExternalDataTask(); /// /// This is an entry point and provides a level of protection against calling upstream API. Will only allow 100 new @@ -59,8 +45,14 @@ public interface IExternalMetadataService /// /// /// - /// - Task GetNewSeriesData(int seriesId, LibraryType libraryType); + /// If the fetch was made + Task FetchSeriesMetadata(int seriesId, LibraryType libraryType); + + Task> GetStacksForUser(int userId); + Task> MatchSeries(MatchSeriesDto dto); + Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId); + Task UpdateSeriesDontMatch(int seriesId, bool dontMatch); + Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId); } public class ExternalMetadataService : IExternalMetadataService @@ -69,27 +61,35 @@ public class ExternalMetadataService : IExternalMetadataService private readonly ILogger _logger; private readonly IMapper _mapper; private readonly ILicenseService _licenseService; + private readonly IScrobblingService _scrobblingService; + private readonly IEventHub _eventHub; + private readonly ICoverDbService _coverDbService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); - public static readonly ImmutableArray NonEligibleLibraryTypes = ImmutableArray.Create(LibraryType.Comic, LibraryType.Book); + public static readonly HashSet NonEligibleLibraryTypes = + [LibraryType.Comic, LibraryType.Book, LibraryType.Image]; private readonly SeriesDetailPlusDto _defaultReturn = new() { + Series = null, Recommendations = null, - Ratings = ArraySegment.Empty, - Reviews = ArraySegment.Empty + Ratings = [], + Reviews = [] }; // Allow 50 requests per 24 hours - private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(12), false); + private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); + private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); - public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, ILicenseService licenseService) + public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) { _unitOfWork = unitOfWork; _logger = logger; _mapper = mapper; _licenseService = licenseService; + _scrobblingService = scrobblingService; + _eventHub = eventHub; + _coverDbService = coverDbService; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } /// @@ -106,73 +106,152 @@ public class ExternalMetadataService : IExternalMetadataService /// This is a task that runs on a schedule and slowly fetches data from Kavita+ to keep /// data in the DB non-stale and fetched. /// - /// To avoid blasting Kavita+ API, this only processes a few records. The goal is to slowly build + /// To avoid blasting Kavita+ API, this only processes 25 records. The goal is to slowly build out/refresh the data /// [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task FetchExternalDataTask() { // Find all Series that are eligible and limit - var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetAllSeriesIdsWithoutMetadata(25); + var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25); if (ids.Count == 0) return; + ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true); - _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+", ids.Count); + _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids)); var count = 0; + var successfulMatches = new List(); var libTypes = await _unitOfWork.LibraryRepository.GetLibraryTypesBySeriesIdsAsync(ids); foreach (var seriesId in ids) { var libraryType = libTypes[seriesId]; - await GetNewSeriesData(seriesId, libraryType); - await Task.Delay(1500); - count++; + var success = await FetchSeriesMetadata(seriesId, libraryType); + if (success) + { + count++; + successfulMatches.Add(seriesId); + } + await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request } - _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} series data from Kavita+", count); + _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches)); } - /// - /// Removes from Blacklist and Invalidates the cache - /// - /// - /// - 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; - 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(); - } /// /// Fetches data from Kavita+ /// /// /// - public async Task GetNewSeriesData(int seriesId, LibraryType libraryType) + /// If a successful match was made + public async Task FetchSeriesMetadata(int seriesId, LibraryType libraryType) { - if (!IsPlusEligible(libraryType)) return; + if (!IsPlusEligible(libraryType)) return false; + if (!await _licenseService.HasActiveLicense()) return false; // Generate key based on seriesId and libraryType or any unique identifier for the request // Check if the request is allowed based on the rate limit if (!RateLimiter.TryAcquire(string.Empty)) { // Request not allowed due to rate limit - _logger.LogDebug("Rate Limit hit for Kavita+ prefetch"); - return; + _logger.LogInformation("Rate Limit hit for Kavita+ prefetch"); + return false; } - _logger.LogDebug("Prefetching Kavita+ data for Series {SeriesId}", seriesId); // Prefetch SeriesDetail data - await GetSeriesDetailPlus(seriesId, libraryType); - - // TODO: Fetch Series Metadata - + return await GetSeriesDetailPlus(seriesId, libraryType) != null; } + 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}") + .WithKavitaPlusHeaders(license) + .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; + } + } + + /// + /// Returns the match results for a Series from UI Flow + /// + /// + /// + public async Task> MatchSeries(MatchSeriesDto dto) + { + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, + SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library); + if (series == null) return []; + + var potentialAnilistId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.AniListWeblinkWebsite); + var potentialMalId = ScrobblingService.ExtractId(dto.Query, ScrobblingService.MalWeblinkWebsite); + + List altNames = [series.LocalizedName, series.OriginalName]; + if (potentialAnilistId == null && potentialMalId == null && !string.IsNullOrEmpty(dto.Query)) + { + altNames.Add(dto.Query); + } + + var matchRequest = new MatchSeriesRequestDto() + { + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), + Query = dto.Query, + SeriesName = series.Name, + AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), + Year = series.Metadata.ReleaseYear, + AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), + MalId = potentialMalId ?? ScrobblingService.GetMalId(series), + }; + + var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + try + { + var results = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(matchRequest) + .ReceiveJson>(); + + // Some summaries can contain multiple
s, we need to ensure it's only 1 + foreach (var result in results) + { + result.Series.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(result.Series.Summary)); + } + + return results; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + } + + return ArraySegment.Empty; + } + + /// /// Retrieves Metadata about a Recommended External Series /// @@ -201,16 +280,18 @@ public class ExternalMetadataService : IExternalMetadataService /// Returns Series Detail data from Kavita+ - Review, Recs, Ratings ///
/// + /// /// - public async Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType) + public async Task GetSeriesDetailPlus(int seriesId, LibraryType libraryType) { if (!IsPlusEligible(libraryType) || !await _licenseService.HasActiveLicense()) return _defaultReturn; - // Check blacklist (bad matches) - if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(seriesId)) return _defaultReturn; + // Check blacklist (bad matches) or if there is a don't match + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + if (series == null || !series.WillScrobble()) return _defaultReturn; var needsRefresh = - await _unitOfWork.ExternalSeriesMetadataRepository.ExternalSeriesMetadataNeedsRefresh(seriesId); + await _unitOfWork.ExternalSeriesMetadataRepository.NeedsDataRefresh(seriesId); if (!needsRefresh) { @@ -218,28 +299,177 @@ public class ExternalMetadataService : IExternalMetadataService return await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesDetailPlusDto(seriesId); } + var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); + if (data == null) return _defaultReturn; + + // Get from Kavita+ API the Full Series metadata with rec/rev and cache to ExternalMetadata tables try { - var data = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(seriesId); - if (data == null) return _defaultReturn; - _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", data.SeriesName); + return await FetchExternalMetadataForSeries(seriesId, libraryType, data); + } + catch (KavitaException ex) + { + _logger.LogError(ex, "Rate limit hit fetching metadata"); + // This can happen when we hit rate limit + return _defaultReturn; + } + } + /// + /// This will override any sort of matching that was done prior and force it to be what the user Selected + /// + /// + /// + /// + /// + public async Task FixSeriesMatch(int seriesId, int? aniListId, long? malId, int? cbrId) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) return; + + // Remove from Blacklist + series.IsBlacklisted = false; + series.DontMatch = false; + _unitOfWork.SeriesRepository.Update(series); + + // Refetch metadata with a Direct lookup + try + { + var metadata = await FetchExternalMetadataForSeries(seriesId, series.Library.Type, + new PlusSeriesRequestDto() + { + AniListId = aniListId, + MalId = malId, + CbrId = cbrId, + MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), + SeriesName = series.Name // Required field, not used since AniList/Mal Id are passed + }); + + if (metadata.Series == null) + { + _logger.LogError("Unable to Match {SeriesName} with Kavita+ Series with Id: {AniListId}/{MalId}/{CbrId}", + series.Name, aniListId, malId, cbrId); + return; + } + + // Find all scrobble events and rewrite them to be the correct + var events = await _unitOfWork.ScrobbleRepository.GetAllEventsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(events); + + // Find all scrobble errors and remove them + var errors = await _unitOfWork.ScrobbleRepository.GetAllScrobbleErrorsForSeries(seriesId); + _unitOfWork.ScrobbleRepository.Remove(errors); + + await _unitOfWork.CommitAsync(); + + // Regenerate all events for the series for all users + BackgroundJob.Enqueue(() => _scrobblingService.CreateEventsFromExistingHistoryForSeries(seriesId)); + + // Name can be null on Series even with a direct match + _logger.LogInformation("Matched {SeriesName} with Kavita+ Series {MatchSeriesName}", series.Name, + metadata.Series.Name); + } + catch (KavitaException ex) + { + // We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times + _logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name); + } + } + + /// + /// Sets a series to Don't Match and removes all previously cached + /// + /// + public async Task UpdateSeriesDontMatch(int seriesId, bool dontMatch) + { + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.ExternalMetadata); + if (series == null) return; + + _logger.LogInformation("User has asked Kavita to stop matching/scrobbling on {SeriesName}", series.Name); + + series.DontMatch = dontMatch; + + if (dontMatch) + { + // When we set as DontMatch, we will clear existing External Metadata + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(series.ExternalSeriesMetadata); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); + _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); + } + + _unitOfWork.SeriesRepository.Update(series); + + await _unitOfWork.CommitAsync(); + } + + /// + /// Requests the full SeriesDetail (rec, review, metadata) data for a Series. Will save to ExternalMetadata tables. + /// + /// + /// + /// + /// + private async Task FetchExternalMetadataForSeries(int seriesId, LibraryType libraryType, PlusSeriesRequestDto data) + { + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) + { + return _defaultReturn; + } + + try + { + _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .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)) - .PostJsonAsync(data) - .ReceiveJson(); + var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + SeriesDetailPlusApiDto? result = null; + + try + { + result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(data) + .ReceiveJson(); // This returns an AniListSeries and Match returns ExternalSeriesDto + } + catch (FlurlHttpException ex) + { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + + if (ex.StatusCode == 400) + { + if (errorMessage.Contains("Too many Requests")) + { + _logger.LogDebug("Hit rate limit, will retry in 3 seconds"); + await Task.Delay(3000); + + result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(data) + .ReceiveJson< + SeriesDetailPlusApiDto>(); + } + else if (errorMessage.Contains("Unknown Series")) + { + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); + } + } + } + + if (result == null) + { + _logger.LogInformation("Hit rate limit twice, try again later"); + return _defaultReturn; + } // Clear out existing results - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - var externalSeriesMetadata = await GetExternalSeriesMetadataForSeries(seriesId, series!); + var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRatings); _unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalRecommendations); @@ -255,12 +485,13 @@ public class ExternalMetadataService : IExternalMetadataService { var rating = _mapper.Map(r); rating.SeriesId = externalSeriesMetadata.SeriesId; + rating.ProviderUrl = r.ProviderUrl; return rating; }).ToList(); // Recommendations - externalSeriesMetadata.ExternalRecommendations ??= new List(); + externalSeriesMetadata.ExternalRecommendations ??= []; var recs = await ProcessRecommendations(libraryType, result.Recommendations, externalSeriesMetadata); var extRatings = externalSeriesMetadata.ExternalRatings @@ -273,35 +504,1044 @@ public class ExternalMetadataService : IExternalMetadataService if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value; if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value; - await _unitOfWork.CommitAsync(); + if (result.CbrId.HasValue) externalSeriesMetadata.CbrId = result.CbrId.Value; + + // If there is metadata and the user has metadata download turned on + var madeMetadataModification = false; + if (result.Series != null && series.Library.AllowMetadataMatching) + { + externalSeriesMetadata.Series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + + try + { + madeMetadataModification = await WriteExternalMetadataToSeries(result.Series, seriesId); + if (madeMetadataModification) + { + _unitOfWork.SeriesRepository.Update(series); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to write Series metadata from Kavita+"); + } + + } + + // WriteExternalMetadataToSeries will commit but not always + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + if (madeMetadataModification) + { + // Inform the UI of the update + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.LibraryId, series.Id, series.Name), false); + } return new SeriesDetailPlusDto() { Recommendations = recs, Ratings = result.Ratings, - Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)) + Reviews = externalSeriesMetadata.ExternalReviews.Select(r => _mapper.Map(r)), + Series = result.Series }; } catch (FlurlHttpException ex) { + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + if (ex.StatusCode == 500) { return _defaultReturn; } + + if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } } catch (Exception ex) { - _logger.LogError(ex, "An error happened during the request to Kavita+ API"); + if (ex.Message.Contains("Too Many Requests")) + { + throw new KavitaException("Too many requests, slow down"); + } + + _logger.LogError(ex, "Unable to fetch external series metadata from Kavita+"); } // Blacklist the series as it wasn't found in Kavita+ - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(seriesId); + series.IsBlacklisted = true; + await _unitOfWork.CommitAsync(); return _defaultReturn; } + /// + /// Given external metadata from Kavita+, write as much as possible to the Kavita series as possible + /// + /// + /// + /// + public async Task WriteExternalMetadataToSeries(ExternalSeriesDetailDto externalMetadata, int seriesId) + { + var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + if (!settings.Enabled) return false; - private async Task GetExternalSeriesMetadataForSeries(int seriesId, Series series) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Related); + if (series == null) return false; + + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(); + + _logger.LogInformation("Writing External metadata to Series {SeriesName}", series.Name); + + var madeModification = false; + var processedGenres = new List(); + var processedTags = new List(); + + madeModification = UpdateSummary(series, settings, externalMetadata) || madeModification; + madeModification = UpdateReleaseYear(series, settings, externalMetadata) || madeModification; + madeModification = UpdateLocalizedName(series, settings, externalMetadata) || madeModification; + madeModification = await UpdatePublicationStatus(series, settings, externalMetadata) || madeModification; + + // Apply field mappings + GenerateGenreAndTagLists(externalMetadata, settings, ref processedTags, ref processedGenres); + + madeModification = await UpdateGenres(series, settings, externalMetadata, processedGenres) || madeModification; + madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; + madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; + + var staff = (externalMetadata.Staff ?? []).Select(s => + { + s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"; + + return s; + }).ToList(); + madeModification = await UpdateWriters(series, settings, staff) || madeModification; + madeModification = await UpdateArtists(series, settings, staff) || madeModification; + madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; + + madeModification = await UpdateRelationships(series, settings, externalMetadata.Relations, defaultAdmin) || madeModification; + madeModification = await UpdateCoverImage(series, settings, externalMetadata) || madeModification; + + madeModification = await UpdateChapters(series, settings, externalMetadata) || madeModification; + + return madeModification; + } + + private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, + ref List processedTags, ref List processedGenres) + { + externalMetadata.Tags ??= []; + externalMetadata.Genres ??= []; + + var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags)) + { + processedTags.AddRange(tagsToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var tagsToGenres)) + { + processedGenres.AddRange(tagsToGenres); + } + + mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings); + if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags)) + { + processedTags.AddRange(genresToTags); + } + if (mappings.TryGetValue(MetadataFieldType.Genre, out var genresToGenres)) + { + processedGenres.AddRange(genresToGenres); + } + + processedTags = ApplyBlackWhiteList(settings, MetadataFieldType.Tag, processedTags); + processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres); + } + + private async Task UpdateRelationships(Series series, MetadataSettingsDto settings, IList? externalMetadataRelations, AppUser defaultAdmin) + { + if (!settings.EnableRelationships) return false; + + if (externalMetadataRelations == null || externalMetadataRelations.Count == 0 || defaultAdmin == null) + { + return false; + } + + foreach (var relation in externalMetadataRelations.Where(r => r.Relation != RelationKind.Parent)) + { + List names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle}.Where(s => !string.IsNullOrEmpty(s)).ToList()!; + var relatedSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName( + names, + relation.PlusMediaFormat.GetMangaFormats(), + defaultAdmin.Id, + relation.AniListId, + SeriesIncludes.Related); + + // Skip if no related series found or series is the parent + if (relatedSeries == null || relatedSeries.Id == series.Id || relation.Relation == RelationKind.Parent) continue; + + // Check if the relationship already exists + var relationshipExists = series.Relations.Any(r => + r.TargetSeriesId == relatedSeries.Id && r.RelationKind == relation.Relation); + + if (relationshipExists) continue; + + // Add new relationship + var newRelation = new SeriesRelation + { + RelationKind = relation.Relation, + TargetSeriesId = relatedSeries.Id, + SeriesId = series.Id, + }; + series.Relations.Add(newRelation); + + // Handle sequel/prequel: add reverse relationship + if (relation.Relation is RelationKind.Prequel or RelationKind.Sequel) + { + var reverseExists = relatedSeries.Relations.Any(r => + r.TargetSeriesId == series.Id && r.RelationKind == GetReverseRelation(relation.Relation)); + + if (!reverseExists) + { + var reverseRelation = new SeriesRelation + { + RelationKind = GetReverseRelation(relation.Relation), + TargetSeriesId = series.Id, + SeriesId = relatedSeries.Id, + }; + relatedSeries.Relations.Add(reverseRelation); + _unitOfWork.SeriesRepository.Attach(reverseRelation); + } + } + + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + } + + return true; + } + + private async Task UpdateCharacters(Series series, MetadataSettingsDto settings, IList? externalCharacters) + { + if (!settings.EnablePeople) return false; + + if (externalCharacters == null || externalCharacters.Count == 0) return false; + + if (series.Metadata.CharacterLocked && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Character)) + { + return false; + } + + series.Metadata.People ??= []; + + var characters = externalCharacters + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Character) + // Need to ensure existing people are retained, but we overwrite anything from a bad match + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + if (characters.Count == 0) return false; + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, characters, PersonRole.Character, _unitOfWork); + + foreach (var spPerson in series.Metadata.People.Where(p => p.Role == PersonRole.Character)) + { + // Set a sort order based on their role + var characterMeta = externalCharacters.FirstOrDefault(c => c.Name == spPerson.Person.Name); + spPerson.OrderWeight = 0; + + if (characterMeta != null) + { + spPerson.KavitaPlusConnection = true; + + spPerson.OrderWeight = characterMeta.Role switch + { + CharacterRole.Main => 0, + CharacterRole.Supporting => 1, + CharacterRole.Background => 2, + _ => 99 // Default for unknown roles + }; + } + } + + // Download the image and save it + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + foreach (var character in externalCharacters) + { + var aniListId = ScrobblingService.ExtractId(character.Url, ScrobblingService.AniListCharacterWebsite); + if (aniListId <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId); + if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage)) + { + await _coverDbService.SetPersonCoverByUrl(person, character.ImageUrl, false); + } + } + + + return true; + } + + private async Task UpdateArtists(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + + var upstreamArtists = staff + .Where(s => s.Role is "Art" or "Story & Art") + .ToList(); + + if (upstreamArtists.Count == 0) return false; + + if (series.Metadata.CoverArtistLocked && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.CoverArtist)) + { + return false; + } + + series.Metadata.People ??= []; + var artists = upstreamArtists + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.CoverArtist) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, artists, PersonRole.CoverArtist, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.CoverArtist)) + { + var meta = upstreamArtists.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetPersonCovers(upstreamArtists); + + return true; + } + + private async Task UpdateWriters(Series series, MetadataSettingsDto settings, List staff) + { + if (!settings.EnablePeople) return false; + + var upstreamWriters = staff + .Where(s => s.Role is "Story" or "Story & Art") + .ToList(); + + if (upstreamWriters.Count == 0) return false; + + if (series.Metadata.WriterLocked && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(PersonRole.Writer)) + { + return false; + } + + series.Metadata.People ??= []; + var writers = upstreamWriters + .Select(w => new PersonDto() + { + Name = w.Name, + AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), + Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), + }) + .Concat(series.Metadata.People + .Where(p => p.Role == PersonRole.Writer) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + + await SeriesService.HandlePeopleUpdateAsync(series.Metadata, writers, PersonRole.Writer, _unitOfWork); + + foreach (var person in series.Metadata.People.Where(p => p.Role == PersonRole.Writer)) + { + var meta = upstreamWriters.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.SeriesRepository.Update(series); + await _unitOfWork.CommitAsync(); + + await DownloadAndSetPersonCovers(upstreamWriters); + + return true; + } + + private async Task UpdateTags(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedTags) + { + externalMetadata.Tags ??= []; + + if (!settings.EnableTags || processedTags.Count == 0) return false; + + if (series.Metadata.TagsLocked && !settings.HasOverride(MetadataSettingField.Tags)) + { + return false; + } + + _logger.LogDebug("Found {TagCount} tags for {SeriesName}", processedTags.Count, series.Name); + var madeModification = false; + var allTags = (await _unitOfWork.TagRepository.GetAllTagsByNameAsync(processedTags.Select(Parser.Normalize))) + .ToList(); + series.Metadata.Tags ??= []; + + TagHelper.UpdateTagList(processedTags, series, allTags, tag => + { + series.Metadata.Tags.Add(tag); + madeModification = true; + }, () => series.Metadata.TagsLocked = true); + + return madeModification; + } + + private static List ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List processedStrings) + { + return fieldType switch + { + MetadataFieldType.Genre => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .ToList(), + MetadataFieldType.Tag => processedStrings.Distinct() + .Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g)) + .Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g)) + .ToList(), + _ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null) + }; + } + + private async Task UpdateGenres(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata, List processedGenres) + { + externalMetadata.Genres ??= []; + + if (!settings.EnableGenres || processedGenres.Count == 0) return false; + + if (series.Metadata.GenresLocked && !settings.HasOverride(MetadataSettingField.Genres)) + { + return false; + } + + _logger.LogDebug("Found {GenreCount} genres for {SeriesName}", processedGenres.Count, series.Name); + var madeModification = false; + var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(processedGenres.Select(Parser.Normalize))).ToList(); + series.Metadata.Genres ??= []; + var exisitingGenres = series.Metadata.Genres; + + GenreHelper.UpdateGenreList(processedGenres, series, allGenres, genre => + { + series.Metadata.Genres.Add(genre); + madeModification = true; + }, () => series.Metadata.GenresLocked = true); + + foreach (var genre in exisitingGenres) + { + if (series.Metadata.Genres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle) != null) continue; + series.Metadata.Genres.Add(genre); + } + + return madeModification; + } + + private async Task UpdatePublicationStatus(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnablePublicationStatus) return false; + + if (series.Metadata.PublicationStatusLocked && !settings.HasOverride(MetadataSettingField.PublicationStatus)) + { + return false; + } + + try + { + var chapters = + (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Chapters))!.Volumes + .SelectMany(v => v.Chapters).ToList(); + var status = DeterminePublicationStatus(series, chapters, externalMetadata); + + series.Metadata.PublicationStatus = status; + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Publication Status for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + private bool UpdateAgeRating(Series series, MetadataSettingsDto settings, IEnumerable allExternalTags) + { + + if (series.Metadata.AgeRatingLocked && !settings.HasOverride(MetadataSettingField.AgeRating)) + { + return false; + } + + try + { + // Determine Age Rating + var totalTags = allExternalTags + .Concat(series.Metadata.Genres.Select(g => g.Title)) + .Concat(series.Metadata.Tags.Select(g => g.Title)); + + var ageRating = DetermineAgeRating(totalTags, settings.AgeRatingMappings); + if (series.Metadata.AgeRating <= ageRating) + { + series.Metadata.AgeRating = ageRating; + return true; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue determining Age Rating for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + + return false; + } + + + private async Task UpdateChapters(Series series, MetadataSettingsDto settings, + ExternalSeriesDetailDto externalMetadata) + { + if (externalMetadata.PlusMediaFormat != PlusMediaFormat.Comic) return false; + + if (externalMetadata.ChapterDtos == null || externalMetadata.ChapterDtos.Count == 0) return false; + + // Get all volumes and chapters + var madeModification = false; + var allChapters = await _unitOfWork.ChapterRepository.GetAllChaptersForSeries(series.Id); + + var matchedChapters = allChapters + .Join( + externalMetadata.ChapterDtos, + chapter => chapter.Range, + dto => dto.IssueNumber, + (chapter, dto) => (chapter, dto) // Create a tuple of matched pairs + ) + .ToList(); + + foreach (var (chapter, potentialMatch) in matchedChapters) + { + _logger.LogDebug("Updating {ChapterNumber} with metadata", chapter.Range); + + // Write the metadata + madeModification = UpdateChapterTitle(chapter, settings, potentialMatch.Title, series.Name) || madeModification; + madeModification = UpdateChapterSummary(chapter, settings, potentialMatch.Summary) || madeModification; + madeModification = UpdateChapterReleaseDate(chapter, settings, potentialMatch.ReleaseDate) || madeModification; + madeModification = await UpdateChapterPublisher(chapter, settings, potentialMatch.Publisher) || madeModification; + + madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.CoverArtist, potentialMatch.Artists) || madeModification; + madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification; + + madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification; + + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + } + + + return madeModification; + } + + + private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary) + { + if (!settings.EnableChapterSummary) return false; + + if (string.IsNullOrEmpty(summary)) return false; + + if (chapter.SummaryLocked && !settings.HasOverride(MetadataSettingField.ChapterSummary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(summary) && !settings.HasOverride(MetadataSettingField.ChapterSummary)) + { + return false; + } + + chapter.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(summary)); + return true; + } + + private static bool UpdateChapterTitle(Chapter chapter, MetadataSettingsDto settings, string? title, string seriesName) + { + if (!settings.EnableChapterTitle) return false; + + if (string.IsNullOrEmpty(title)) return false; + + if (chapter.TitleNameLocked && !settings.HasOverride(MetadataSettingField.ChapterTitle)) + { + return false; + } + + if (!title.Contains(seriesName) && !settings.HasOverride(MetadataSettingField.ChapterTitle)) + { + return false; + } + + chapter.TitleName = title; + return true; + } + + private static bool UpdateChapterReleaseDate(Chapter chapter, MetadataSettingsDto settings, DateTime? releaseDate) + { + if (!settings.EnableChapterReleaseDate) return false; + + if (releaseDate == null || releaseDate == DateTime.MinValue) return false; + + if (chapter.ReleaseDateLocked && !settings.HasOverride(MetadataSettingField.ChapterReleaseDate)) + { + return false; + } + + if (!settings.HasOverride(MetadataSettingField.ChapterReleaseDate)) + { + return false; + } + + chapter.ReleaseDate = releaseDate.Value; + return true; + } + + private async Task UpdateChapterPublisher(Chapter chapter, MetadataSettingsDto settings, string? publisher) + { + if (!settings.EnableChapterPublisher) return false; + + if (string.IsNullOrEmpty(publisher)) return false; + + if (chapter.PublisherLocked && !settings.HasOverride(MetadataSettingField.ChapterPublisher)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(publisher) && !settings.HasOverride(MetadataSettingField.ChapterPublisher)) + { + return false; + } + + // Some publishers (CBR) can be represented as Boom! Studios/Boom! Town imprint, so let's handle that appropriately + if (publisher.Contains('/') || publisher.Contains("imprint", StringComparison.InvariantCultureIgnoreCase)) + { + var imprint = publisher.Split('/')[1].Replace("imprint", string.Empty); + return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]) || + await UpdateChapterPeople(chapter, settings, PersonRole.Imprint, [imprint]); + } + + return await UpdateChapterPeople(chapter, settings, PersonRole.Publisher, [publisher]); + } + + private async Task UpdateChapterCoverImage(Chapter chapter, MetadataSettingsDto settings, string? coverUrl) + { + if (!settings.EnableChapterCoverImage) return false; + + if (string.IsNullOrEmpty(coverUrl)) return false; + + if (chapter.CoverImageLocked && !settings.HasOverride(MetadataSettingField.ChapterCovers)) + { + return false; + } + + if (string.IsNullOrEmpty(coverUrl)) + { + return false; + } + + await DownloadChapterCovers(chapter, coverUrl); + return true; + } + + private async Task UpdateChapterPeople(Chapter chapter, MetadataSettingsDto settings, PersonRole role, IList? staff) + { + if (!settings.EnablePeople) return false; + + if (staff?.Count == 0) return false; + + if (chapter.IsPersonRoleLocked(role) && !settings.HasOverride(MetadataSettingField.People)) + { + return false; + } + + if (!settings.IsPersonAllowed(role) && role != PersonRole.Publisher) + { + return false; + } + + chapter.People ??= []; + var people = staff! + .Select(w => new PersonDto() + { + Name = w, + }) + .Concat(chapter.People + .Where(p => p.Role == role) + .Where(p => !p.KavitaPlusConnection) + .Select(p => _mapper.Map(p.Person)) + ) + .DistinctBy(p => Parser.Normalize(p.Name)) + .ToList(); + + await PersonHelper.UpdateChapterPeopleAsync(chapter, staff ?? [], role, _unitOfWork); + + foreach (var person in chapter.People.Where(p => p.Role == role)) + { + var meta = people.FirstOrDefault(c => c.Name == person.Person.Name); + person.OrderWeight = 0; + + if (meta != null) + { + person.KavitaPlusConnection = true; + } + } + + _unitOfWork.ChapterRepository.Update(chapter); + await _unitOfWork.CommitAsync(); + + return true; + } + + private async Task UpdateCoverImage(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableCoverImage) return false; + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) return false; + + if (series.CoverImageLocked && !settings.HasOverride(MetadataSettingField.Covers)) + { + return false; + } + + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) + { + return false; + } + + await DownloadSeriesCovers(series, externalMetadata.CoverUrl); + return true; + } + + + private static bool UpdateReleaseYear(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableStartDate) return false; + + if (!externalMetadata.StartDate.HasValue) return false; + + if (series.Metadata.ReleaseYearLocked && !settings.HasOverride(MetadataSettingField.StartDate)) + { + return false; + } + + if (series.Metadata.ReleaseYear != 0 && !settings.HasOverride(MetadataSettingField.StartDate)) + { + return false; + } + + series.Metadata.ReleaseYear = externalMetadata.StartDate.Value.Year; + return true; + } + + private static bool UpdateLocalizedName(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableLocalizedName) return false; + + if (series.LocalizedNameLocked && !settings.HasOverride(MetadataSettingField.LocalizedName)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.LocalizedName) && !settings.HasOverride(MetadataSettingField.LocalizedName)) + { + return false; + } + + // We need to make the best appropriate guess + if (externalMetadata.Name == series.Name) + { + // Choose closest (usually last) synonym + var validSynonyms = externalMetadata.Synonyms + .Where(IsRomanCharacters) + .Where(s => s.ToNormalized() != series.Name.ToNormalized()) + .ToList(); + + if (validSynonyms.Count == 0) return false; + + series.LocalizedName = validSynonyms[^1]; + series.LocalizedNameLocked = true; + } + else if (IsRomanCharacters(externalMetadata.Name)) + { + series.LocalizedName = externalMetadata.Name; + series.LocalizedNameLocked = true; + } + + + return true; + } + + private static bool UpdateSummary(Series series, MetadataSettingsDto settings, ExternalSeriesDetailDto externalMetadata) + { + if (!settings.EnableSummary) return false; + + if (string.IsNullOrEmpty(externalMetadata.Summary)) return false; + + if (series.Metadata.SummaryLocked && !settings.HasOverride(MetadataSettingField.Summary)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(series.Metadata.Summary) && !settings.HasOverride(MetadataSettingField.Summary)) + { + return false; + } + + series.Metadata.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(externalMetadata.Summary)); + return true; + } + + + private static RelationKind GetReverseRelation(RelationKind relation) + { + return relation switch + { + RelationKind.Prequel => RelationKind.Sequel, + RelationKind.Sequel => RelationKind.Prequel, + _ => relation // For other relationships, no reverse needed + }; + } + + private async Task DownloadSeriesCovers(Series series, string coverUrl) + { + try + { + await _coverDbService.SetSeriesCoverByUrl(series, coverUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Series {SeriesName} ({SeriesId})", series.Name, series.Id); + } + } + + private async Task DownloadChapterCovers(Chapter chapter, string coverUrl) + { + try + { + await _coverDbService.SetChapterCoverByUrl(chapter, coverUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception downloading cover image for Chapter {ChapterName} ({SeriesId})", chapter.Range, chapter.Id); + } + } + + private async Task DownloadAndSetPersonCovers(List people) + { + foreach (var staff in people) + { + var aniListId = ScrobblingService.ExtractId(staff.Url, ScrobblingService.AniListStaffWebsite); + if (aniListId is null or <= 0) continue; + var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value); + if (person == null || string.IsNullOrEmpty(staff.ImageUrl) || + !string.IsNullOrEmpty(person.CoverImage) || staff.ImageUrl.EndsWith("default.jpg")) continue; + + try + { + await _coverDbService.SetPersonCoverByUrl(person, staff.ImageUrl, false, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception saving cover image for Person {PersonName} ({PersonId})", person.Name, person.Id); + } + + } + } + + private PublicationStatus DeterminePublicationStatus(Series series, List chapters, ExternalSeriesDetailDto externalMetadata) + { + try + { + // Determine the expected total count based on local metadata + series.Metadata.TotalCount = Math.Max( + chapters.Max(chapter => chapter.TotalCount), + externalMetadata.Volumes > 0 ? externalMetadata.Volumes : externalMetadata.Chapters + ); + + // The actual number of count's defined across all chapter's metadata + series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); + + var nonSpecialVolumes = series.Volumes + .Where(v => v.MaxNumber.IsNot(Parser.SpecialVolumeNumber)) + .ToList(); + + var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); + + if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) + { + series.Metadata.MaxCount = 1; + } + else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial) + { + series.Metadata.MaxCount = series.Metadata.TotalCount; + } + else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && + maxVolume <= series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else if (maxVolume == series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else + { + series.Metadata.MaxCount = maxChapter; + } + + var status = PublicationStatus.OnGoing; + + var hasExternalCounts = externalMetadata.Volumes > 0 || externalMetadata.Chapters > 0; + + if (hasExternalCounts) + { + status = PublicationStatus.Ended; + + // Check if all volumes/chapters match the total count + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + status = PublicationStatus.Completed; + } + } + + return status; + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an issue determining Publication Status"); + } + + return PublicationStatus.OnGoing; + } + + private static Dictionary> ApplyFieldMappings(IEnumerable values, MetadataFieldType sourceType, List mappings) + { + var result = new Dictionary>(); + + foreach (var field in Enum.GetValues()) + { + result[field] = []; + } + + foreach (var value in values) + { + var mapping = mappings.FirstOrDefault(m => + m.SourceType == sourceType && + m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase)); + + if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue)) + { + var targetType = mapping.DestinationType; + + if (!mapping.ExcludeFromSource) + { + result[sourceType].Add(mapping.SourceValue); + } + + result[targetType].Add(mapping.DestinationValue); + } + else + { + // If no mapping, keep the original value + result[sourceType].Add(value); + } + } + + // Ensure distinct + foreach (var key in result.Keys) + { + result[key] = result[key].Distinct().ToList(); + } + + return result; + } + + + /// + /// Returns the highest age rating from all tags/genres based on user-supplied mappings + /// + /// A combo of all tags/genres + /// + /// + public static AgeRating DetermineAgeRating(IEnumerable values, Dictionary mappings) + { + // Find highest age rating from mappings + mappings ??= new Dictionary(); + + return values + .Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown) + .DefaultIfEmpty(AgeRating.Unknown) + .Max(); + } + + + /// + /// Gets from DB or creates a new one with just SeriesId + /// + /// + /// + /// + private async Task GetOrCreateExternalSeriesMetadataForSeries(int seriesId, Series series) { var externalSeriesMetadata = await _unitOfWork.ExternalSeriesMetadataRepository.GetExternalSeriesMetadata(seriesId); if (externalSeriesMetadata != null) return externalSeriesMetadata; @@ -312,6 +1552,7 @@ public class ExternalMetadataService : IExternalMetadataService }; series.ExternalSeriesMetadata = externalSeriesMetadata; _unitOfWork.ExternalSeriesMetadataRepository.Attach(externalSeriesMetadata); + return externalSeriesMetadata; } @@ -380,6 +1621,15 @@ public class ExternalMetadataService : IExternalMetadataService } + /// + /// This is to get series information for the recommendation drawer on Kavita + /// + /// This uses a different API that series detail + /// + /// + /// + /// + /// private async Task GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId) { var payload = new ExternalMetadataIdsDto() @@ -406,23 +1656,22 @@ public class ExternalMetadataService : IExternalMetadataService } payload.SeriesName = series.Name; payload.LocalizedSeriesName = series.LocalizedName; - payload.PlusMediaFormat = ConvertToMediaFormat(series.Library.Type, series.Format); + payload.PlusMediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format); } } try { - return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") - .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)) + var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + .WithKavitaPlusHeaders(license, token) .PostJsonAsync(payload) .ReceiveJson(); + ret.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(ret.Summary)); + + return ret; + } catch (Exception e) { @@ -431,16 +1680,4 @@ public class ExternalMetadataService : IExternalMetadataService return null; } - - private static MediaFormat ConvertToMediaFormat(LibraryType libraryType, MangaFormat seriesFormat) - { - return libraryType switch - { - LibraryType.Manga => seriesFormat == MangaFormat.Epub ? MediaFormat.LightNovel : MediaFormat.Manga, - LibraryType.Comic => MediaFormat.Comic, - LibraryType.Book => MediaFormat.Book, - LibraryType.LightNovel => MediaFormat.LightNovel, - _ => MediaFormat.Unknown - }; - } } diff --git a/API/Services/Plus/LicenseService.cs b/API/Services/Plus/LicenseService.cs index 439a1aaf5..774103518 100644 --- a/API/Services/Plus/LicenseService.cs +++ b/API/Services/Plus/LicenseService.cs @@ -1,10 +1,13 @@ using System; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Data; using API.DTOs.Account; -using API.DTOs.License; +using API.DTOs.KavitaPlus.License; using API.Entities.Enums; +using API.Extensions; +using API.Services.Tasks; using EasyCaching.Core; using Flurl.Http; using Kavita.Common; @@ -29,17 +32,23 @@ public interface ILicenseService Task HasActiveLicense(bool forceCheck = false); Task HasActiveSubscription(string? license); Task ResetLicense(string license, string email); + Task GetLicenseInfo(bool forceCheck = false); } public class LicenseService( IEasyCachingProviderFactory cachingProviderFactory, IUnitOfWork unitOfWork, - ILogger logger) + ILogger logger, + IVersionUpdaterService versionUpdaterService) : ILicenseService { private readonly TimeSpan _licenseCacheTimeout = TimeSpan.FromHours(8); - public const string Cron = "0 */4 * * *"; - private const string CacheKey = "license"; + public const string Cron = "0 */9 * * *"; + /// + /// Cache key for if license is valid or not + /// + public const string CacheKey = "license"; + private const string LicenseInfoCacheKey = "license-info"; /// @@ -53,13 +62,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check") - .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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new LicenseValidDto() { License = license, @@ -87,13 +90,7 @@ public class LicenseService( try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/register") - .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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new EncryptLicenseDto() { License = license.Trim(), @@ -118,36 +115,6 @@ public class LicenseService( } } - /// - /// Checks licenses and updates cache - /// - /// Expected to be called at startup and on reoccurring basis - // public async Task ValidateLicenseStatus() - // { - // var provider = _cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - // try - // { - // var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - // if (string.IsNullOrEmpty(license.Value)) { - // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - // return; - // } - // - // _logger.LogInformation("Validating Kavita+ License"); - // - // await provider.FlushAsync(); - // var isValid = await IsLicenseValid(license.Value); - // await provider.SetAsync(CacheKey, isValid, _licenseCacheTimeout); - // - // _logger.LogInformation("Validating Kavita+ License - Complete"); - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "There was an error talking with Kavita+ API for license validation. Rescheduling check in 30 mins"); - // await provider.SetAsync(CacheKey, false, _licenseCacheTimeout); - // BackgroundJob.Schedule(() => ValidateLicenseStatus(), TimeSpan.FromMinutes(30)); - // } - // } /// /// Checks licenses and updates cache @@ -181,33 +148,30 @@ public class LicenseService( return false; } + /// + /// Checks if the sub is active and caches the result. This should not be used too much over cache as it will skip backend caching. + /// + /// + /// public async Task HasActiveSubscription(string? license) { if (string.IsNullOrWhiteSpace(license)) return false; try { var response = await (Configuration.KavitaPlusApiUrl + "/api/license/check-sub") - .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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(new LicenseValidDto() { License = license, InstallId = HashUtil.ServerToken() }) .ReceiveString(); + var result = bool.Parse(response); - if (!result) - { - var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); - await provider.FlushAsync(); - await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); - } + var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + await provider.FlushAsync(); + await provider.SetAsync(CacheKey, result, _licenseCacheTimeout); return result; } @@ -224,8 +188,11 @@ public class LicenseService( serverSetting.Value = string.Empty; unitOfWork.SettingsRepository.Update(serverSetting); await unitOfWork.CommitAsync(); + var provider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); await provider.RemoveAsync(CacheKey); + + } public async Task AddLicense(string license, string email, string? discordId) @@ -247,13 +214,7 @@ public class LicenseService( { var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var response = await (Configuration.KavitaPlusApiUrl + "/api/license/reset") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", encryptedLicense.Value) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(encryptedLicense.Value) .PostJsonAsync(new ResetLicenseDto() { License = license.Trim(), @@ -279,4 +240,68 @@ public class LicenseService( return false; } + + /// + /// Fetches information about the license from Kavita+. If there is no license or an exception, will return null and can be assumed it is not active + /// + /// + /// + public async Task GetLicenseInfo(bool forceCheck = false) + { + // Check if there is a license + var hasLicense = + !string.IsNullOrEmpty((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)) + .Value); + + if (!hasLicense) return null; + + // Check the cache + var licenseInfoProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.LicenseInfo); + if (!forceCheck) + { + var cacheValue = await licenseInfoProvider.GetAsync(LicenseInfoCacheKey); + if (cacheValue.HasValue) return cacheValue.Value; + } + + // TODO: If info.IsCancelled && notActive, let's remove the license so we aren't constantly checking + + try + { + var encryptedLicense = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + var response = await (Configuration.KavitaPlusApiUrl + "/api/license/info") + .WithKavitaPlusHeaders(encryptedLicense.Value) + .GetJsonAsync(); + + // This indicates a mismatch on installId or no active subscription + if (response == null) return null; + + // Ensure that current version is within the 3 version limit. Don't count Nightly releases or Hotfixes + var releases = await versionUpdaterService.GetAllReleases(); + response.IsValidVersion = releases + .Where(r => !r.UpdateTitle.Contains("Hotfix")) // We don't care about Hotfix releases + .Where(r => !r.IsPrerelease) // Ensure we don't take current nightlies within the current/last stable + .Take(3) + .All(r => new Version(r.UpdateVersion) <= BuildInfo.Version); + + response.HasLicense = hasLicense; + + // Cache if the license is valid here as well + var licenseProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.License); + await licenseProvider.SetAsync(CacheKey, response.IsActive, _licenseCacheTimeout); + + // Cache the license info if IsActive and ExpirationDate > DateTime.UtcNow + 2 + if (response.IsActive && response.ExpirationDate > DateTime.UtcNow.AddDays(2)) + { + await licenseInfoProvider.SetAsync(LicenseInfoCacheKey, response, _licenseCacheTimeout); + } + + return response; + } + catch (FlurlHttpException e) + { + logger.LogError(e, "An error happened during the request to Kavita+ API"); + } + + return null; + } } diff --git a/API/Services/Plus/RecommendationService.cs b/API/Services/Plus/RecommendationService.cs deleted file mode 100644 index 24cb1445b..000000000 --- a/API/Services/Plus/RecommendationService.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using API.Data; -using API.Data.Repositories; -using API.DTOs; -using API.DTOs.Recommendation; -using API.DTOs.Scrobbling; -using API.Entities; -using API.Entities.Enums; -using API.Extensions; -using API.Helpers.Builders; -using Flurl.Http; -using Kavita.Common; -using Kavita.Common.EnvironmentInfo; -using Kavita.Common.Helpers; -using Microsoft.Extensions.Logging; - -namespace API.Services.Plus; -#nullable enable - - -public interface IRecommendationService -{ - //Task GetRecommendationsForSeries(int userId, int seriesId); -} - - -public class RecommendationService : IRecommendationService -{ - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - - public RecommendationService(IUnitOfWork unitOfWork, ILogger logger) - { - _unitOfWork = unitOfWork; - _logger = logger; - - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - } - - public async Task GetRecommendationsForSeries(int userId, int seriesId) - { - var series = - await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, - SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.Volumes | SeriesIncludes.Chapters); - if (series == null || series.Library.Type == LibraryType.Comic) return new RecommendationDto(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); - - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var canSeeExternalSeries = user is {AgeRestriction: AgeRating.NotApplicable} && - await _unitOfWork.UserRepository.IsUserAdminAsync(user); - - var recDto = new RecommendationDto() - { - ExternalSeries = new List(), - OwnedSeries = new List() - }; - - var recs = await GetRecommendations(license.Value, series); - foreach (var rec in recs) - { - // Find the series based on name and type and that the user has access too - var seriesForRec = await _unitOfWork.SeriesRepository.GetSeriesDtoByNamesAndMetadataIds(rec.RecommendationNames, - series.Library.Type, ScrobblingService.CreateUrl(ScrobblingService.AniListWeblinkWebsite, rec.AniListId), - ScrobblingService.CreateUrl(ScrobblingService.MalWeblinkWebsite, rec.MalId)); - - if (seriesForRec != null) - { - recDto.OwnedSeries.Add(seriesForRec); - continue; - } - - if (!canSeeExternalSeries) continue; - // We can show this based on user permissions - if (string.IsNullOrEmpty(rec.Name) || string.IsNullOrEmpty(rec.SiteUrl) || string.IsNullOrEmpty(rec.CoverUrl)) continue; - recDto.ExternalSeries.Add(new ExternalSeriesDto() - { - Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name, - Url = rec.SiteUrl, - CoverUrl = rec.CoverUrl, - Summary = rec.Summary, - AniListId = rec.AniListId, - MalId = rec.MalId - }); - } - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, recDto.OwnedSeries); - - recDto.OwnedSeries = recDto.OwnedSeries.DistinctBy(s => s.Id).OrderBy(r => r.Name).ToList(); - recDto.ExternalSeries = recDto.ExternalSeries.DistinctBy(s => s.Name.ToNormalized()).OrderBy(r => r.Name).ToList(); - - return recDto; - } - - - protected async Task> GetRecommendations(string license, Series series) - { - try - { - return await (Configuration.KavitaPlusApiUrl + "/api/recommendation") - .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)) - .PostJsonAsync(new PlusSeriesDtoBuilder(series).Build()) - .ReceiveJson>(); - - } - catch (Exception e) - { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); - } - - return new List(); - } -} diff --git a/API/Services/Plus/ScrobblingService.cs b/API/Services/Plus/ScrobblingService.cs index 93d75c246..ef22736d2 100644 --- a/API/Services/Plus/ScrobblingService.cs +++ b/API/Services/Plus/ScrobblingService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading.Tasks; @@ -10,14 +11,17 @@ using API.DTOs.Filtering; using API.DTOs.Scrobbling; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Entities.Scrobble; +using API.Extensions; using API.Helpers; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Flurl.Http; using Hangfire; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Plus; @@ -34,6 +38,9 @@ public enum ScrobbleProvider Kavita = 0, AniList = 1, Mal = 2, + [Obsolete] + GoogleBooks = 3, + Cbr = 4 } public interface IScrobblingService @@ -52,63 +59,64 @@ public interface IScrobblingService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ProcessUpdatesSinceLastSync(); Task CreateEventsFromExistingHistory(int userId = 0); + Task CreateEventsFromExistingHistoryForSeries(int seriesId); Task ClearEventsForSeries(int userId, int seriesId); } public class ScrobblingService : IScrobblingService { private readonly IUnitOfWork _unitOfWork; - private readonly ITokenService _tokenService; private readonly IEventHub _eventHub; private readonly ILogger _logger; private readonly ILicenseService _licenseService; private readonly ILocalizationService _localizationService; + private readonly IEmailService _emailService; public const string AniListWeblinkWebsite = "https://anilist.co/manga/"; public const string MalWeblinkWebsite = "https://myanimelist.net/manga/"; public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id="; public const string MangaDexWeblinkWebsite = "https://mangadex.org/title/"; + public const string AniListStaffWebsite = "https://anilist.co/staff/"; + public const string AniListCharacterWebsite = "https://anilist.co/character/"; - private static readonly IDictionary WeblinkExtractionMap = new Dictionary() + + private static readonly Dictionary WeblinkExtractionMap = new Dictionary() { {AniListWeblinkWebsite, 0}, {MalWeblinkWebsite, 0}, {GoogleBooksWeblinkWebsite, 0}, {MangaDexWeblinkWebsite, 0}, + {AniListStaffWebsite, 0}, + {AniListCharacterWebsite, 0}, }; private const int ScrobbleSleepTime = 1000; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90) - private static readonly IList BookProviders = new List() - { - }; - private static readonly IList LightNovelProviders = new List() - { + private static readonly IList BookProviders = []; + private static readonly IList LightNovelProviders = + [ ScrobbleProvider.AniList - }; - private static readonly IList ComicProviders = new List(); - private static readonly IList MangaProviders = new List() - { - ScrobbleProvider.AniList - }; + ]; + private static readonly IList ComicProviders = []; + private static readonly IList MangaProviders = (List) + [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"; - public ScrobblingService(IUnitOfWork unitOfWork, ITokenService tokenService, - IEventHub eventHub, ILogger logger, ILicenseService licenseService, - ILocalizationService localizationService) + public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger logger, + ILicenseService licenseService, ILocalizationService localizationService, IEmailService emailService) { _unitOfWork = unitOfWork; - _tokenService = tokenService; _eventHub = eventHub; _logger = logger; _licenseService = licenseService; _localizationService = localizationService; + _emailService = emailService; - FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -123,13 +131,72 @@ public class ScrobblingService : IScrobblingService var users = await _unitOfWork.UserRepository.GetAllUsersAsync(); foreach (var user in users) { - if (string.IsNullOrEmpty(user.AniListAccessToken) || !_tokenService.HasTokenExpired(user.AniListAccessToken)) continue; - _logger.LogInformation("User {UserName}'s AniList token has expired! They need to regenerate it for scrobbling to work", user.UserName); - await _eventHub.SendMessageToAsync(MessageFactory.ScrobblingKeyExpired, - MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), user.Id); + if (string.IsNullOrEmpty(user.AniListAccessToken)) continue; + + var tokenExpiry = JwtHelper.GetTokenExpiry(user.AniListAccessToken); + + // Send early reminder 5 days before token expiry + if (await ShouldSendEarlyReminder(user.Id, tokenExpiry)) + { + await _emailService.SendTokenExpiringSoonEmail(user.Id, ScrobbleProvider.AniList); + } + + // Send expiration notification after token expiry + if (await ShouldSendExpirationReminder(user.Id, tokenExpiry)) + { + await _emailService.SendTokenExpiredEmail(user.Id, ScrobbleProvider.AniList); + } + + // Check token validity + if (JwtHelper.IsTokenValid(user.AniListAccessToken)) continue; + + _logger.LogInformation( + "User {UserName}'s AniList token has expired or is expiring in a few days! They need to regenerate it for scrobbling to work", + user.UserName); + + // Notify user via event + await _eventHub.SendMessageToAsync( + MessageFactory.ScrobblingKeyExpired, + MessageFactory.ScrobblingKeyExpiredEvent(ScrobbleProvider.AniList), + user.Id); + } } + /// + /// Checks if an early reminder email should be sent. + /// + private async Task ShouldSendEarlyReminder(int userId, DateTime tokenExpiry) + { + var earlyReminderDate = tokenExpiry.AddDays(-5); + if (earlyReminderDate > DateTime.UtcNow) return false; + + var hasAlreadySentReminder = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpiringSoonTemplate && + h.SendDate >= earlyReminderDate); + + return !hasAlreadySentReminder; + + } + + /// + /// Checks if an expiration notification email should be sent. + /// + private async Task ShouldSendExpirationReminder(int userId, DateTime tokenExpiry) + { + if (tokenExpiry > DateTime.UtcNow) return false; + + var hasAlreadySentExpirationEmail = await _unitOfWork.DataContext.EmailHistory + .AnyAsync(h => h.AppUserId == userId && h.Sent && + h.EmailTemplate == EmailService.TokenExpirationTemplate && + h.SendDate >= tokenExpiry); + + return !hasAlreadySentExpirationEmail; + + } + + public async Task HasTokenExpired(int userId, ScrobbleProvider provider) { var token = await GetTokenForProvider(userId, provider); @@ -148,7 +215,7 @@ public class ScrobblingService : IScrobblingService private async Task HasTokenExpired(string token, ScrobbleProvider provider) { if (string.IsNullOrEmpty(token) || - !_tokenService.HasTokenExpired(token)) return false; + !TokenService.HasTokenExpired(token)) return false; var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); if (string.IsNullOrEmpty(license.Value)) return true; @@ -156,13 +223,7 @@ public class ScrobblingService : IScrobblingService try { var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/valid-key?provider=" + provider + "&key=" + token) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-license-key", license.Value) - .WithHeader("x-installId", HashUtil.ServerToken()) - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs)) + .WithKavitaPlusHeaders(license.Value, token) .GetStringAsync(); return bool.Parse(response); @@ -228,9 +289,9 @@ public class ScrobblingService : IScrobblingService LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.Review, AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), ReviewBody = reviewBody, ReviewTitle = reviewTitle }; @@ -250,9 +311,12 @@ public class ScrobblingService : IScrobblingService { if (!await _licenseService.HasActiveLicense()) return; - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + _logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; @@ -274,24 +338,39 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.ScoreUpdated, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = GetAniListId(series), + MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), Rating = rating }; _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId); + } + + public static long? GetMalId(Series series) + { + var malId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite); + return malId ?? series.ExternalSeriesMetadata?.MalId; + } + + public static int? GetAniListId(Series seriesWithExternalMetadata) + { + var aniListId = ExtractId(seriesWithExternalMetadata.Metadata.WebLinks, AniListWeblinkWebsite); + return aniListId ?? seriesWithExternalMetadata.ExternalSeriesMetadata?.AniListId; } public async Task ScrobbleReadingUpdate(int userId, int seriesId) { if (!await _licenseService.HasActiveLicense()) return; - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; + _logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; @@ -321,18 +400,25 @@ public class ScrobblingService : IScrobblingService SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = ScrobbleEventType.ChapterRead, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = GetAniListId(series), + MalId = GetMalId(series), AppUserId = userId, VolumeNumber = (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId), ChapterNumber = await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId), - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; + + if (evt.VolumeNumber is Parser.SpecialVolumeNumber) + { + // We don't process Specials because they will never match on AniList + return; + } + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); - _logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId); + _logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {UserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId); } catch (Exception ex) { @@ -344,26 +430,37 @@ public class ScrobblingService : IScrobblingService { if (!await _licenseService.HasActiveLicense()) return; - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library); - if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library | SeriesIncludes.ExternalMetadata); + if (series == null || !series.Library.AllowScrobbling) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist")); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return; - _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); if (await CheckIfCannotScrobble(userId, seriesId, series)) return; + _logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name); - var existing = await _unitOfWork.ScrobbleRepository.Exists(userId, series.Id, - onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead); - if (existing) return; // BUG: If I take a series and add to remove from want to read, then add to want to read, Kavita rejects the second as a duplicate, when it's not + // Get existing events for this series/user + var existingEvents = (await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId)) + .Where(e => new[] { ScrobbleEventType.AddWantToRead, ScrobbleEventType.RemoveWantToRead }.Contains(e.ScrobbleEventType)); + // Remove all existing want-to-read events for this series/user + foreach (var existingEvent in existingEvents) + { + _unitOfWork.ScrobbleRepository.Remove(existingEvent); + } + + // Create the new event var evt = new ScrobbleEvent() { SeriesId = series.Id, LibraryId = series.LibraryId, ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead, - AniListId = ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite), - MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite), + AniListId = GetAniListId(series), + MalId = GetMalId(series), AppUserId = userId, - Format = LibraryTypeHelper.GetFormat(series.Library.Type), + Format = series.Library.Type.ConvertToPlusMediaFormat(series.Format), }; + _unitOfWork.ScrobbleRepository.Attach(evt); await _unitOfWork.CommitAsync(); _logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId); @@ -371,6 +468,7 @@ public class ScrobblingService : IScrobblingService private async Task CheckIfCannotScrobble(int userId, int seriesId, Series series) { + if (series.DontMatch) return true; if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId)) { _logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name, @@ -381,6 +479,7 @@ public class ScrobblingService : IScrobblingService var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId); if (library is not {AllowScrobbling: true}) return true; if (!ExternalMetadataService.IsPlusEligible(library.Type)) return true; + return false; } @@ -390,20 +489,14 @@ public class ScrobblingService : IScrobblingService try { var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/rate-limit?accessToken=" + aniListToken) - .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)) + .WithKavitaPlusHeaders(license, aniListToken) .GetStringAsync(); return int.Parse(response); } catch (Exception e) { - _logger.LogError(e, "An error happened during the request to Kavita+ API"); + _logger.LogError(e, "An error happened trying to get rate limit from Kavita+ API"); } return 0; @@ -414,13 +507,7 @@ public class ScrobblingService : IScrobblingService try { var response = await (Configuration.KavitaPlusApiUrl + "/api/scrobbling/update") - .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)) + .WithKavitaPlusHeaders(license) .PostJsonAsync(data) .ReceiveJson(); @@ -429,27 +516,38 @@ 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"); + _logger.LogInformation("Kavita+ was unable to match the series: {SeriesName}", evt.Series.Name); if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) { + // Create a new ExternalMetadata entry to indicate that this is not matchable + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(evt.SeriesId, SeriesIncludes.ExternalMetadata); + if (series == null) return 0; + + series.ExternalSeriesMetadata ??= new ExternalSeriesMetadata() {SeriesId = evt.SeriesId}; + series.IsBlacklisted = true; + _unitOfWork.SeriesRepository.Update(series); + _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() { Comment = UnknownSeriesErrorMessage, @@ -457,7 +555,7 @@ public class ScrobblingService : IScrobblingService LibraryId = evt.LibraryId, SeriesId = evt.SeriesId }); - await _unitOfWork.ExternalSeriesMetadataRepository.CreateBlacklistedSeries(evt.SeriesId, false); + } evt.IsErrored = true; @@ -479,17 +577,24 @@ 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; } - catch (FlurlHttpException ex) + catch (FlurlHttpException ex) { - _logger.LogError("Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); + var errorMessage = await ex.GetResponseStringAsync(); + // Trim quotes if the response is a JSON string + errorMessage = errorMessage.Trim('"'); + + if (errorMessage.Contains("Too Many Requests")) + { + _logger.LogInformation("Hit Too many requests, sleeping to regain requests and retrying"); + await Task.Delay(TimeSpan.FromMinutes(10)); + return await PostScrobbleUpdate(data, license, evt); + } + + _logger.LogError(ex, "Scrobbling to Kavita+ API failed due to error: {ErrorMessage}", ex.Message); if (ex.Message.Contains("Call failed with status code 500 (Internal Server Error)")) { if (!await _unitOfWork.ScrobbleRepository.HasErrorForSeries(evt.SeriesId)) @@ -511,11 +616,26 @@ public class ScrobblingService : IScrobblingService } /// - /// This will back fill events from existing progress history, ratings, and want to read for users that have a valid license + /// This will backfill events from existing progress history, ratings, and want to read for users that have a valid license /// /// Defaults to 0 meaning all users. Allows a userId to be set if a scrobble key is added to a user public async Task CreateEventsFromExistingHistory(int userId = 0) { + if (!await _licenseService.HasActiveLicense()) return; + + if (userId != 0) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.AniListAccessToken)) return; + if (user.HasRunScrobbleEventGeneration) + { + _logger.LogWarning("User {UserName} has already run scrobble event generation, Kavita will not generate more events", user.UserName); + return; + } + } + + + var libAllowsScrobbling = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()) .ToDictionary(lib => lib.Id, lib => lib.AllowScrobbling); @@ -523,8 +643,6 @@ public class ScrobblingService : IScrobblingService .Where(l => userId == 0 || userId == l.Id) .Select(u => u.Id); - if (!await _licenseService.HasActiveLicense()) return; - foreach (var uId in userIds) { var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); @@ -541,13 +659,6 @@ public class ScrobblingService : IScrobblingService await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); } - var reviews = await _unitOfWork.UserRepository.GetSeriesWithReviews(uId); - foreach (var review in reviews) - { - if (!libAllowsScrobbling[review.Series.LibraryId]) continue; - await ScrobbleReviewUpdate(uId, review.SeriesId, review.Tagline, review.Review); - } - var seriesWithProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(0, uId, new UserParams(), new FilterDto() { @@ -567,6 +678,68 @@ public class ScrobblingService : IScrobblingService await ScrobbleReadingUpdate(uId, series.Id); } + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(uId); + if (user != null) + { + user.HasRunScrobbleEventGeneration = true; + user.ScrobbleEventGenerationRan = DateTime.UtcNow; + await _unitOfWork.CommitAsync(); + } + } + } + + public async Task CreateEventsFromExistingHistoryForSeries(int seriesId) + { + if (!await _licenseService.HasActiveLicense()) return; + + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null || !series.Library.AllowScrobbling) return; + + _logger.LogInformation("Creating Scrobbling events for Series {SeriesName}", series.Name); + + var userIds = (await _unitOfWork.UserRepository.GetAllUsersAsync()) + .Select(u => u.Id); + + foreach (var uId in userIds) + { + // Handle "Want to Read" updates specific to the series + var wantToRead = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(uId); + foreach (var wtr in wantToRead.Where(wtr => wtr.Id == seriesId)) + { + await ScrobbleWantToReadUpdate(uId, wtr.Id, true); + } + + // Handle ratings specific to the series + var ratings = await _unitOfWork.UserRepository.GetSeriesWithRatings(uId); + foreach (var rating in ratings.Where(rating => rating.SeriesId == seriesId)) + { + await ScrobbleRatingUpdate(uId, rating.SeriesId, rating.Rating); + } + + // Handle progress updates for the specific series + var seriesProgress = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync( + series.LibraryId, + uId, + new UserParams(), + new FilterDto + { + ReadStatus = new ReadStatus + { + Read = true, + InProgress = true, + NotRead = false + }, + Libraries = new List { series.LibraryId }, + SeriesNameQuery = series.Name + }); + + foreach (var progress in seriesProgress.Where(progress => progress.Id == seriesId)) + { + if (progress.PagesRead > 0) + { + await ScrobbleReadingUpdate(uId, progress.Id); + } + } } } @@ -594,8 +767,10 @@ public class ScrobblingService : IScrobblingService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ClearProcessedEvents() { - var events = await _unitOfWork.ScrobbleRepository.GetProcessedEvents(7); + const int daysAgo = 7; + var events = await _unitOfWork.ScrobbleRepository.GetProcessedEvents(daysAgo); _unitOfWork.ScrobbleRepository.Remove(events); + _logger.LogInformation("Removing {Count} scrobble events that have been processed {DaysAgo}+ days ago", events.Count, daysAgo); await _unitOfWork.CommitAsync(); } @@ -608,9 +783,7 @@ public class ScrobblingService : IScrobblingService public async Task ProcessUpdatesSinceLastSync() { // Check how many scrobble events we have available then only do those. - _logger.LogInformation("Starting Scrobble Processing"); var userRateLimits = new Dictionary(); - var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); var progressCounter = 0; @@ -642,50 +815,112 @@ public class ScrobblingService : IScrobblingService .Where(e => !errors.Contains(e.SeriesId)) .ToList(); - var decisions = addToWantToRead - .GroupBy(item => new { item.SeriesId, item.AppUserId }) - .Select(group => new - { - group.Key.SeriesId, - UserId = group.Key.AppUserId, - Event = group.First(), - Decision = group.Count() - removeWantToRead - .Count(removeItem => removeItem.SeriesId == group.Key.SeriesId && removeItem.AppUserId == group.Key.AppUserId) - }) - .Where(d => d.Decision > 0) - .Select(d => d.Event) - .ToList(); + var decisions = CalculateNetWantToReadDecisions(addToWantToRead, removeWantToRead); - // For all userIds, ensure that we can connect and have access - var usersToScrobble = readEvents.Select(r => r.AppUser) - .Concat(addToWantToRead.Select(r => r.AppUser)) - .Concat(removeWantToRead.Select(r => r.AppUser)) - .Concat(ratingEvents.Select(r => r.AppUser)) - .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) - .DistinctBy(u => u.Id) - .ToList(); - foreach (var user in usersToScrobble) + // Clear any events that are already on error table + var erroredEvents = await _unitOfWork.ScrobbleRepository.GetAllEventsWithSeriesIds(errors); + if (erroredEvents.Count > 0) { - await SetAndCheckRateLimit(userRateLimits, user, license.Value); + _unitOfWork.ScrobbleRepository.Remove(erroredEvents); + await _unitOfWork.CommitAsync(); } - var totalProgress = readEvents.Count + decisions.Count + ratingEvents.Count + decisions.Count; + var totalEvents = readEvents.Count + decisions.Count + ratingEvents.Count; + if (totalEvents == 0) return; + + // Get all the applicable users to scrobble and set their rate limits + var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); + var usersToScrobble = await PrepareUsersToScrobble(readEvents, addToWantToRead, removeWantToRead, ratingEvents, userRateLimits, license); + + + _logger.LogInformation("Scrobble Processing Details:" + + "\n Read Events: {ReadEventsCount}" + + "\n Want to Read Events: {WantToReadEventsCount}" + + "\n Rating Events: {RatingEventsCount}" + + "\n Users to Scrobble: {UsersToScrobbleCount}" + + "\n Total Events to Process: {TotalEvents}", + readEvents.Count, + decisions.Count, + ratingEvents.Count, + usersToScrobble.Count, + totalEvents); - _logger.LogInformation("Found {TotalEvents} Scrobble Events", totalProgress); try { - // Recalculate the highest volume/chapter - foreach (var readEvt in readEvents) - { - readEvt.VolumeNumber = - (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, - readEvt.AppUser.Id); - readEvt.ChapterNumber = - await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(readEvt.SeriesId, - readEvt.AppUser.Id); - _unitOfWork.ScrobbleRepository.Update(readEvt); - } - progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto() + progressCounter = await ProcessReadEvents(readEvents, userRateLimits, usersToScrobble, totalEvents, progressCounter); + + progressCounter = await ProcessRatingEvents(ratingEvents, userRateLimits, usersToScrobble, totalEvents, progressCounter); + + progressCounter = await ProcessWantToReadRatingEvents(decisions, userRateLimits, usersToScrobble, totalEvents, progressCounter); + } + catch (FlurlHttpException ex) + { + _logger.LogError(ex, "Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); + return; + } + + + await SaveToDb(progressCounter, true); + _logger.LogInformation("Scrobbling Events is complete"); + + // Cleanup any events that are due to bugs or legacy + try + { + var eventsWithoutAnilistToken = (await _unitOfWork.ScrobbleRepository.GetEvents()) + .Where(e => !e.IsProcessed && !e.IsErrored) + .Where(e => string.IsNullOrEmpty(e.AppUser.AniListAccessToken)); + + _unitOfWork.ScrobbleRepository.Remove(eventsWithoutAnilistToken); + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when trying to delete old scrobble events when the user has no active token"); + } + } + + /// + /// Calculates the net want-to-read decisions by considering all events. + /// Returns events that represent the final state for each user/series pair. + /// + /// List of events for adding to want-to-read + /// List of events for removing from want-to-read + /// List of events that represent the final state (add or remove) + private static List CalculateNetWantToReadDecisions(List addEvents, List removeEvents) + { + // Create a dictionary to track the latest event for each user/series combination + var latestEvents = new Dictionary<(int SeriesId, int AppUserId), ScrobbleEvent>(); + + // Process all add events + foreach (var addEvent in addEvents) + { + var key = (addEvent.SeriesId, addEvent.AppUserId); + + if (latestEvents.TryGetValue(key, out var value) && addEvent.CreatedUtc <= value.CreatedUtc) continue; + + value = addEvent; + latestEvents[key] = value; + } + + // Process all remove events + foreach (var removeEvent in removeEvents) + { + var key = (removeEvent.SeriesId, removeEvent.AppUserId); + + if (latestEvents.TryGetValue(key, out var value) && removeEvent.CreatedUtc <= value.CreatedUtc) continue; + + value = removeEvent; + latestEvents[key] = value; + } + + // Return all events that represent the final state + return latestEvents.Values.ToList(); + } + + private async Task ProcessWantToReadRatingEvents(List decisions, Dictionary userRateLimits, List usersToScrobble, int totalEvents, int progressCounter) + { + progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, + totalEvents, evt => Task.FromResult(new ScrobbleDto() { Format = evt.Format, AniListId = evt.AniListId, @@ -696,14 +931,29 @@ public class ScrobblingService : IScrobblingService AniListToken = evt.AppUser.AniListAccessToken, SeriesName = evt.Series.Name, LocalizedSeriesName = evt.Series.LocalizedName, - ScrobbleDateUtc = evt.LastModifiedUtc, - Year = evt.Series.Metadata.ReleaseYear, - StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), - LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), - }); + Year = evt.Series.Metadata.ReleaseYear + })); - progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, - totalProgress, evt => Task.FromResult(new ScrobbleDto() + // After decisions, we need to mark all the want to read and remove from want to read as completed + if (decisions.Any(d => d.IsProcessed)) + { + foreach (var scrobbleEvent in decisions.Where(d => d.IsProcessed)) + { + scrobbleEvent.IsProcessed = true; + scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; + _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); + } + await _unitOfWork.CommitAsync(); + } + + return progressCounter; + } + + private async Task ProcessRatingEvents(List ratingEvents, Dictionary userRateLimits, List usersToScrobble, + int totalEvents, int progressCounter) + { + return await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, + totalEvents, evt => Task.FromResult(new ScrobbleDto() { Format = evt.Format, AniListId = evt.AniListId, @@ -715,53 +965,68 @@ public class ScrobblingService : IScrobblingService Rating = evt.Rating, Year = evt.Series.Metadata.ReleaseYear })); - - progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, - totalProgress, evt => Task.FromResult(new ScrobbleDto() - { - Format = evt.Format, - AniListId = evt.AniListId, - MALId = (int?) evt.MalId, - ScrobbleEventType = evt.ScrobbleEventType, - ChapterNumber = evt.ChapterNumber, - VolumeNumber = (int?) evt.VolumeNumber, - AniListToken = evt.AppUser.AniListAccessToken, - SeriesName = evt.Series.Name, - LocalizedSeriesName = evt.Series.LocalizedName, - Year = evt.Series.Metadata.ReleaseYear - })); - - // After decisions, we need to mark all the want to read and remove from want to read as completed - if (decisions.All(d => d.IsProcessed)) - { - foreach (var scrobbleEvent in addToWantToRead) - { - scrobbleEvent.IsProcessed = true; - scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); - } - foreach (var scrobbleEvent in removeWantToRead) - { - scrobbleEvent.IsProcessed = true; - scrobbleEvent.ProcessDateUtc = DateTime.UtcNow; - _unitOfWork.ScrobbleRepository.Update(scrobbleEvent); - } - await _unitOfWork.CommitAsync(); - } - } - catch (FlurlHttpException) - { - _logger.LogError("Kavita+ API or a Scrobble service may be experiencing an outage. Stopping sending data"); - return; - } - - - await SaveToDb(progressCounter, true); - _logger.LogInformation("Scrobbling Events is complete"); } - private async Task ProcessEvents(IEnumerable events, IDictionary userRateLimits, + private async Task ProcessReadEvents(List readEvents, Dictionary userRateLimits, List usersToScrobble, int totalEvents, + int progressCounter) + { + // Recalculate the highest volume/chapter + foreach (var readEvt in readEvents) + { + // Note: this causes skewing in the scrobble history because it makes it look like there are duplicate events + readEvt.VolumeNumber = + (int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId, + readEvt.AppUser.Id); + readEvt.ChapterNumber = + await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(readEvt.SeriesId, + readEvt.AppUser.Id); + _unitOfWork.ScrobbleRepository.Update(readEvt); + } + + return await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalEvents, + async evt => new ScrobbleDto() + { + Format = evt.Format, + AniListId = evt.AniListId, + MALId = (int?) evt.MalId, + ScrobbleEventType = evt.ScrobbleEventType, + ChapterNumber = evt.ChapterNumber, + VolumeNumber = (int?) evt.VolumeNumber, + AniListToken = evt.AppUser.AniListAccessToken!, + SeriesName = evt.Series.Name, + LocalizedSeriesName = evt.Series.LocalizedName, + ScrobbleDateUtc = evt.LastModifiedUtc, + Year = evt.Series.Metadata.ReleaseYear, + StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id), + LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id), + }); + } + + + private async Task> PrepareUsersToScrobble(List readEvents, List addToWantToRead, List removeWantToRead, List ratingEvents, + Dictionary userRateLimits, ServerSetting license) + { + // For all userIds, ensure that we can connect and have access + var usersToScrobble = readEvents.Select(r => r.AppUser) + .Concat(addToWantToRead.Select(r => r.AppUser)) + .Concat(removeWantToRead.Select(r => r.AppUser)) + .Concat(ratingEvents.Select(r => r.AppUser)) + .Where(user => !string.IsNullOrEmpty(user.AniListAccessToken)) + .Where(user => user.UserPreferences.AniListScrobblingEnabled) + .DistinctBy(u => u.Id) + .ToList(); + + foreach (var user in usersToScrobble) + { + await SetAndCheckRateLimit(userRateLimits, user, license.Value); + } + + return usersToScrobble; + } + + + private async Task ProcessEvents(IEnumerable events, Dictionary userRateLimits, int usersToScrobble, int progressCounter, int totalProgress, Func> createEvent) { var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey); @@ -769,27 +1034,29 @@ public class ScrobblingService : IScrobblingService { _logger.LogDebug("Processing Reading Events: {Count} / {Total}", progressCounter, totalProgress); progressCounter++; + // Check if this media item can even be processed for this user - if (!DoesUserHaveProviderAndValid(evt)) + if (!CanProcessScrobbleEvent(evt)) { continue; } - if (_tokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) + if (TokenService.HasTokenExpired(evt.AppUser.AniListAccessToken)) { _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() { - Comment = "AniList token has expired and needs rotating. Scrobbles wont work until then", - Details = $"User: {evt.AppUser.UserName}", + Comment = "AniList token has expired and needs rotating. Scrobbling wont work until then", + Details = $"User: {evt.AppUser.UserName}, Expired: {TokenService.GetTokenExpiry(evt.AppUser.AniListAccessToken)}", LibraryId = evt.LibraryId, SeriesId = evt.SeriesId }); await _unitOfWork.CommitAsync(); - return 0; + continue; } - if (await _unitOfWork.ExternalSeriesMetadataRepository.IsBlacklistedSeries(evt.SeriesId)) + if (evt.Series.IsBlacklisted || evt.Series.DontMatch) { + _logger.LogInformation("Series {SeriesName} ({SeriesId}) can't be matched and thus cannot scrobble this event", evt.Series.Name, evt.SeriesId); _unitOfWork.ScrobbleRepository.Attach(new ScrobbleError() { Comment = UnknownSeriesErrorMessage, @@ -798,11 +1065,12 @@ public class ScrobblingService : IScrobblingService SeriesId = evt.SeriesId }); evt.IsErrored = true; - evt.ErrorDetails = "Series cannot be matched for Scrobbling"; + evt.ErrorDetails = UnknownSeriesErrorMessage; evt.ProcessDateUtc = DateTime.UtcNow; _unitOfWork.ScrobbleRepository.Update(evt); await _unitOfWork.CommitAsync(); - return 0; + + continue; } var count = await SetAndCheckRateLimit(userRateLimits, evt.AppUser, license.Value); @@ -816,6 +1084,17 @@ 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 or 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; @@ -830,16 +1109,16 @@ public class ScrobblingService : IScrobblingService { if (ex.Message.Contains("Access token is invalid")) { - _logger.LogCritical("Access Token for UserId: {UserId} needs to be rotated to continue scrobbling", evt.AppUser.Id); + _logger.LogCritical(ex, "Access Token for UserId: {UserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id); evt.IsErrored = true; evt.ErrorDetails = AccessTokenErrorMessage; _unitOfWork.ScrobbleRepository.Update(evt); - return progressCounter; } } - catch (Exception) + catch (Exception ex) { /* Swallow as it's already been handled in PostScrobbleUpdate */ + _logger.LogError(ex, "Error processing event {EventId}", evt.Id); } await SaveToDb(progressCounter); // We can use count to determine how long to sleep based on rate gain. It might be specific to AniList, but we can model others @@ -853,46 +1132,37 @@ public class ScrobblingService : IScrobblingService private async Task SaveToDb(int progressCounter, bool force = false) { - if (!force || progressCounter % 5 == 0) + if ((force || progressCounter % 5 == 0) && _unitOfWork.HasChanges()) { - _logger.LogDebug("Saving Progress"); + _logger.LogDebug("Saving Scrobbling Event Processing Progress"); await _unitOfWork.CommitAsync(); } } - private static bool DoesUserHaveProviderAndValid(ScrobbleEvent readEvent) + + private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent) { var userProviders = GetUserProviders(readEvent.AppUser); - if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any()) + switch (readEvent.Series.Library.Type) { - return true; + case LibraryType.Manga when MangaProviders.Intersect(userProviders).Any(): + case LibraryType.Comic when + ComicProviders.Intersect(userProviders).Any(): + case LibraryType.Book when + BookProviders.Intersect(userProviders).Any(): + case LibraryType.LightNovel when + LightNovelProviders.Intersect(userProviders).Any(): + return true; + default: + return false; } - - if (readEvent.Series.Library.Type == LibraryType.Comic && - ComicProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.Book && - BookProviders.Intersect(userProviders).Any()) - { - return true; - } - - if (readEvent.Series.Library.Type == LibraryType.LightNovel && - LightNovelProviders.Intersect(userProviders).Any()) - { - return true; - } - - return false; } - private static IList GetUserProviders(AppUser appUser) + private static List GetUserProviders(AppUser appUser) { var providers = new List(); if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList); + return providers; } @@ -912,12 +1182,18 @@ public class ScrobblingService : IScrobblingService var value = tokens[index]; if (typeof(T) == typeof(int?)) { - if (int.TryParse(value, out var intValue)) + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue; } + else if (typeof(T) == typeof(int)) + { + if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) + return (T)(object)intValue; + return default; + } else if (typeof(T) == typeof(long?)) { - if (long.TryParse(value, out var longValue)) + if (long.TryParse(value, CultureInfo.InvariantCulture, out var longValue)) return (T)(object)longValue; } else if (typeof(T) == typeof(string)) @@ -926,9 +1202,43 @@ public class ScrobblingService : IScrobblingService } } - return default(T?); + return default; } + /// + /// Generate a URL from a given ID and website + /// + /// Type of the ID (e.g., int, long, string) + /// The ID to embed in the URL + /// The base website URL + /// The generated URL or null if the website is not supported + public static string? GenerateUrl(T id, string website) + { + if (!WeblinkExtractionMap.ContainsKey(website)) + { + return null; // Unsupported website + } + + if (Equals(id, default(T))) + { + throw new ArgumentNullException(nameof(id), "ID cannot be null."); + } + + // Ensure the type of the ID matches supported types + if (typeof(T) == typeof(int) || typeof(T) == typeof(long) || typeof(T) == typeof(string)) + { + return $"{website}{id}"; + } + + throw new ArgumentException("Unsupported ID type. Supported types are int, long, and string.", nameof(id)); + } + + public static string CreateUrl(string url, long? id) + { + return id is null or 0 ? string.Empty : $"{url}{id}/"; + } + + private async Task SetAndCheckRateLimit(IDictionary userRateLimits, AppUser user, string license) { if (string.IsNullOrEmpty(user.AniListAccessToken)) return 0; @@ -942,7 +1252,7 @@ public class ScrobblingService : IScrobblingService } catch (Exception ex) { - _logger.LogInformation("User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); + _logger.LogInformation(ex, "User {UserName} had an issue figuring out rate: {Message}", user.UserName, ex.Message); userRateLimits.Add(user.Id, 0); } @@ -955,9 +1265,4 @@ public class ScrobblingService : IScrobblingService return count; } - public static string CreateUrl(string url, long? id) - { - if (id is null or 0) return string.Empty; - return $"{url}{id}/"; - } } diff --git a/API/Services/Plus/SmartCollectionSyncService.cs b/API/Services/Plus/SmartCollectionSyncService.cs new file mode 100644 index 000000000..1bd0dfb6b --- /dev/null +++ b/API/Services/Plus/SmartCollectionSyncService.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.Scrobbling; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers; +using API.SignalR; +using Flurl.Http; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Microsoft.Extensions.Logging; + +namespace API.Services.Plus; +#nullable enable + +internal sealed class SeriesCollection +{ + public required IList Series { get; set; } + public required string Summary { get; set; } + public required string Title { get; set; } + /// + /// Total items in the source, not what was matched + /// + public int TotalItems { get; set; } +} + +/// +/// Responsible to synchronize Collection series from non-Kavita sources +/// +public interface ISmartCollectionSyncService +{ + /// + /// Synchronize all collections + /// + /// + Task Sync(); + /// + /// Synchronize a collection + /// + /// + /// + Task Sync(int collectionId); +} + +public class SmartCollectionSyncService : ISmartCollectionSyncService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IEventHub _eventHub; + private readonly ILicenseService _licenseService; + + private const int SyncDelta = -2; + // Allow 50 requests per 24 hours + private static readonly RateLimiter RateLimiter = new RateLimiter(50, TimeSpan.FromHours(24), false); + + + public SmartCollectionSyncService(IUnitOfWork unitOfWork, ILogger logger, + IEventHub eventHub, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _eventHub = eventHub; + _licenseService = licenseService; + } + + /// + /// For every Sync-eligible collection, synchronize with upstream + /// + /// + public async Task Sync() + { + if (!await _licenseService.HasActiveLicense()) return; + var expirationTime = DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour); + var collections = (await _unitOfWork.CollectionTagRepository.GetAllCollectionsForSyncing(expirationTime)) + .Where(CanSync) + .ToList(); + + _logger.LogInformation("Found {Count} collections to synchronize", collections.Count); + foreach (var collection in collections) + { + try + { + await SyncCollection(collection); + } + catch (RateLimitException) + { + break; + } + } + + _logger.LogInformation("Synchronization complete"); + } + + public async Task Sync(int collectionId) + { + if (!await _licenseService.HasActiveLicense()) return; + var collection = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId, CollectionIncludes.Series); + if (!CanSync(collection)) + { + _logger.LogInformation("Requested to sync {CollectionName} but not applicable to sync", collection!.Title); + return; + } + + try + { + await SyncCollection(collection!); + } catch (RateLimitException) {/* Swallow */} + } + + private static bool CanSync(AppUserCollection? collection) + { + if (collection is not {Source: ScrobbleProvider.Mal}) return false; + if (string.IsNullOrEmpty(collection.SourceUrl)) return false; + if (collection.LastSyncUtc.Truncate(TimeSpan.TicksPerHour) >= DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour)) return false; + return true; + } + + private async Task SyncCollection(AppUserCollection collection) + { + if (!RateLimiter.TryAcquire(string.Empty)) + { + // Request not allowed due to rate limit + _logger.LogDebug("Rate Limit hit for Smart Collection Sync"); + throw new RateLimitException(); + } + + var info = await GetStackInfo(GetStackId(collection.SourceUrl!)); + if (info == null) + { + _logger.LogInformation("Unable to find collection through Kavita+"); + return; + } + + // Check each series in the collection against what's in the target + // For everything that's not there, link it up for this user. + _logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, 0, info.TotalItems, ProgressEventType.Started)); + + var missingCount = 0; + var missingSeries = new StringBuilder(); + var counter = -1; + foreach (var seriesInfo in info.Series.OrderBy(s => s.SeriesName)) + { + counter++; + try + { + // Normalize series name and localized name + var normalizedSeriesName = seriesInfo.SeriesName?.ToNormalized(); + var normalizedLocalizedSeriesName = seriesInfo.LocalizedSeriesName?.ToNormalized(); + + // Search for existing series in the collection + var formats = seriesInfo.PlusMediaFormat.GetMangaFormats(); + var existingSeries = collection.Items.FirstOrDefault(s => + (s.Name.ToNormalized() == normalizedSeriesName || + s.NormalizedName == normalizedSeriesName || + s.LocalizedName.ToNormalized() == normalizedLocalizedSeriesName || + s.NormalizedLocalizedName == normalizedLocalizedSeriesName || + + s.NormalizedName == normalizedLocalizedSeriesName || + s.NormalizedLocalizedName == normalizedSeriesName) + && formats.Contains(s.Format)); + + _logger.LogDebug("Trying to find {SeriesName} with formats ({Formats}) within Kavita for linking. Found: {ExistingSeriesName} ({ExistingSeriesId})", + seriesInfo.SeriesName, formats, existingSeries?.Name, existingSeries?.Id); + + if (existingSeries != null) + { + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated)); + continue; + } + + // Series not found in the collection, try to find it in the server + var newSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(seriesInfo.SeriesName, + seriesInfo.LocalizedSeriesName, + formats, collection.AppUserId); + + collection.Items ??= new List(); + if (newSeries != null) + { + // Add the new series to the collection + collection.Items.Add(newSeries); + + } + else + { + _logger.LogDebug("{Series} not found in the server", seriesInfo.SeriesName); + missingCount++; + missingSeries.Append( + $"{seriesInfo.SeriesName}"); + missingSeries.Append("
"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An exception occured when linking up a series to the collection. Skipping"); + missingCount++; + missingSeries.Append( + $"{seriesInfo.SeriesName}"); + missingSeries.Append("
"); + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated)); + } + + // At this point, all series in the info have been checked and added if necessary + // You may want to commit changes to the database if needed + collection.LastSyncUtc = DateTime.UtcNow.Truncate(TimeSpan.TicksPerHour); + collection.TotalSourceCount = info.TotalItems; + collection.Summary = info.Summary; + collection.MissingSeriesFromSource = missingSeries.ToString(); + + _unitOfWork.CollectionTagRepository.Update(collection); + + try + { + await _unitOfWork.CommitAsync(); + + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collection); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, info.TotalItems, info.TotalItems, ProgressEventType.Ended)); + + await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated, + MessageFactory.CollectionUpdatedEvent(collection.Id), false); + + _logger.LogInformation("Finished Syncing Collection {CollectionName} - Missing {MissingCount} series", + collection.Title, missingCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error during saving the collection"); + } + } + + + + private static long GetStackId(string url) + { + var tokens = url.Split("/"); + return long.Parse(tokens[^1], CultureInfo.InvariantCulture); + } + + private async Task GetStackInfo(long stackId) + { + _logger.LogDebug("Fetching Kavita+ for MAL Stack"); + + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + + var seriesForStack = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stack?stackId=" + stackId) + .WithKavitaPlusHeaders(license) + .GetJsonAsync(); + + return seriesForStack; + } +} diff --git a/API/Services/Plus/WantToReadSyncService.cs b/API/Services/Plus/WantToReadSyncService.cs new file mode 100644 index 000000000..07861710c --- /dev/null +++ b/API/Services/Plus/WantToReadSyncService.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Data.Repositories; +using API.DTOs.Recommendation; +using API.DTOs.SeriesDetail; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using Flurl.Http; +using Hangfire; +using Kavita.Common; +using Microsoft.Extensions.Logging; +using Org.BouncyCastle.Bcpg.Sig; + +namespace API.Services.Plus; + + +public interface IWantToReadSyncService +{ + Task Sync(); +} + +/// +/// Responsible for syncing Want To Read from upstream providers with Kavita +/// +public class WantToReadSyncService : IWantToReadSyncService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly ILicenseService _licenseService; + + public WantToReadSyncService(IUnitOfWork unitOfWork, ILogger logger, ILicenseService licenseService) + { + _unitOfWork = unitOfWork; + _logger = logger; + _licenseService = licenseService; + } + + public async Task Sync() + { + if (!await _licenseService.HasActiveLicense()) return; + + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + + var users = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.WantToRead | AppUserIncludes.UserPreferences); + foreach (var user in users.Where(u => u.UserPreferences.WantToReadSync)) + { + if (string.IsNullOrEmpty(user.MalUserName) && string.IsNullOrEmpty(user.AniListAccessToken)) continue; + + try + { + _logger.LogInformation("Syncing want to read for user: {UserName}", user.UserName); + var wantToReadSeries = + await ( + $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/want-to-read?malUsername={user.MalUserName}&aniListToken={user.AniListAccessToken}") + .WithKavitaPlusHeaders(license) + .WithTimeout( + TimeSpan.FromSeconds(120)) // Give extra time as MAL + AniList can result in a lot of data + .GetJsonAsync>(); + + // Match the series (note: There may be duplicates in the final result) + foreach (var unmatchedSeries in wantToReadSeries) + { + var match = await _unitOfWork.SeriesRepository.MatchSeries(unmatchedSeries); + if (match == null) + { + continue; + } + + // There is a match, add it + user.WantToRead.Add(new AppUserWantToRead() + { + SeriesId = match.Id, + }); + _logger.LogDebug("Added {MatchName} ({Format}) to Want to Read", match.Name, match.Format); + } + + // Remove existing Want to Read that are duplicates + user.WantToRead = user.WantToRead.DistinctBy(d => d.SeriesId).ToList(); + + // TODO: Need to write in the history table the last sync time + + // Save the left over entities + _unitOfWork.UserRepository.Update(user); + await _unitOfWork.CommitAsync(); + + // Trigger CleanupService to cleanup any series in WantToRead that don't belong + RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when processing want to read series sync for {User}", user.UserName); + } + } + + } + + // Allow syncing if there are any libraries that have an appropriate Provider, the user has the appropriate token, and the last Sync validates + // private async Task CanSync(AppUser? user) + // { + // + // if (collection is not {Source: ScrobbleProvider.Mal}) return false; + // if (string.IsNullOrEmpty(collection.SourceUrl)) return false; + // if (collection.LastSyncUtc.Truncate(TimeSpan.TicksPerHour) >= DateTime.UtcNow.AddDays(SyncDelta).Truncate(TimeSpan.TicksPerHour)) return false; + // return true; + // } +} diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 19548e0a6..3b3cb37d5 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; @@ -51,8 +52,9 @@ public class ReaderService : IReaderService private readonly IImageService _imageService; private readonly IDirectoryService _directoryService; private readonly IScrobblingService _scrobblingService; - private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default; - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default; + private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default; + private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default; + private readonly ChapterSortComparerSpecialsLast _chapterSortComparerSpecialsLast = ChapterSortComparerSpecialsLast.Default; private const float MinWordsPerHour = 10260F; private const float MaxWordsPerHour = 30000F; @@ -75,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}")); } /// @@ -120,6 +122,7 @@ public class ReaderService : IReaderService var seenVolume = new Dictionary(); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); if (series == null) throw new KavitaException("series-doesnt-exist"); + foreach (var chapter in chapters) { var userProgress = GetUserProgressForChapter(user, chapter); @@ -132,7 +135,7 @@ public class ReaderService : IReaderService VolumeId = chapter.VolumeId, SeriesId = seriesId, ChapterId = chapter.Id, - LibraryId = series.LibraryId + LibraryId = series.LibraryId, }); } else @@ -142,13 +145,14 @@ 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)); // Send out volume events for each distinct volume - if (!seenVolume.ContainsKey(chapter.VolumeId)) + if (seenVolume.TryAdd(chapter.VolumeId, true)) { - seenVolume[chapter.VolumeId] = true; await _eventHub.SendMessageAsync(MessageFactory.UserProgressUpdate, MessageFactory.UserProgressUpdateEvent(user.Id, user.UserName!, seriesId, chapter.VolumeId, 0, chapters.Where(c => c.VolumeId == chapter.VolumeId).Sum(c => c.Pages))); @@ -176,6 +180,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)); @@ -198,7 +203,7 @@ public class ReaderService : IReaderService /// Must have Progresses populated /// /// - private static AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) + private AppUserProgress? GetUserProgressForChapter(AppUser user, Chapter chapter) { AppUserProgress? userProgress = null; @@ -218,11 +223,12 @@ public class ReaderService : IReaderService var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList(); if (progresses.Count > 1) { - user.Progresses = new List - { - user.Progresses.First() - }; + var highestProgress = progresses.Max(x => x.PagesRead); + var firstProgress = progresses.OrderBy(p => p.LastModifiedUtc).First(); + firstProgress.PagesRead = highestProgress; + user.Progresses = [firstProgress]; userProgress = user.Progresses.First(); + _logger.LogInformation("Trying to save progress and multiple progress entries exist, deleting and rewriting with highest progress rate: {@Progress}", userProgress); } } @@ -265,7 +271,7 @@ public class ReaderService : IReaderService SeriesId = progressDto.SeriesId, ChapterId = progressDto.ChapterId, LibraryId = progressDto.LibraryId, - BookScrollId = progressDto.BookScrollId + BookScrollId = progressDto.BookScrollId, }); _unitOfWork.UserRepository.Update(userWithProgress); } @@ -279,6 +285,9 @@ public class ReaderService : IReaderService _unitOfWork.AppUserProgressRepository.Update(userProgress); } + _logger.LogDebug("Saving Progress on Chapter {ChapterId} from Series {SeriesId} to {PageNum}", progressDto.ChapterId, progressDto.SeriesId, progressDto.PageNum); + userProgress?.MarkModified(); + if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); @@ -347,11 +356,23 @@ public class ReaderService : IReaderService return page; } + private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter) + { + if (volume.IsSpecial()) + { + // Handle specials by sorting on their Filename aka Range + return GetNextChapterId(volume.Chapters.OrderBy(x => x.SortOrder), currentChapter.SortOrder, dto => dto.SortOrder); + } + + return -1; + } + + /// /// Tries to find the next logical Chapter /// /// - /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> SP 01 → SP 02 + /// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> (Annual 1 -> Annual 2) -> (SP 01 → SP 02) /// /// /// @@ -360,112 +381,88 @@ public class ReaderService : IReaderService /// -1 if nothing can be found public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) { - var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)) - .ToList(); - var currentVolume = volumes.Single(v => v.Id == volumeId); - var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); - if (currentVolume.MinNumber == 0) + var currentVolume = volumes.FirstOrDefault(v => v.Id == volumeId); + if (currentVolume == null) { - // Handle specials by sorting on their Filename aka Range - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Range, dto => dto.Range); + // Handle the case where the current volume is not found + return -1; + } + + var currentChapter = currentVolume.Chapters.FirstOrDefault(c => c.Id == currentChapterId); + if (currentChapter == null) + { + // Handle the case where the current chapter is not found + return -1; + } + + var currentVolumeIndex = volumes.IndexOf(currentVolume); + var chapterId = -1; + + if (currentVolume.IsSpecial()) + { + // Handle specials by sorting on their Range + chapterId = GetNextSpecialChapter(currentVolume, currentChapter); + return chapterId; + } + + if (currentVolume.IsLooseLeaf()) + { + // Handle loose-leaf chapters + chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.SortOrder), + currentChapter.SortOrder, + dto => dto.SortOrder); if (chapterId > 0) return chapterId; + + // Check specials next, as that is the order + if (currentVolumeIndex + 1 >= volumes.Count) return -1; // There are no special volumes, so there is nothing + + var specialVolume = volumes[currentVolumeIndex + 1]; + if (!specialVolume.IsSpecial()) return -1; + return specialVolume.Chapters.OrderByNatural(c => c.Range).FirstOrDefault()?.Id ?? -1; } - var next = false; - foreach (var volume in volumes) + // Check within the current volume if the next chapter within it can be next + var chapters = currentVolume.Chapters.OrderBy(c => c.MinNumber).ToList(); + var currentChapterIndex = chapters.IndexOf(currentChapter); + if (currentChapterIndex < chapters.Count - 1) { - var volumeNumbersMatch = volume.Name == currentVolume.Name; - if (volumeNumbersMatch && volume.Chapters.Count > 1) - { - // Handle Chapters within current Volume - // In this case, i need 0 first because 0 represents a full volume file. - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Number.AsFloat(), _chapterSortComparer), - currentChapter.Range, dto => dto.Range); - if (chapterId > 0) return chapterId; - next = true; - continue; - } - - if (volumeNumbersMatch) - { - next = true; - continue; - } - - if (!next) continue; - - // Handle Chapters within next Volume - // ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+ - var chapters = volume.Chapters.OrderBy(x => x.Number.AsDouble(), _chapterSortComparer).ToList(); - if (currentChapter.Number.Equals(Parser.DefaultChapter) && chapters[^1].Number.Equals(Parser.DefaultChapter)) - { - // We need to handle an extra check if the current chapter is the last special, as we should return -1 - if (currentChapter.IsSpecial) return -1; - - return chapters.Last().Id; - } - - var firstChapter = chapters.FirstOrDefault(); - if (firstChapter == null) break; - var isSpecial = firstChapter.IsSpecial || currentChapter.IsSpecial; - if (isSpecial) - { - var chapterId = GetNextChapterId(volume.Chapters.OrderByNatural(x => x.Number), - currentChapter.Range, dto => dto.Range); - if (chapterId > 0) return chapterId; - } else if (firstChapter.Number.AsDouble() >= currentChapter.Number.AsDouble()) return firstChapter.Id; - // If we are the last chapter and next volume is there, we should try to use it (unless it's volume 0) - else if (firstChapter.Number.AsDouble() == 0) return firstChapter.Id; - - // If on last volume AND there are no specials left, then let's return -1 - var anySpecials = volumes.Where(v => $"{v.MinNumber}" == Parser.DefaultVolume) - .SelectMany(v => v.Chapters.Where(c => c.IsSpecial)).Any(); - if (currentVolume.MinNumber != 0 && !anySpecials) - { - return -1; - } + return chapters[currentChapterIndex + 1].Id; } + // Check within the current Volume + chapterId = GetNextChapterId(chapters, currentChapter.SortOrder, dto => dto.SortOrder); + if (chapterId > 0) return chapterId; - - // If we are the last volume and we didn't find any next volume, loop back to volume 0 and give the first chapter - // This has an added problem that it will loop up to the beginning always - // Should I change this to Max number? volumes.LastOrDefault()?.Number -> volumes.Max(v => v.Number) - - if (currentVolume.MinNumber != 0 && currentVolume.MinNumber == volumes.LastOrDefault()?.MinNumber && volumes.Count > 1) + // Now check the next volume + var nextVolumeIndex = currentVolumeIndex + 1; + if (nextVolumeIndex < volumes.Count) { - var chapterVolume = volumes.FirstOrDefault(); - if (chapterVolume?.MinNumber != 0) return -1; + // Get the first chapter from the next volume + chapterId = volumes[nextVolumeIndex].Chapters.MinBy(c => c.MinNumber, _chapterSortComparerForInChapterSorting)?.Id ?? -1; + return chapterId; + } - // This is my attempt at fixing a bug where we loop around to the beginning, but I just can't seem to figure it out - // var orderedVolumes = volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default).ToList(); - // if (currentVolume.Number == orderedVolumes.FirstOrDefault().Number) - // { - // // We can move into loose leaf chapters - // //var firstLooseLeaf = volumes.LastOrDefault().Chapters.MinBy(x => x.Number.AsDouble(), _chapterSortComparer); - // var nextChapterId = GetNextChapterId( - // volumes.LastOrDefault().Chapters.OrderBy(x => x.Number.AsDouble(), _chapterSortComparer), - // "0", dto => dto.Range); - // // CHECK if we need a IsSpecial check - // if (nextChapterId > 0) return nextChapterId; - // } - - - var firstChapter = chapterVolume.Chapters.MinBy(x => x.Number.AsDouble(), _chapterSortComparer); - if (firstChapter == null) return -1; - - - return firstChapter.Id; + // We are the last volume, so we need to check loose leaf + if (currentVolumeIndex == volumes.Count - 1) + { + // Try to find the first loose-leaf chapter in this volume + var firstLooseLeafChapter = volumes.WhereLooseLeaf().FirstOrDefault()?.Chapters.MinBy(c => c.MinNumber, _chapterSortComparerForInChapterSorting); + if (firstLooseLeafChapter != null) + { + return firstLooseLeafChapter.Id; + } } return -1; } + /// /// Tries to find the prev logical Chapter /// /// - /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02 + /// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← (V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02) /// /// /// @@ -474,52 +471,76 @@ public class ReaderService : IReaderService /// -1 if nothing can be found public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) { - var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList(); + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); var currentVolume = volumes.Single(v => v.Id == volumeId); var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); - if (currentVolume.MinNumber == 0) + var chapterId = -1; + + if (currentVolume.IsSpecial()) { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Range, - dto => dto.Range); + // Check within Specials, if not set the currentVolume to Loose Leaf + chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.SortOrder).Reverse(), + currentChapter.SortOrder, + dto => dto.SortOrder); if (chapterId > 0) return chapterId; + currentVolume = volumes.Find(v => v.IsLooseLeaf()); } - var next = false; - foreach (var volume in volumes) + if (currentVolume != null && currentVolume.IsLooseLeaf()) { - if (volume.MinNumber == currentVolume.MinNumber) - { - var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting).Reverse(), - currentChapter.Range, dto => dto.Range); - if (chapterId > 0) return chapterId; - next = true; // When the diff between volumes is more than 1, we need to explicitly tell that next volume is our use case - continue; - } - if (next) - { - if (currentVolume.MinNumber - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work - var lastChapter = volume.Chapters.MaxBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting); - if (lastChapter == null) return -1; - return lastChapter.Id; - } + // If loose leaf, handle within the loose leaf. If not there, then set currentVolume to volumes.Last() where not LooseLeaf or Special + var currentVolumeChapters = currentVolume.Chapters.OrderBy(x => x.SortOrder).ToList(); + chapterId = GetPrevChapterId(currentVolumeChapters, + currentChapter.SortOrder, dto => dto.SortOrder, c => c.Id); + if (chapterId > 0) return chapterId; + currentVolume = volumes.FindLast(v => !v.IsLooseLeaf() && !v.IsSpecial()); + if (currentVolume != null) return currentVolume.Chapters.OrderBy(x => x.SortOrder).Last()?.Id ?? -1; } - var lastVolume = volumes.MaxBy(v => v.MinNumber); - if (currentVolume.MinNumber == 0 && currentVolume.MinNumber != lastVolume?.MinNumber && lastVolume?.Chapters.Count > 1) + // When we started as a special and there was no loose leafs, reset the currentVolume + if (currentVolume == null) { - var lastChapter = lastVolume.Chapters.MaxBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting); - if (lastChapter == null) return -1; - return lastChapter.Id; + currentVolume = volumes.Find(v => !v.IsLooseLeaf() && !v.IsSpecial()); + if (currentVolume == null) return -1; + return currentVolume.Chapters.OrderBy(x => x.SortOrder).Last()?.Id ?? -1; } + // At this point, only need to check within the current Volume else move 1 level back + // Check current volume + chapterId = GetPrevChapterId(currentVolume.Chapters.OrderBy(x => x.SortOrder), + currentChapter.SortOrder, dto => dto.SortOrder, c => c.Id); + if (chapterId > 0) return chapterId; + + + var currentVolumeIndex = volumes.IndexOf(currentVolume); + if (currentVolumeIndex == 0) return -1; + currentVolume = volumes[currentVolumeIndex - 1]; + if (currentVolume.IsLooseLeaf() || currentVolume.IsSpecial()) return -1; + chapterId = currentVolume.Chapters.OrderBy(x => x.SortOrder).Last().Id; + if (chapterId > 0) return chapterId; + + return -1; + } + + private static int GetPrevChapterId(IEnumerable source, float currentValue, Func selector, Func idSelector) + { + var sortedSource = source.OrderBy(selector).ToList(); + var currentChapterIndex = sortedSource.FindIndex(x => selector(x).Is(currentValue)); + + if (currentChapterIndex > 0) + { + return idSelector(sortedSource[currentChapterIndex - 1]); + } + + // There is no previous chapter return -1; } /// /// Finds the chapter to continue reading from. If a chapter has progress and not complete, return that. If not, progress in the - /// ordering (Volumes -> Loose Chapters -> Special) to find next chapter. If all are read, return first in order for series. + /// ordering (Volumes -> Loose Chapters -> Annuals -> Special) to find next chapter. If all are read, return first in order for series. /// /// /// @@ -528,45 +549,59 @@ public class ReaderService : IReaderService { var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); - if (!await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId)) - { - // I think i need a way to sort volumes last - var chapters = volumes.OrderBy(v => v.MinNumber, _chapterSortComparer).First().Chapters - .OrderBy(c => c.Number.AsFloat()) - .ToList(); + var anyUserProgress = + await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId); - // If there are specials, then return the first Non-special - if (chapters.Exists(c => c.IsSpecial)) - { - var firstChapter = chapters.FirstOrDefault(c => !c.IsSpecial); - if (firstChapter == null) - { - // If there is no non-special chapter, then return first chapter - return chapters[0]; - } + if (!anyUserProgress) + { + // I think i need a way to sort volumes last + volumes = volumes.OrderBy(v => v.MinNumber, _chapterSortComparerSpecialsLast).ToList(); - return firstChapter; - } - // Else use normal logic - return chapters[0]; - } + // Check if we have a non-loose leaf volume + var nonLooseLeafNonSpecialVolume = volumes.Find(v => !v.IsLooseLeaf() && !v.IsSpecial()); + if (nonLooseLeafNonSpecialVolume != null) + { + return nonLooseLeafNonSpecialVolume.Chapters.MinBy(c => c.SortOrder); + } + + // We only have a loose leaf or Special left + + var chapters = volumes.First(v => v.IsLooseLeaf() || v.IsSpecial()).Chapters + .OrderBy(c => c.SortOrder) + .ToList(); + + // If there are specials, then return the first Non-special + if (chapters.Exists(c => c.IsSpecial)) + { + var firstChapter = chapters.Find(c => !c.IsSpecial); + if (firstChapter == null) + { + // If there is no non-special chapter, then return first chapter + return chapters[0]; + } + + return firstChapter; + } + // Else use normal logic + return chapters[0]; + } // Loop through all chapters that are not in volume 0 var volumeChapters = volumes - .Where(v => v.MinNumber != 0) + .WhereNotLooseLeaf() .SelectMany(v => v.Chapters) .ToList(); // NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails // If there are any volumes that have progress, return those. If not, move on. var currentlyReadingChapter = volumeChapters - .OrderBy(c => c.Number.AsDouble(), _chapterSortComparer) + .OrderBy(c => c.MinNumber, _chapterSortComparerDefaultLast) .FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0); if (currentlyReadingChapter != null) return currentlyReadingChapter; // Order with volume 0 last so we prefer the natural order - return FindNextReadingChapter(volumes.OrderBy(v => v.MinNumber, SortComparerZeroLast.Default) - .SelectMany(v => v.Chapters.OrderBy(c => c.Number.AsDouble())) + return FindNextReadingChapter(volumes.OrderBy(v => v.MinNumber, _chapterSortComparerDefaultLast) + .SelectMany(v => v.Chapters.OrderBy(c => c.SortOrder)) .ToList()); } @@ -607,7 +642,7 @@ public class ReaderService : IReaderService } - private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber, Func accessor) + private static int GetNextChapterId(IEnumerable chapters, float currentChapterNumber, Func accessor) { var next = false; var chaptersList = chapters.ToList(); @@ -637,8 +672,8 @@ public class ReaderService : IReaderService foreach (var volume in volumes.OrderBy(v => v.MinNumber)) { var chapters = volume.Chapters - .Where(c => !c.IsSpecial && Parser.MaxNumberFromRange(c.Range) <= chapterNumber) - .OrderBy(c => c.Number.AsFloat()); + .Where(c => !c.IsSpecial && c.MaxNumber <= chapterNumber) + .OrderBy(c => c.MinNumber); await MarkChaptersAsRead(user, volume.SeriesId, chapters.ToList()); } } @@ -658,21 +693,23 @@ public class ReaderService : IReaderService { var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0); var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0); + return new HourEstimateRangeDto { MinHours = Math.Min(minHours, maxHours), MaxHours = Math.Max(minHours, maxHours), - AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour)) + AvgHours = wordCount / AvgWordsPerHour }; } var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0); var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0); + return new HourEstimateRangeDto { MinHours = Math.Min(minHoursPages, maxHoursPages), MaxHours = Math.Max(minHoursPages, maxHoursPages), - AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F)) + AvgHours = pageCount / AvgPagesPerMinute / 60F }; } @@ -746,7 +783,7 @@ public class ReaderService : IReaderService } var files = _directoryService.GetFilesWithExtension(outputDirectory, - Tasks.Scanner.Parser.Parser.ImageFileExtensions); + Parser.ImageFileExtensions); return CacheService.GetPageFromFiles(files, pageNum); } catch (Exception ex) @@ -768,9 +805,11 @@ public class ReaderService : IReaderService { switch(libraryType) { + case LibraryType.Image: case LibraryType.Manga: return "Chapter" + (includeSpace ? " " : string.Empty); case LibraryType.Comic: + case LibraryType.ComicVine: if (includeHash) { return "Issue #"; } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 86deed393..464c3b33c 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -2,17 +2,17 @@ using API.Data.Metadata; using API.Entities.Enums; using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; namespace API.Services; #nullable enable public interface IReadingItemService { - ComicInfo? GetComicInfo(string filePath); 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, LibraryType type); + ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type); } public class ReadingItemService : IReadingItemService @@ -21,16 +21,30 @@ public class ReadingItemService : IReadingItemService private readonly IBookService _bookService; private readonly IImageService _imageService; private readonly IDirectoryService _directoryService; - private readonly IDefaultParser _defaultParser; + private readonly ILogger _logger; + private readonly BasicParser _basicParser; + private readonly ComicVineParser _comicVineParser; + private readonly ImageParser _imageParser; + private readonly BookParser _bookParser; + private readonly PdfParser _pdfParser; + private readonly MagazineParser _magazineParser; - public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, IDirectoryService directoryService) + public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, + IDirectoryService directoryService, ILogger logger) { _archiveService = archiveService; _bookService = bookService; _imageService = imageService; _directoryService = directoryService; + _logger = logger; + + _imageParser = new ImageParser(directoryService); + _basicParser = new BasicParser(directoryService, _imageParser); + _bookParser = new BookParser(directoryService, bookService, _basicParser); + _comicVineParser = new ComicVineParser(directoryService); + _pdfParser = new PdfParser(directoryService); + _magazineParser = new MagazineParser(directoryService); - _defaultParser = new DefaultParser(directoryService); } /// @@ -38,9 +52,9 @@ public class ReadingItemService : IReadingItemService /// /// Fully qualified path of file /// - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetComicInfo(string filePath) { - if (Parser.IsEpub(filePath)) + if (Parser.IsEpub(filePath) || Parser.IsPdf(filePath)) { return _bookService.GetComicInfo(filePath); } @@ -59,78 +73,24 @@ public class ReadingItemService : IReadingItemService /// Path of a file /// /// Library type to determine parsing to perform - public ParserInfo? ParseFile(string path, string rootPath, LibraryType type) + public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) { - var info = Parse(path, rootPath, type); - if (info == null) + try { + 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; } - - - // This catches when original library type is Manga/Comic and when parsing with non - if (Parser.IsEpub(path) && Parser.ParseVolume(info.Series) != Parser.DefaultVolume) // Shouldn't this be info.Volume != DefaultVolume? - { - var hasVolumeInTitle = !Parser.ParseVolume(info.Title) - .Equals(Parser.DefaultVolume); - var hasVolumeInSeries = !Parser.ParseVolume(info.Series) - .Equals(Parser.DefaultVolume); - - if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series))) - { - // 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); - } - else - { - var info2 = _defaultParser.Parse(path, rootPath, LibraryType.Book); - info.Merge(info2); - } - - } - - // This is first time ComicInfo is called - info.ComicInfo = GetComicInfo(path); - if (info.ComicInfo == null) return info; - - if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) - { - info.Volumes = info.ComicInfo.Volume; - } - if (!string.IsNullOrEmpty(info.ComicInfo.Series)) - { - info.Series = info.ComicInfo.Series.Trim(); - } - if (!string.IsNullOrEmpty(info.ComicInfo.Number)) - { - info.Chapters = info.ComicInfo.Number; - } - - // Patch is SeriesSort from ComicInfo - if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort)) - { - info.SeriesSort = info.ComicInfo.TitleSort.Trim(); - } - - if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format)) - { - info.IsSpecial = true; - info.Chapters = Parser.DefaultChapter; - info.Volumes = Parser.DefaultVolume; - } - - if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) - { - info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); - } - - if (!string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries)) - { - info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim(); - } - - return info; } /// @@ -141,6 +101,7 @@ public class ReadingItemService : IReadingItemService /// public int GetNumberOfPages(string filePath, MangaFormat format) { + switch (format) { case MangaFormat.Archive: @@ -216,8 +177,33 @@ public class ReadingItemService : IReadingItemService /// /// /// - private ParserInfo? Parse(string path, string rootPath, LibraryType type) + private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type) { - return Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type); + if (_comicVineParser.IsApplicable(path, type)) + { + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_imageParser.IsApplicable(path, type)) + { + return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_bookParser.IsApplicable(path, type)) + { + return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_magazineParser.IsApplicable(path, type)) + { + return _magazineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_pdfParser.IsApplicable(path, type)) + { + return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + if (_basicParser.IsApplicable(path, type)) + { + return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } + + return null; } } diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 4f12f9df1..8c4f63430 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -36,8 +37,8 @@ public interface IReadingListService Task AddChaptersToReadingList(int seriesId, IList chapterIds, ReadingList readingList); - Task ValidateCblFile(int userId, CblReadingList cblReading); - Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false); + Task ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false); + Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false); Task CalculateStartAndEndDates(ReadingList readingListWithItems); /// /// This is expected to be called from ProcessSeries and has the Full Series present. Will generate on the default admin user. @@ -46,6 +47,17 @@ public interface IReadingListService /// /// Task CreateReadingListsFromSeries(Series series, Library library); + + Task CreateReadingListsFromSeries(int libraryId, int seriesId); + Task GenerateReadingListCoverImage(int readingListId); + /// + /// Check, and update if needed, all reading lists' AgeRating who contain the passed series + /// + /// The series whose age rating is being updated + /// The new (uncommited) age rating of the series + /// + /// This method does not commit changes + Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating); } /// @@ -57,21 +69,26 @@ public class ReadingListService : IReadingListService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IEventHub _eventHub; - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default; + private readonly IImageService _imageService; + private readonly IDirectoryService _directoryService; + private static readonly Regex JustNumbers = new Regex(@"^\d+$", RegexOptions.Compiled | RegexOptions.IgnoreCase, Parser.RegexTimeout); - public ReadingListService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub) + public ReadingListService(IUnitOfWork unitOfWork, ILogger logger, + IEventHub eventHub, IImageService imageService, IDirectoryService directoryService) { _unitOfWork = unitOfWork; _logger = logger; _eventHub = eventHub; + _imageService = imageService; + _directoryService = directoryService; } public static string FormatTitle(ReadingListItemDto item) { var title = string.Empty; - if (item.ChapterNumber == Parser.DefaultChapter && item.VolumeNumber != Parser.DefaultVolume) { + if (item.ChapterNumber == Parser.DefaultChapter && item.VolumeNumber != Parser.LooseLeafVolume) { title = $"Volume {item.VolumeNumber}"; } @@ -87,7 +104,13 @@ public class ReadingListService : IReadingListService { title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}"; } - } else { + } + else if (item.VolumeNumber == Parser.SpecialVolume) + { + title = specialTitle; + } + else + { title = $"Volume {specialTitle}"; } } @@ -99,15 +122,30 @@ public class ReadingListService : IReadingListService if (title != string.Empty) return title; + // item.ChapterNumber is Range if (item.ChapterNumber == Parser.DefaultChapter && !string.IsNullOrEmpty(item.ChapterTitleName)) { title = item.ChapterTitleName; } + else if (item.IsSpecial && + (!string.IsNullOrEmpty(item.ChapterTitleName) || !string.IsNullOrEmpty(chapterNum))) + { + if (!string.IsNullOrEmpty(item.ChapterTitleName)) + { + title = item.ChapterTitleName; + } + else + { + title = chapterNum; + } + + } else { title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum; } + return title; } @@ -391,8 +429,8 @@ public class ReadingListService : IReadingListService var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes)) - .OrderBy(c => Parser.MinNumberFromRange(c.Volume.Name)) - .ThenBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting) + .OrderBy(c => c.Volume.MinNumber) + .ThenBy(x => x.SortOrder) .ToList(); var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1; @@ -407,6 +445,21 @@ public class ReadingListService : IReadingListService return index > lastOrder + 1; } + /// + /// Create Reading lists from a Series + /// + /// Execute this from Hangfire + /// + /// + public async Task CreateReadingListsFromSeries(int libraryId, int seriesId) + { + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); + if (series == null || library == null) return; + + await CreateReadingListsFromSeries(series, library); + } + public async Task CreateReadingListsFromSeries(Series series, Library library) { if (!library.ManageReadingLists) return; @@ -420,6 +473,7 @@ public class ReadingListService : IReadingListService _logger.LogInformation("Processing Reading Lists for {SeriesName}", series.Name); var user = await _unitOfWork.UserRepository.GetDefaultAdminUser(); series.Metadata ??= new SeriesMetadataBuilder().Build(); + foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters)) { var pairs = new List>(); @@ -472,8 +526,12 @@ public class ReadingListService : IReadingListService if (!_unitOfWork.HasChanges()) continue; + + _imageService.UpdateColorScape(readingList); await CalculateReadingListAgeRating(readingList); + await _unitOfWork.CommitAsync(); // TODO: See if we can avoid this extra commit by reworking bottom logic + await CalculateStartAndEndDates(await _unitOfWork.ReadingListRepository.GetReadingListByTitleAsync(arcPair.Item1, user.Id, ReadingListIncludes.Items | ReadingListIncludes.ItemChapter)); await _unitOfWork.CommitAsync(); @@ -496,13 +554,13 @@ public class ReadingListService : IReadingListService var maxPairs = Math.Max(arcs.Length, arcNumbers.Length); for (var i = 0; i < maxPairs; i++) { - var arcNumber = int.MaxValue.ToString(); + var arcNumber = int.MaxValue.ToString(CultureInfo.InvariantCulture); if (arcNumbers.Length > i) { arcNumber = arcNumbers[i]; } - if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, out _)) continue; + if (string.IsNullOrEmpty(arcs[i]) || !int.TryParse(arcNumber, CultureInfo.InvariantCulture, out _)) continue; data.Add(new Tuple(arcs[i], arcNumber)); } @@ -514,19 +572,21 @@ public class ReadingListService : IReadingListService /// /// /// - public async Task ValidateCblFile(int userId, CblReadingList cblReading) + /// When true, will force ComicVine library naming conventions: Series (Year) for Series name matching. + public async Task ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false) { var importSummary = new CblImportSummaryDto { CblName = cblReading.Name, Success = CblImportResult.Success, - Results = new List(), + Results = [], SuccessfulInserts = new List() }; + if (IsCblEmpty(cblReading, importSummary, out var readingListFromCbl)) return readingListFromCbl; - // Is there another reading list with the same name? - if (await _unitOfWork.ReadingListRepository.ReadingListExists(cblReading.Name)) + // Is there another reading list with the same name on the user's account? + if (await _unitOfWork.ReadingListRepository.ReadingListExistsForUser(cblReading.Name, userId)) { importSummary.Success = CblImportResult.Fail; importSummary.Results.Add(new CblBookResult @@ -536,10 +596,12 @@ public class ReadingListService : IReadingListService }); } - var uniqueSeries = cblReading.Books.Book.Select(b => Parser.Normalize(b.Series)).Distinct().ToList(); + + var uniqueSeries = GetUniqueSeries(cblReading, useComicLibraryMatching); var userSeries = (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); - if (!userSeries.Any()) + + if (userSeries.Count == 0) { // Report that no series exist in the reading list importSummary.Results.Add(new CblBookResult @@ -568,6 +630,16 @@ public class ReadingListService : IReadingListService return importSummary; } + private static string GetSeriesFormatting(CblBook book, bool useComicLibraryMatching) + { + return useComicLibraryMatching ? $"{book.Series} ({book.Volume})" : book.Series; + } + + private static List GetUniqueSeries(CblReadingList cblReading, bool useComicLibraryMatching) + { + return cblReading.Books.Book.Select(b => Parser.Normalize(GetSeriesFormatting(b, useComicLibraryMatching))).Distinct().ToList(); + } + /// /// Imports (or pretends to) a cbl into a reading list. Call first! @@ -575,8 +647,9 @@ public class ReadingListService : IReadingListService /// /// /// + /// When true, will force ComicVine library naming conventions: Series (Year) for Series name matching. /// - public async Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false) + public async Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false) { var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems); _logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user!.UserName); @@ -588,13 +661,14 @@ public class ReadingListService : IReadingListService SuccessfulInserts = new List() }; - var uniqueSeries = cblReading.Books.Book.Select(b => Parser.Normalize(b.Series)).Distinct().ToList(); + var uniqueSeries = GetUniqueSeries(cblReading, useComicLibraryMatching); var userSeries = (await _unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); - var allSeries = userSeries.ToDictionary(s => Parser.Normalize(s.Name)); - var allSeriesLocalized = userSeries.ToDictionary(s => Parser.Normalize(s.LocalizedName)); + var allSeries = userSeries.ToDictionary(s => s.NormalizedName); + var allSeriesLocalized = userSeries.ToDictionary(s => s.NormalizedLocalizedName); var readingListNameNormalized = Parser.Normalize(cblReading.Name); + // Get all the user's reading lists var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle); if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList)) @@ -619,7 +693,7 @@ public class ReadingListService : IReadingListService readingList.Items ??= new List(); foreach (var (book, i) in cblReading.Books.Book.Select((value, i) => ( value, i ))) { - var normalizedSeries = Parser.Normalize(book.Series); + var normalizedSeries = Parser.Normalize(GetSeriesFormatting(book, useComicLibraryMatching)); if (!allSeries.TryGetValue(normalizedSeries, out var bookSeries) && !allSeriesLocalized.TryGetValue(normalizedSeries, out bookSeries)) { importSummary.Results.Add(new CblBookResult(book) @@ -631,9 +705,11 @@ public class ReadingListService : IReadingListService } // Prioritize lookup by Volume then Chapter, but allow fallback to just Chapter var bookVolume = string.IsNullOrEmpty(book.Volume) - ? Parser.DefaultVolume + ? Parser.LooseLeafVolume : book.Volume; - var matchingVolume = bookSeries.Volumes.Find(v => bookVolume == v.Name) ?? bookSeries.Volumes.Find(v => v.MinNumber == 0); + var matchingVolume = bookSeries.Volumes.Find(v => bookVolume == v.Name) + ?? bookSeries.Volumes.GetLooseLeafVolumeOrDefault() + ?? bookSeries.Volumes.GetSpecialVolumeOrDefault(); if (matchingVolume == null) { importSummary.Results.Add(new CblBookResult(book) @@ -645,11 +721,11 @@ public class ReadingListService : IReadingListService continue; } - // We need to handle chapter 0 or empty string when it's just a volume + // We need to handle default chapter or empty string when it's just a volume var bookNumber = string.IsNullOrEmpty(book.Number) ? Parser.DefaultChapter : book.Number; - var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Number == bookNumber); + var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Range == bookNumber); if (chapter == null) { importSummary.Results.Add(new CblBookResult(book) @@ -697,7 +773,10 @@ public class ReadingListService : IReadingListService } // If there are no items, don't create a blank list - if (!_unitOfWork.HasChanges() || !readingList.Items.Any()) return importSummary; + if (!_unitOfWork.HasChanges() || readingList.Items.Count == 0) return importSummary; + + + _imageService.UpdateColorScape(readingList); await _unitOfWork.CommitAsync(); @@ -707,7 +786,7 @@ public class ReadingListService : IReadingListService private static IList FindCblImportConflicts(IEnumerable userSeries) { var dict = new HashSet(); - return userSeries.Where(series => !dict.Add(Parser.Normalize(series.Name))).ToList(); + return userSeries.Where(series => !dict.Add(series.NormalizedName)).ToList(); } private static bool IsCblEmpty(CblReadingList cblReading, CblImportSummaryDto importSummary, @@ -748,4 +827,51 @@ public class ReadingListService : IReadingListService file.Close(); return cblReadingList; } + + public async Task GenerateReadingListCoverImage(int readingListId) + { + // TODO: Currently reading lists are dynamically generated at runtime. This needs to be overhauled to be generated and stored within + // the Reading List (and just expire every so often) so we can utilize ColorScapes. + // Check if a cover already exists for the reading list + // var potentialExistingCoverPath = _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, + // ImageService.GetReadingListFormat(readingListId)); + // if (_directoryService.FileSystem.File.Exists(potentialExistingCoverPath)) + // { + // // Check if we need to update CoverScape + // + // } + + var covers = await _unitOfWork.ReadingListRepository.GetRandomCoverImagesAsync(readingListId); + var destFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, + ImageService.GetReadingListFormat(readingListId)); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + destFile += settings.EncodeMediaAs.GetExtension(); + + if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; + ImageService.CreateMergedImage( + covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), + settings.CoverImageSize, + destFile); + // TODO: Refactor this so that reading lists have a dedicated cover image so we can calculate primary/secondary colors + + return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; + } + + public async Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating) + { + var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsBySeriesId(seriesId); + foreach (var readingList in readingLists) + { + var seriesIds = readingList.Items.Select(item => item.SeriesId).ToList(); + seriesIds.Remove(seriesId); // Don't get AgeRating from database + + var maxAgeRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds); + if (ageRating > maxAgeRating) + { + maxAgeRating = ageRating; + } + + readingList.AgeRating = maxAgeRating; + } + } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 1ecdd7096..7c2d4f6eb 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -1,27 +1,23 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; -using API.Constants; -using API.Controllers; using API.Data; using API.Data.Repositories; using API.DTOs; -using API.DTOs.CollectionTags; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using EasyCaching.Core; using Hangfire; using Kavita.Common; using Microsoft.Extensions.Logging; @@ -40,10 +36,11 @@ public interface ISeriesService Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true); Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true); - Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle, + Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash); Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); Task GetEstimatedChapterCreationDate(int seriesId, int userId); + } public class SeriesService : ISeriesService @@ -54,16 +51,18 @@ public class SeriesService : ISeriesService private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IReadingListService _readingListService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { ExpectedDate = null, ChapterNumber = 0, - VolumeNumber = 0 + VolumeNumber = Parser.LooseLeafVolumeNumber }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService, + IReadingListService readingListService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -71,32 +70,32 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; + _readingListService = readingListService; } /// - /// Returns the first chapter for a series to extract metadata from (ie Summary, etc) + /// Returns the first chapter for a series to extract metadata from (ie Summary, etc.) /// /// The full series with all volumes and chapters on it /// public static Chapter? GetFirstChapterForMetadata(Series series) { var sortedVolumes = series.Volumes - .Where(v => float.TryParse(v.Name, CultureInfo.InvariantCulture, out var parsedValue) && parsedValue != 0.0f) - .OrderBy(v => float.TryParse(v.Name, CultureInfo.InvariantCulture, out var parsedValue) ? parsedValue : float.MaxValue); - var minVolumeNumber = sortedVolumes - .MinBy(v => v.Name.AsFloat()); + .Where(v => v.MinNumber.IsNot(Parser.LooseLeafVolumeNumber)) + .OrderBy(v => v.MinNumber); + var minVolumeNumber = sortedVolumes.MinBy(v => v.MinNumber); var allChapters = series.Volumes - .SelectMany(v => v.Chapters.OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default)) + .SelectMany(v => v.Chapters.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default)) .ToList(); var minChapter = allChapters .FirstOrDefault(); - if (minVolumeNumber != null && minChapter != null && float.TryParse(minChapter.Number, CultureInfo.InvariantCulture, out var chapNum) && - (chapNum >= minVolumeNumber.MinNumber || chapNum == 0)) + if (minVolumeNumber != null && minChapter != null && + (minChapter.MinNumber >= minVolumeNumber.MinNumber || minChapter.MinNumber.Is(Parser.DefaultChapterNumber))) { - return minVolumeNumber.Chapters.MinBy(c => c.Number.AsFloat(), ChapterSortComparer.Default); + return minVolumeNumber.Chapters.MinBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default); } return minChapter; @@ -112,24 +111,12 @@ public class SeriesService : ISeriesService try { var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId; - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata); 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) - { - series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating; - series.Metadata.AgeRatingLocked = true; - } - if (NumberHelper.IsValidYear(updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) && series.Metadata.ReleaseYear != updateSeriesMetadataDto.SeriesMetadata.ReleaseYear) { series.Metadata.ReleaseYear = updateSeriesMetadataDto.SeriesMetadata.ReleaseYear; @@ -164,112 +151,149 @@ 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.Any()) - { - 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(); + series.Metadata.Genres ??= []; GenreHelper.UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, genre => { series.Metadata.Genres.Add(genre); }, () => series.Metadata.GenresLocked = true); } + else + { + series.Metadata.Genres = []; + } - if (updateSeriesMetadataDto.SeriesMetadata?.Tags != null && updateSeriesMetadataDto.SeriesMetadata.Tags.Any()) + if (updateSeriesMetadataDto.SeriesMetadata?.Tags is {Count: > 0}) { var allTags = (await _unitOfWork.TagRepository .GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title)))) .ToList(); - series.Metadata.Tags ??= new List(); + series.Metadata.Tags ??= []; TagHelper.UpdateTagList(updateSeriesMetadataDto.SeriesMetadata?.Tags, series, allTags, tag => { series.Metadata.Tags.Add(tag); }, () => series.Metadata.TagsLocked = true); } - - - if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata)) + else { - void HandleAddPerson(Person person) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - - series.Metadata.People ??= new List(); - var allWriters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Writer, - updateSeriesMetadataDto.SeriesMetadata!.Writers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata!.Writers, series, allWriters.AsReadOnly(), - HandleAddPerson, () => series.Metadata.WriterLocked = true); - - var allCharacters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Character, - updateSeriesMetadataDto.SeriesMetadata!.Characters.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Character, updateSeriesMetadataDto.SeriesMetadata.Characters, series, allCharacters.AsReadOnly(), - HandleAddPerson, () => series.Metadata.CharacterLocked = true); - - var allColorists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Colorist, - updateSeriesMetadataDto.SeriesMetadata!.Colorists.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Colorist, updateSeriesMetadataDto.SeriesMetadata.Colorists, series, allColorists.AsReadOnly(), - HandleAddPerson, () => series.Metadata.ColoristLocked = true); - - var allEditors = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Editor, - updateSeriesMetadataDto.SeriesMetadata!.Editors.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Editor, updateSeriesMetadataDto.SeriesMetadata.Editors, series, allEditors.AsReadOnly(), - HandleAddPerson, () => series.Metadata.EditorLocked = true); - - var allInkers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Inker, - updateSeriesMetadataDto.SeriesMetadata!.Inkers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Inker, updateSeriesMetadataDto.SeriesMetadata.Inkers, series, allInkers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.InkerLocked = true); - - var allLetterers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Letterer, - updateSeriesMetadataDto.SeriesMetadata!.Letterers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Letterer, updateSeriesMetadataDto.SeriesMetadata.Letterers, series, allLetterers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.LettererLocked = true); - - var allPencillers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Penciller, - updateSeriesMetadataDto.SeriesMetadata!.Pencillers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Penciller, updateSeriesMetadataDto.SeriesMetadata.Pencillers, series, allPencillers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.PencillerLocked = true); - - var allPublishers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Publisher, - updateSeriesMetadataDto.SeriesMetadata!.Publishers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPublishers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.PublisherLocked = true); - - var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator, - updateSeriesMetadataDto.SeriesMetadata!.Translators.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allTranslators.AsReadOnly(), - HandleAddPerson, () => series.Metadata.TranslatorLocked = true); - - var allCoverArtists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.CoverArtist, - updateSeriesMetadataDto.SeriesMetadata!.CoverArtists.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allCoverArtists.AsReadOnly(), - HandleAddPerson, () => series.Metadata.CoverArtistLocked = true); + series.Metadata.Tags = []; } + if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata?.AgeRating) + { + series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; + series.Metadata.AgeRatingLocked = true; + await _readingListService.UpdateReadingListAgeRatingForSeries(series.Id, series.Metadata.AgeRating); + } + else + { + if (!series.Metadata.AgeRatingLocked) + { + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) + { + series.Metadata.AgeRating = updatedRating; + } + } + } + + // Update people and locks if (updateSeriesMetadataDto.SeriesMetadata != null) { + series.Metadata.People ??= []; + + // Writers + if (!series.Metadata.WriterLocked || !updateSeriesMetadataDto.SeriesMetadata.WriterLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer, _unitOfWork); + } + + // Cover Artists + if (!series.Metadata.CoverArtistLocked || !updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist, _unitOfWork); + } + + // Colorists + if (!series.Metadata.ColoristLocked || !updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist, _unitOfWork); + } + + // Editors + if (!series.Metadata.EditorLocked || !updateSeriesMetadataDto.SeriesMetadata.EditorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor, _unitOfWork); + } + + // Inkers + if (!series.Metadata.InkerLocked || !updateSeriesMetadataDto.SeriesMetadata.InkerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker, _unitOfWork); + } + + // Letterers + if (!series.Metadata.LettererLocked || !updateSeriesMetadataDto.SeriesMetadata.LettererLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer, _unitOfWork); + } + + // Pencillers + if (!series.Metadata.PencillerLocked || !updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller, _unitOfWork); + } + + // Publishers + if (!series.Metadata.PublisherLocked || !updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher, _unitOfWork); + } + + // Imprints + if (!series.Metadata.ImprintLocked || !updateSeriesMetadataDto.SeriesMetadata.ImprintLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint, _unitOfWork); + } + + // Teams + if (!series.Metadata.TeamLocked || !updateSeriesMetadataDto.SeriesMetadata.TeamLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team, _unitOfWork); + } + + // Locations + if (!series.Metadata.LocationLocked || !updateSeriesMetadataDto.SeriesMetadata.LocationLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location, _unitOfWork); + } + + // Translators + if (!series.Metadata.TranslatorLocked || !updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator, _unitOfWork); + } + + // Characters + if (!series.Metadata.CharacterLocked || !updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Characters, PersonRole.Character, _unitOfWork); + } + series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; series.Metadata.PublicationStatusLocked = updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked; series.Metadata.LanguageLocked = updateSeriesMetadataDto.SeriesMetadata.LanguageLocked; @@ -279,10 +303,12 @@ public class SeriesService : ISeriesService series.Metadata.ColoristLocked = updateSeriesMetadataDto.SeriesMetadata.ColoristLocked; series.Metadata.EditorLocked = updateSeriesMetadataDto.SeriesMetadata.EditorLocked; series.Metadata.InkerLocked = updateSeriesMetadataDto.SeriesMetadata.InkerLocked; + series.Metadata.ImprintLocked = updateSeriesMetadataDto.SeriesMetadata.ImprintLocked; series.Metadata.LettererLocked = updateSeriesMetadataDto.SeriesMetadata.LettererLocked; series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked; series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked; series.Metadata.TranslatorLocked = updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked; + series.Metadata.LocationLocked = updateSeriesMetadataDto.SeriesMetadata.LocationLocked; series.Metadata.CoverArtistLocked = updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked; series.Metadata.WriterLocked = updateSeriesMetadataDto.SeriesMetadata.WriterLocked; series.Metadata.SummaryLocked = updateSeriesMetadataDto.SeriesMetadata.SummaryLocked; @@ -296,7 +322,7 @@ public class SeriesService : ISeriesService await _unitOfWork.CommitAsync(); - // Trigger code to cleanup tags, collections, people, etc + // Trigger code to clean up tags, collections, people, etc try { await _taskScheduler.CleanupDbEntries(); @@ -306,12 +332,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) @@ -323,46 +343,111 @@ public class SeriesService : ISeriesService return false; } - - private static void UpdateCollectionsList(ICollection? tags, Series series, IReadOnlyCollection allTags, - Action handleAdd) + /// + /// Exclusively for Series Update API + /// + /// + /// + /// + public static async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role, IUnitOfWork unitOfWork) { - // 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) + // TODO: Cleanup this code so we aren't using UnitOfWork like this + + // Normalize all names from the DTOs + var normalizedNames = peopleDtos + .Select(p => Parser.Normalize(p.Name)) + .Distinct() + .ToList(); + + // Bulk select people who already exist in the database + var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); + + // Use a dictionary for quick lookups + var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName) + .ToDictionary(p => p.NormalizedName, p => p); + + // List to track people that will be added to the metadata + var peopleToAdd = new List(); + + foreach (var personDto in peopleDtos) { - if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) + var normalizedPersonName = Parser.Normalize(personDto.Name); + + // Check if the person exists in the dictionary + if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out var p)) { - // Remove tag - series.Metadata.CollectionTags.Remove(existing); + // TODO: Should I add more controls here to map back? + if (personDto.AniListId > 0 && p.AniListId <= 0 && p.AniListId != personDto.AniListId) + { + p.AniListId = personDto.AniListId; + } + p.Description = string.IsNullOrEmpty(p.Description) ? personDto.Description : p.Description; + continue; // If we ever want to update metadata for existing people, we'd do it here } + + // Person doesn't exist, so create a new one + var newPerson = new Person + { + Name = personDto.Name, + NormalizedName = normalizedPersonName, + AniListId = personDto.AniListId, + Description = personDto.Description, + Asin = personDto.Asin, + CoverImage = personDto.CoverImage, + MalId = personDto.MalId, + HardcoverId = personDto.HardcoverId, + }; + + peopleToAdd.Add(newPerson); + existingPeopleDictionary[normalizedPersonName] = newPerson; } - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in tags) + // Add any new people to the database in bulk + if (peopleToAdd.Count != 0) { - var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title); - if (existingTag != null) + unitOfWork.PersonRepository.Attach(peopleToAdd); + } + + // Now that we have all the people (new and existing), update the SeriesMetadataPeople + UpdateSeriesMetadataPeople(metadata, metadata.People, existingPeopleDictionary.Values, role); + } + + private static void UpdateSeriesMetadataPeople(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable people, PersonRole role) + { + var peopleToAdd = people.ToList(); + + // Remove any people in the existing metadataPeople for this role that are no longer present in the input list + var peopleToRemove = metadataPeople + .Where(mp => mp.Role == role && peopleToAdd.TrueForAll(p => p.NormalizedName != mp.Person.NormalizedName)) + .ToList(); + + foreach (var personToRemove in peopleToRemove) + { + metadataPeople.Remove(personToRemove); + } + + // Add new people for this role if they don't already exist + foreach (var person in peopleToAdd) + { + var existingPersonEntry = metadataPeople + .FirstOrDefault(mp => mp.Person.NormalizedName == person.NormalizedName && mp.Role == role); + + if (existingPersonEntry == null) { - if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title)) + metadataPeople.Add(new SeriesMetadataPeople { - handleAdd(existingTag); - } - } - else - { - // Add new tag - handleAdd(new CollectionTagBuilder(tag.Title) - .WithId(tag.Id) - .WithSummary(tag.Summary) - .WithIsPromoted(tag.Promoted) - .Build()); + PersonId = person.Id, + Person = person, + SeriesMetadataId = metadata.Id, + SeriesMetadata = metadata, + Role = role + }); } } } + + /// /// /// @@ -426,6 +511,7 @@ public class SeriesService : ISeriesService allChapterIds.AddRange(mapping.Value); } + // NOTE: This isn't getting all the people and whatnot currently var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds); _unitOfWork.SeriesRepository.Remove(series); @@ -447,7 +533,7 @@ public class SeriesService : ISeriesService } await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + await _unitOfWork.CollectionTagRepository.RemoveCollectionsWithoutSeries(); _taskScheduler.CleanupChapters(allChapterIds.ToArray()); return true; } @@ -482,74 +568,58 @@ public class SeriesService : ISeriesService var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId); - var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)) - .OrderBy(v => Parser.MinNumberFromRange(v.Name)) - .ToList(); + var bookTreatment = libraryType is LibraryType.Book or LibraryType.LightNovel; + var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); // For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number. var processedVolumes = new List(); - if (libraryType is LibraryType.Book or LibraryType.LightNovel) + foreach (var volume in volumes) { - var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty); - foreach (var volume in volumes) + if (volume.IsLooseLeaf() || volume.IsSpecial()) + { + continue; + } + + if (RenameVolumeName(volume, libraryType, volumeLabel) || (bookTreatment && !volume.IsSpecial())) { - volume.Chapters = volume.Chapters - .OrderBy(d => d.Number.AsDouble(), ChapterSortComparer.Default) - .ToList(); - var firstChapter = volume.Chapters.First(); - // On Books, skip volumes that are specials, since these will be shown - if (firstChapter.IsSpecial) continue; - RenameVolumeName(firstChapter, volume, libraryType, volumeLabel); processedVolumes.Add(volume); } } - else - { - processedVolumes = volumes.Where(v => v.MinNumber > 0).ToList(); - processedVolumes.ForEach(v => - { - v.Name = $"Volume {v.Name}"; - v.Chapters = v.Chapters.OrderBy(d => d.Number.AsDouble(), ChapterSortComparer.Default).ToList(); - }); - } var specials = new List(); - var chapters = volumes.SelectMany(v => v.Chapters.Select(c => - { - if (v.MinNumber == 0) return c; - c.VolumeTitle = v.Name; - return c; - }).OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default)).ToList(); + // Why isn't this doing a check if chapter is not special as it wont get included + var chapters = volumes + .SelectMany(v => v.Chapters + .Select(c => + { + if (v.IsLooseLeaf() || v.IsSpecial()) return c; + c.VolumeTitle = v.Name; + return c; + }) + .OrderBy(c => c.SortOrder)) + .ToList(); foreach (var chapter in chapters) { chapter.Title = await FormatChapterTitle(userId, chapter, libraryType); - if (!chapter.IsSpecial) continue; - if (!string.IsNullOrEmpty(chapter.TitleName)) chapter.Title = chapter.TitleName; + if (!chapter.IsSpecial) continue; specials.Add(chapter); } - // Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes) - IEnumerable retChapters; - if (libraryType is LibraryType.Book or LibraryType.LightNovel) - { - retChapters = Array.Empty(); - } else - { - retChapters = chapters.Where(ShouldIncludeChapter); - } + // Don't show chapter -100000 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes) + IEnumerable retChapters = bookTreatment ? Array.Empty() : chapters.Where(ShouldIncludeChapter); - var storylineChapters = libraryType == LibraryType.Magazine ? [] - : volumes - .Where(v => v.MinNumber == 0) - .SelectMany(v => v.Chapters.Where(c => !c.IsSpecial)) - .OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default) - .ToList(); + var storylineChapters = volumes + .WhereLooseLeaf() + .SelectMany(v => v.Chapters.Where(c => !c.IsSpecial)) + .OrderBy(c => c.SortOrder) + .ToList(); // When there's chapters without a volume number revert to chapter sorting only as opposed to volume then chapter if (storylineChapters.Count > 0) { - retChapters = retChapters.OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default); + retChapters = retChapters.OrderBy(c => c.SortOrder, ChapterSortComparerDefaultLast.Default); } return new SeriesDetailDto @@ -560,6 +630,7 @@ public class SeriesService : ISeriesService StorylineChapters = storylineChapters, TotalCount = chapters.Count, UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages), + // TODO: See if we can get the ContinueFrom here }; } @@ -570,69 +641,91 @@ public class SeriesService : ISeriesService /// private static bool ShouldIncludeChapter(ChapterDto chapter) { - return !chapter.IsSpecial && !chapter.Number.Equals(Parser.DefaultChapter); + return !chapter.IsSpecial && chapter.MinNumber.IsNot(Parser.DefaultChapterNumber); } - public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType, string volumeLabel = "Volume") + /// + /// 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) { + var firstChapter = volume.Chapters.First(); + // On Books, skip volumes that are specials, since these will be shown + // if (firstChapter.IsSpecial) + // { + // // Some books can be SP marker and also position of 0, this will trick Kavita into rendering it as part of a non-special volume + // // We need to rename the entity so that it renders out correctly + // return false; + // } if (string.IsNullOrEmpty(firstChapter.TitleName)) { - if (firstChapter.Range.Equals(Parser.DefaultVolume)) return; + if (firstChapter.Range.Equals(Parser.LooseLeafVolume)) return false; var title = Path.GetFileNameWithoutExtension(firstChapter.Range); - if (string.IsNullOrEmpty(title)) return; - volume.Name += $" - {title}"; + if (string.IsNullOrEmpty(title)) return false; + volume.Name += $" - {title}"; // OPDS smart list 7 (just pdfs) triggered this } - else if (volume.Name != "0") + else if (!volume.IsLooseLeaf()) { // If the titleName has Volume inside it, let's just send that back? - volume.Name += $" - {firstChapter.TitleName}"; + volume.Name = firstChapter.TitleName; } - // else - // { - // volume.Name += $""; - // } - return; + return !firstChapter.IsSpecial; } - volume.Name = $"{volumeLabel} {volume.Name}".Trim(); + volume.Name = $"{volumeLabel.Trim()} {volume.Name}".Trim(); + return true; } - public async Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle, bool withHash) + public async Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash) { - if (string.IsNullOrEmpty(chapterTitle)) throw new ArgumentException("Chapter Title cannot be null"); + if (string.IsNullOrEmpty(chapterTitle) && (isSpecial || libraryType == LibraryType.Book)) throw new ArgumentException("Chapter Title cannot be null"); if (isSpecial) { - return Parser.CleanSpecialTitle(chapterTitle); + return Parser.CleanSpecialTitle(chapterTitle!); } var hashSpot = withHash ? "#" : string.Empty; - return libraryType switch + var baseChapter = libraryType switch { - LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle), - LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", chapterTitle), + LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle!), + LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", chapterRange), + LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange), LibraryType.Magazine => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterTitle), - LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterTitle), - LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterTitle), - LibraryType.Image => await _localizationService.Translate(userId, "chapter-num", chapterTitle), + LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange), + LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterRange), + LibraryType.Image => await _localizationService.Translate(userId, "chapter-num", chapterRange), _ => await _localizationService.Translate(userId, "chapter-num", ' ') }; + + if (!string.IsNullOrEmpty(chapterTitle) && libraryType != LibraryType.Book && chapterTitle != chapterRange) + { + baseChapter += " - " + chapterTitle; + } + + + return baseChapter; } public async Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true) { - return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Title, withHash); + return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash); } public async Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true) { - return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Title, withHash); + return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash); } + // TODO: Refactor this out and use FormatChapterTitle instead across library public async Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false) { var hashSpot = withHash ? "#" : string.Empty; @@ -641,6 +734,7 @@ public class SeriesService : ISeriesService LibraryType.Book => await _localizationService.Translate(userId, "book-num", string.Empty), LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", string.Empty), LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty), + LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty), LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", string.Empty), _ => await _localizationService.Translate(userId, "chapter-num", ' ') }).Trim(); @@ -658,7 +752,7 @@ public class SeriesService : ISeriesService } /// - /// Update the relations attached to the Series. Does not generate associated Sequel/Prequel pairs on target series. + /// Update the relations attached to the Series. Generates associated Sequel/Prequel pairs on target series. /// /// /// @@ -676,14 +770,90 @@ public class SeriesService : ISeriesService UpdateRelationForKind(dto.AlternativeSettings, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeSetting).ToList(), series, RelationKind.AlternativeSetting); UpdateRelationForKind(dto.AlternativeVersions, series.Relations.Where(r => r.RelationKind == RelationKind.AlternativeVersion).ToList(), series, RelationKind.AlternativeVersion); UpdateRelationForKind(dto.Doujinshis, series.Relations.Where(r => r.RelationKind == RelationKind.Doujinshi).ToList(), series, RelationKind.Doujinshi); - UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel); - UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel); UpdateRelationForKind(dto.Editions, series.Relations.Where(r => r.RelationKind == RelationKind.Edition).ToList(), series, RelationKind.Edition); + UpdateRelationForKind(dto.Annuals, series.Relations.Where(r => r.RelationKind == RelationKind.Annual).ToList(), series, RelationKind.Annual); + + await UpdatePrequelSequelRelations(dto.Prequels, series, RelationKind.Prequel); + await UpdatePrequelSequelRelations(dto.Sequels, series, RelationKind.Sequel); if (!_unitOfWork.HasChanges()) return true; return await _unitOfWork.CommitAsync(); } + /// + /// Updates Prequel/Sequel relations and creates reciprocal relations on target series. + /// + /// List of target series IDs + /// The current series being updated + /// The relation kind (Prequel or Sequel) + private async Task UpdatePrequelSequelRelations(ICollection targetSeriesIds, Series series, RelationKind kind) + { + var existingRelations = series.Relations.Where(r => r.RelationKind == kind).ToList(); + + // Remove relations that are not in the new list + foreach (var relation in existingRelations.Where(relation => !targetSeriesIds.Contains(relation.TargetSeriesId))) + { + series.Relations.Remove(relation); + await RemoveReciprocalRelation(series.Id, relation.TargetSeriesId, GetOppositeRelationKind(kind)); + } + + // Add new relations + foreach (var targetSeriesId in targetSeriesIds) + { + if (series.Relations.Any(r => r.RelationKind == kind && r.TargetSeriesId == targetSeriesId)) + continue; + + series.Relations.Add(new SeriesRelation + { + Series = series, + SeriesId = series.Id, + TargetSeriesId = targetSeriesId, + RelationKind = kind + }); + + await AddReciprocalRelation(series.Id, targetSeriesId, GetOppositeRelationKind(kind)); + } + + _unitOfWork.SeriesRepository.Update(series); + } + + private static RelationKind GetOppositeRelationKind(RelationKind kind) + { + return kind == RelationKind.Prequel ? RelationKind.Sequel : RelationKind.Prequel; + } + + private async Task AddReciprocalRelation(int sourceSeriesId, int targetSeriesId, RelationKind kind) + { + var targetSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(targetSeriesId, SeriesIncludes.Related); + if (targetSeries == null) return; + + if (targetSeries.Relations.Any(r => r.RelationKind == kind && r.TargetSeriesId == sourceSeriesId)) + return; + + targetSeries.Relations.Add(new SeriesRelation + { + Series = targetSeries, + SeriesId = targetSeriesId, + TargetSeriesId = sourceSeriesId, + RelationKind = kind + }); + + _unitOfWork.SeriesRepository.Update(targetSeries); + } + + private async Task RemoveReciprocalRelation(int sourceSeriesId, int targetSeriesId, RelationKind kind) + { + var targetSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(targetSeriesId, SeriesIncludes.Related); + if (targetSeries == null) return; + + var relationToRemove = targetSeries.Relations.FirstOrDefault(r => r.RelationKind == kind && r.TargetSeriesId == sourceSeriesId); + if (relationToRemove != null) + { + targetSeries.Relations.Remove(relationToRemove); + _unitOfWork.SeriesRepository.Update(targetSeries); + } + } + /// /// Applies the provided list to the series. Adds new relations and removes deleted relations. @@ -744,19 +914,19 @@ public class SeriesService : ISeriesService // Calculate the time differences between consecutive chapters var timeDifferences = new List(); DateTime? previousChapterTime = null; - foreach (var chapter in chapters) + foreach (var chapterCreatedUtc in chapters.Select(c => c.CreatedUtc)) { - if (previousChapterTime.HasValue && (chapter.CreatedUtc - previousChapterTime.Value) <= TimeSpan.FromHours(1)) + if (previousChapterTime.HasValue && (chapterCreatedUtc - previousChapterTime.Value) <= TimeSpan.FromHours(1)) { continue; // Skip this chapter if it's within an hour of the previous one } - if ((chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) + if ((chapterCreatedUtc - previousChapterTime ?? TimeSpan.Zero) != TimeSpan.Zero) { - timeDifferences.Add(chapter.CreatedUtc - previousChapterTime ?? TimeSpan.Zero); + timeDifferences.Add(chapterCreatedUtc - previousChapterTime ?? TimeSpan.Zero); } - previousChapterTime = chapter.CreatedUtc; + previousChapterTime = chapterCreatedUtc; } if (timeDifferences.Count < minimumTimeDeltas) @@ -780,21 +950,25 @@ public class SeriesService : ISeriesService } // Calculate the forecast for when the next chapter is expected - var nextChapterExpected = chapters.Any() - ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) - : (DateTime?)null; + // var nextChapterExpected = chapters.Count > 0 + // ? chapters.Max(c => c.CreatedUtc) + TimeSpan.FromDays(forecastedTimeDifference) + // : (DateTime?)null; + var lastChapterDate = chapters.Max(c => c.CreatedUtc); + var estimatedDate = lastChapterDate.AddDays(forecastedTimeDifference); + var nextChapterExpected = estimatedDate.Day > DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month) + ? new DateTime(estimatedDate.Year, estimatedDate.Month, DateTime.DaysInMonth(estimatedDate.Year, estimatedDate.Month)) + : estimatedDate; // For number and volume number, we need the highest chapter, not the latest created - var lastChapter = chapters.MaxBy(c => c.Number.AsFloat())!; - float.TryParse(lastChapter.Number, NumberStyles.Number, CultureInfo.InvariantCulture, - out var lastChapterNumber); + var lastChapter = chapters.MaxBy(c => c.MaxNumber)!; + var lastChapterNumber = lastChapter.MaxNumber; var lastVolumeNum = chapters.Select(c => c.Volume.MinNumber).Max(); var result = new NextExpectedChapterDto { ChapterNumber = 0, - VolumeNumber = 0, + VolumeNumber = Parser.LooseLeafVolumeNumber, ExpectedDate = nextChapterExpected, Title = string.Empty }; @@ -807,6 +981,7 @@ public class SeriesService : ISeriesService { LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber), LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), + LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", "#", result.ChapterNumber), LibraryType.Book => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", result.ChapterNumber), _ => await _localizationService.Translate(userId, "chapter-num", result.ChapterNumber) diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs new file mode 100644 index 000000000..fd44b5962 --- /dev/null +++ b/API/Services/SettingsService.cs @@ -0,0 +1,447 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using API.Data; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Settings; +using API.Entities; +using API.Entities.Enums; +using API.Extensions; +using API.Logging; +using API.Services.Tasks.Scanner; +using Hangfire; +using Kavita.Common; +using Kavita.Common.EnvironmentInfo; +using Kavita.Common.Helpers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface ISettingsService +{ + Task UpdateMetadataSettings(MetadataSettingsDto dto); + Task UpdateSettings(ServerSettingDto updateSettingsDto); +} + + +public class SettingsService : ISettingsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IDirectoryService _directoryService; + private readonly ILibraryWatcher _libraryWatcher; + private readonly ITaskScheduler _taskScheduler; + private readonly ILogger _logger; + + public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService, + ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler, + ILogger logger) + { + _unitOfWork = unitOfWork; + _directoryService = directoryService; + _libraryWatcher = libraryWatcher; + _taskScheduler = taskScheduler; + _logger = logger; + } + + /// + /// Update the metadata settings for Kavita+ Metadata feature + /// + /// + /// + public async Task UpdateMetadataSettings(MetadataSettingsDto dto) + { + var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + existingMetadataSetting.Enabled = dto.Enabled; + existingMetadataSetting.EnableSummary = dto.EnableSummary; + existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName; + existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus; + existingMetadataSetting.EnableRelationships = dto.EnableRelationships; + existingMetadataSetting.EnablePeople = dto.EnablePeople; + existingMetadataSetting.EnableStartDate = dto.EnableStartDate; + existingMetadataSetting.EnableGenres = dto.EnableGenres; + existingMetadataSetting.EnableTags = dto.EnableTags; + existingMetadataSetting.FirstLastPeopleNaming = dto.FirstLastPeopleNaming; + existingMetadataSetting.EnableCoverImage = dto.EnableCoverImage; + + existingMetadataSetting.EnableChapterPublisher = dto.EnableChapterPublisher; + existingMetadataSetting.EnableChapterSummary = dto.EnableChapterSummary; + existingMetadataSetting.EnableChapterTitle = dto.EnableChapterTitle; + existingMetadataSetting.EnableChapterReleaseDate = dto.EnableChapterReleaseDate; + existingMetadataSetting.EnableChapterCoverImage = dto.EnableChapterCoverImage; + + existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings ?? []; + + existingMetadataSetting.Blacklist = (dto.Blacklist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Whitelist = (dto.Whitelist ?? []).Where(s => !string.IsNullOrWhiteSpace(s)).DistinctBy(d => d.ToNormalized()).ToList() ?? []; + existingMetadataSetting.Overrides = [.. dto.Overrides ?? []]; + existingMetadataSetting.PersonRoles = dto.PersonRoles ?? []; + + // Handle Field Mappings + + // Clear existing mappings + existingMetadataSetting.FieldMappings ??= []; + _unitOfWork.SettingsRepository.RemoveRange(existingMetadataSetting.FieldMappings); + existingMetadataSetting.FieldMappings.Clear(); + + if (dto.FieldMappings != null) + { + // Add new mappings + foreach (var mappingDto in dto.FieldMappings) + { + existingMetadataSetting.FieldMappings.Add(new MetadataFieldMapping + { + SourceType = mappingDto.SourceType, + DestinationType = mappingDto.DestinationType, + SourceValue = mappingDto.SourceValue, + DestinationValue = mappingDto.DestinationValue, + ExcludeFromSource = mappingDto.ExcludeFromSource + }); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + + // Return updated settings + return await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + } + + /// + /// Update Server Settings + /// + /// + /// + /// + public async Task UpdateSettings(ServerSettingDto updateSettingsDto) + { + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + var updateBookmarks = false; + var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + + var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; + if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && + !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + { + bookmarkDirectory = + _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); + } + + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + { + bookmarkDirectory = _directoryService.BookmarkDirectory; + } + + var updateTask = false; + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.OnDeckProgressDays && + updateSettingsDto.OnDeckProgressDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckProgressDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.OnDeckUpdateDays && + updateSettingsDto.OnDeckUpdateDays + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.OnDeckUpdateDays + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.Port && updateSettingsDto.Port + string.Empty != setting.Value) + { + if (OsInfo.IsDocker) continue; + setting.Value = updateSettingsDto.Port + string.Empty; + // Port is managed in appSetting.json + Configuration.Port = updateSettingsDto.Port; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CacheSize && + updateSettingsDto.CacheSize + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.CacheSize + string.Empty; + // CacheSize is managed in appSetting.json + Configuration.CacheSize = updateSettingsDto.CacheSize; + _unitOfWork.SettingsRepository.Update(setting); + } + + updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto); + + UpdateEmailSettings(setting, updateSettingsDto); + + + + if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value) + { + if (OsInfo.IsDocker) continue; + // Validate IP addresses + foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(',', + StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!IPAddress.TryParse(ipAddress.Trim(), out _)) + { + throw new KavitaException("ip-address-invalid"); + } + } + + setting.Value = updateSettingsDto.IpAddresses; + // IpAddresses is managed in appSetting.json + Configuration.IpAddresses = updateSettingsDto.IpAddresses; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) + { + var path = !updateSettingsDto.BaseUrl.StartsWith('/') + ? $"/{updateSettingsDto.BaseUrl}" + : updateSettingsDto.BaseUrl; + path = !path.EndsWith('/') + ? $"{path}/" + : path; + setting.Value = path; + Configuration.BaseUrl = updateSettingsDto.BaseUrl; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.LoggingLevel && + updateSettingsDto.LoggingLevel + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.LoggingLevel + string.Empty; + LogLevelOptions.SwitchLogLevel(updateSettingsDto.LoggingLevel); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableOpds && + updateSettingsDto.EnableOpds + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableOpds + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EncodeMediaAs && + ((int)updateSettingsDto.EncodeMediaAs).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.EncodeMediaAs).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.CoverImageSize && + ((int)updateSettingsDto.CoverImageSize).ToString() != setting.Value) + { + setting.Value = ((int)updateSettingsDto.CoverImageSize).ToString(); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value) + { + setting.Value = (updateSettingsDto.HostName + string.Empty).Trim(); + setting.Value = UrlHelper.RemoveEndingSlash(setting.Value); + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // Validate new directory can be used + if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) + { + throw new KavitaException("bookmark-dir-permissions"); + } + + originalBookmarkDirectory = setting.Value; + + // Normalize the path deliminators. Just to look nice in DB, no functionality + setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); + _unitOfWork.SettingsRepository.Update(setting); + updateBookmarks = true; + + } + + if (setting.Key == ServerSettingKey.AllowStatCollection && + updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalBackups && + updateSettingsDto.TotalBackups + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) + { + throw new KavitaException("total-backups"); + } + + setting.Value = updateSettingsDto.TotalBackups + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TotalLogs && + updateSettingsDto.TotalLogs + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalLogs > 30 || updateSettingsDto.TotalLogs < 1) + { + throw new KavitaException("total-logs"); + } + + setting.Value = updateSettingsDto.TotalLogs + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EnableFolderWatching && + updateSettingsDto.EnableFolderWatching + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableFolderWatching + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } + + if (!_unitOfWork.HasChanges()) return updateSettingsDto; + + try + { + await _unitOfWork.CommitAsync(); + + if (!updateSettingsDto.AllowStatCollection) + { + _taskScheduler.CancelStatsTasks(); + } + else + { + await _taskScheduler.ScheduleStatsTasks(); + } + + if (updateBookmarks) + { + UpdateBookmarkDirectory(originalBookmarkDirectory, bookmarkDirectory); + } + + if (updateTask) + { + BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks()); + } + + if (updateSettingsDto.EnableFolderWatching) + { + BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching()); + } + else + { + BackgroundJob.Enqueue(() => _libraryWatcher.StopWatching()); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when updating server settings"); + await _unitOfWork.RollbackAsync(); + throw new KavitaException("generic-error"); + } + + + _logger.LogInformation("Server Settings updated"); + + return updateSettingsDto; + } + + private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory) + { + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); + } + + private bool UpdateSchedulingSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + + return true; + } + + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + + if (setting.Key == ServerSettingKey.TaskCleanup && updateSettingsDto.TaskCleanup != setting.Value) + { + setting.Value = updateSettingsDto.TaskCleanup; + _unitOfWork.SettingsRepository.Update(setting); + return true; + } + return false; + } + + private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto) + { + if (setting.Key == ServerSettingKey.EmailHost && + updateSettingsDto.SmtpConfig.Host + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Host + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailPort && + updateSettingsDto.SmtpConfig.Port + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Port + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthPassword && + updateSettingsDto.SmtpConfig.Password + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.Password + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailAuthUserName && + updateSettingsDto.SmtpConfig.UserName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.UserName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderAddress && + updateSettingsDto.SmtpConfig.SenderAddress + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderAddress + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSenderDisplayName && + updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SenderDisplayName + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailSizeLimit && + updateSettingsDto.SmtpConfig.SizeLimit + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.SizeLimit + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailEnableSsl && + updateSettingsDto.SmtpConfig.EnableSsl + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.EnableSsl + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.EmailCustomizedTemplates && + updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.SmtpConfig.CustomizedTemplates + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + } +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 6e39e76a3..006bad184 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -5,10 +5,14 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; using API.DTOs.Statistics; +using API.DTOs.Stats; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; +using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -32,6 +36,7 @@ public interface IStatisticService IEnumerable> GetWordsReadCountByYear(int userId = 0); Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); + Task> GetFilesByExtension(string fileExtension); } /// @@ -54,13 +59,17 @@ public class StatisticService : IStatisticService public async Task GetUserReadStatistics(int userId, IList libraryIds) { if (libraryIds.Count == 0) + { libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); + } + // Total Pages Read var totalPagesRead = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) .Where(p => libraryIds.Contains(p.LibraryId)) - .SumAsync(p => p.PagesRead); + .Select(p => (int?) p.PagesRead) + .SumAsync() ?? 0; var timeSpentReading = await TimeSpentReadingForUsersAsync(new List() {userId}, libraryIds); @@ -79,7 +88,9 @@ public class StatisticService : IStatisticService var lastActive = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MaxAsync(p => p.LastModified); + .Select(p => p.LastModified) + .DefaultIfEmpty() + .MaxAsync(); // First get the total pages per library @@ -117,12 +128,25 @@ public class StatisticService : IStatisticService var earliestReadDate = await _context.AppUserProgresses .Where(p => p.AppUserId == userId) - .MinAsync(p => p.Created); + .Select(p => p.Created) + .DefaultIfEmpty() + .MinAsync(); + + if (earliestReadDate == DateTime.MinValue) + { + averageReadingTimePerWeek = 0; + } + else + { +#pragma warning disable S6561 + var timeDifference = DateTime.Now - earliestReadDate; +#pragma warning restore S6561 + var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); + + averageReadingTimePerWeek /= deltaWeeks; + } - var timeDifference = DateTime.Now - earliestReadDate; - var deltaWeeks = (int)Math.Ceiling(timeDifference.TotalDays / 7); - averageReadingTimePerWeek /= deltaWeeks; return new UserReadStatistics() @@ -269,7 +293,6 @@ public class StatisticService : IStatisticService var distinctPeople = _context.Person - .AsSplitQuery() .AsEnumerable() .GroupBy(sm => sm.NormalizedName) .Select(sm => sm.Key) @@ -287,7 +310,7 @@ public class StatisticService : IStatisticService TotalPeople = distinctPeople, TotalSize = await _context.MangaFile.SumAsync(m => m.Bytes), TotalTags = await _context.Tag.CountAsync(), - VolumeCount = await _context.Volume.Where(v => v.MinNumber != 0).CountAsync(), + VolumeCount = await _context.Volume.Where(v => Math.Abs(v.MinNumber - Parser.LooseLeafVolumeNumber) > 0.001f).CountAsync(), MostActiveUsers = mostActiveUsers, MostActiveLibraries = mostActiveLibrary, MostPopularSeries = mostPopularSeries, @@ -335,8 +358,9 @@ public class StatisticService : IStatisticService SeriesId = u.SeriesId, LibraryId = u.LibraryId, ReadDate = u.LastModified, + ReadDateUtc = u.LastModifiedUtc, ChapterId = u.ChapterId, - ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).Number + ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).MinNumber }) .OrderByDescending(d => d.ReadDate) .ToListAsync(); @@ -531,6 +555,16 @@ public class StatisticService : IStatisticService p.chapter.AvgHoursToRead * (p.progress.PagesRead / (1.0f * p.chapter.Pages)))); } + public async Task> GetFilesByExtension(string fileExtension) + { + var query = _context.MangaFile + .Where(f => f.Extension == fileExtension) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(f => f.FilePath); + + return await query.ToListAsync(); + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); @@ -553,7 +587,6 @@ public class StatisticService : IStatisticService .Contains(c.Id)) }) .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead)) - .Take(5) .ToList(); @@ -573,16 +606,17 @@ public class StatisticService : IStatisticService chapterLibLookup.Add(cl.ChapterId, cl.LibraryId); } - var user = new Dictionary>(); + var user = new Dictionary>(); foreach (var userChapter in topUsersAndReadChapters) { - if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, new Dictionary()); + if (!user.ContainsKey(userChapter.User.Id)) user.Add(userChapter.User.Id, []); var libraryTimes = user[userChapter.User.Id]; foreach (var chapter in userChapter.Chapters) { var library = libraries.First(l => l.Id == chapterLibLookup[chapter.Id]); - if (!libraryTimes.ContainsKey(library.Type)) libraryTimes.Add(library.Type, 0L); + libraryTimes.TryAdd(library.Type, 0f); + var existingHours = libraryTimes[library.Type]; libraryTimes[library.Type] = existingHours + chapter.AvgHoursToRead; } diff --git a/API/Services/StreamService.cs b/API/Services/StreamService.cs index f12f10a8a..1f2e55579 100644 --- a/API/Services/StreamService.cs +++ b/API/Services/StreamService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -11,6 +12,7 @@ using API.Helpers; using API.SignalR; using Kavita.Common; using Kavita.Common.Helpers; +using Microsoft.Extensions.Logging; namespace API.Services; @@ -33,6 +35,9 @@ public interface IStreamService Task CreateExternalSource(int userId, ExternalSourceDto dto); Task UpdateExternalSource(int userId, ExternalSourceDto dto); Task DeleteExternalSource(int userId, int externalSourceId); + Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId); + Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId); + Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter); } public class StreamService : IStreamService @@ -40,12 +45,14 @@ public class StreamService : IStreamService private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; + private readonly ILogger _logger; - public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService) + public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService, ILogger logger) { _unitOfWork = unitOfWork; _eventHub = eventHub; _localizationService = localizationService; + _logger = logger; } public async Task> GetDashboardStreams(int userId, bool visibleOnly = true) @@ -91,6 +98,7 @@ public class StreamService : IStreamService var ret = new DashboardStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -123,7 +131,10 @@ public class StreamService : IStreamService AppUserIncludes.DashboardStreams); var stream = user?.DashboardStreams.FirstOrDefault(d => d.Id == dto.Id); if (stream == null) + { throw new KavitaException(await _localizationService.Translate(userId, "dashboard-stream-doesnt-exist")); + } + if (stream.Order == dto.ToPosition) return; var list = user!.DashboardStreams.OrderBy(s => s.Order).ToList(); @@ -179,6 +190,7 @@ public class StreamService : IStreamService var ret = new SideNavStreamDto() { + Id = createdStream.Id, Name = createdStream.Name, IsProvided = createdStream.IsProvided, Visible = createdStream.Visible, @@ -341,4 +353,72 @@ public class StreamService : IStreamService await _unitOfWork.CommitAsync(); } + + public async Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetSideNavStream(sideNavStreamId); + if (stream == null) throw new KavitaException("sidenav-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("sidenav-stream-doesnt-exist"); + + + if (stream.StreamType != SideNavStreamType.SmartFilter) + { + throw new KavitaException("sidenav-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting SideNav Smart Filter Stream: {FilterId}", sideNavStreamId); + throw; + } + } + + public async Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId) + { + try + { + var stream = await _unitOfWork.UserRepository.GetDashboardStream(dashboardStreamId); + if (stream == null) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.AppUserId != userId) throw new KavitaException("dashboard-stream-doesnt-exist"); + + if (stream.StreamType != DashboardStreamType.SmartFilter) + { + throw new KavitaException("dashboard-stream-only-delete-smart-filter"); + } + + _unitOfWork.UserRepository.Delete(stream); + + await _unitOfWork.CommitAsync(); + } catch (Exception ex) + { + _logger.LogError(ex, "There was an exception deleting Dashboard Smart Filter Stream: {FilterId}", dashboardStreamId); + throw; + } + } + + public async Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter) + { + var sideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(smartFilter.Id); + var dashboardStreams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(smartFilter.Id); + + foreach (var sideNavStream in sideNavStreams) + { + sideNavStream.Name = smartFilter.Name; + } + + foreach (var dashboardStream in dashboardStreams) + { + dashboardStream.Name = smartFilter.Name; + } + + await _unitOfWork.CommitAsync(); + } } diff --git a/API/Services/TachiyomiService.cs b/API/Services/TachiyomiService.cs index 68d4bb5ac..7cba28695 100644 --- a/API/Services/TachiyomiService.cs +++ b/API/Services/TachiyomiService.cs @@ -14,10 +14,11 @@ using AutoMapper; using Microsoft.Extensions.Logging; namespace API.Services; +#nullable enable public interface ITachiyomiService { - Task GetLatestChapter(int seriesId, int userId); + Task GetLatestChapter(int seriesId, int userId); Task MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber); } @@ -29,12 +30,12 @@ public class TachiyomiService : ITachiyomiService { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IReaderService _readerService; private static readonly CultureInfo EnglishCulture = CultureInfo.CreateSpecificCulture("en-US"); - public TachiyomiService(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, IReaderService readerService) + public TachiyomiService(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, IReaderService readerService) { _unitOfWork = unitOfWork; _readerService = readerService; @@ -51,7 +52,7 @@ public class TachiyomiService : ITachiyomiService /// If its a chapter, return the chapterDto as is. /// If it's a volume, the volume number gets returned in the 'Number' attribute of a chapterDto encoded. /// The volume number gets divided by 10,000 because that's how Tachiyomi interprets volumes - public async Task GetLatestChapter(int seriesId, int userId) + public async Task GetLatestChapter(int seriesId, int userId) { var currentChapter = await _readerService.GetContinuePoint(seriesId, userId); @@ -69,55 +70,53 @@ public class TachiyomiService : ITachiyomiService // Else return the max chapter to Tachiyomi so it can consider everything read var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(seriesId)).ToImmutableList(); - var looseLeafChapterVolume = volumes.Find(v => v.MinNumber == 0); + var looseLeafChapterVolume = volumes.GetLooseLeafVolumeOrDefault(); if (looseLeafChapterVolume == null) { var volumeChapter = _mapper.Map(volumes [^1].Chapters - .OrderBy(c => c.Number.AsFloat(), ChapterSortComparerZeroFirst.Default) + .OrderBy(c => c.MinNumber, ChapterSortComparerDefaultFirst.Default) .Last()); - if (volumeChapter.Number == Parser.DefaultVolume) + + if (volumeChapter.MinNumber.Is(Parser.LooseLeafVolumeNumber)) { var volume = volumes.First(v => v.Id == volumeChapter.VolumeId); - return new ChapterDto() - { - // Use R to ensure that localization of underlying system doesn't affect the stringification - // https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework - Number = (volume.MinNumber / 10_000f).ToString("R", EnglishCulture) - }; + return CreateTachiyomiChapterDto(volume.MinNumber); } - return new ChapterDto() - { - Number = (int.Parse(volumeChapter.Number) / 10_000f).ToString("R", EnglishCulture) - }; + return CreateTachiyomiChapterDto(volumeChapter.MinNumber); } var lastChapter = looseLeafChapterVolume.Chapters - .OrderBy(c => double.Parse(c.Number, CultureInfo.InvariantCulture), ChapterSortComparer.Default) + .OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default) .Last(); - return _mapper.Map(lastChapter); + + return _mapper.Map(lastChapter); } // There is progress, we now need to figure out the highest volume or chapter and return that. var prevChapter = (await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId))!; - var volumeWithProgress = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId); + var volumeWithProgress = (await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId))!; // We only encode for single-file volumes - if (volumeWithProgress!.MinNumber != 0 && volumeWithProgress.Chapters.Count == 1) + if (!volumeWithProgress.IsLooseLeaf() && volumeWithProgress.Chapters.Count == 1) { // The progress is on a volume, encode it as a fake chapterDTO - return new ChapterDto() - { - // Use R to ensure that localization of underlying system doesn't affect the stringification - // https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework - Number = (volumeWithProgress.MinNumber / 10_000f).ToString("R", EnglishCulture) - - }; + return CreateTachiyomiChapterDto(volumeWithProgress.MinNumber); } // Progress is just on a chapter, return as is - return prevChapter; + return _mapper.Map(prevChapter); + } + + private static TachiyomiChapterDto CreateTachiyomiChapterDto(float number) + { + return new TachiyomiChapterDto() + { + // Use R to ensure that localization of underlying system doesn't affect the stringification + // https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework + Number = (number / 10_000f).ToString("R", EnglishCulture) + }; } /// diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 079c28fce..e73d82b1f 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -4,12 +4,17 @@ using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.Entities.Enums; +using API.Extensions; +using API.Helpers; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; using API.Services.Tasks.Metadata; +using API.SignalR; using Hangfire; +using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; namespace API.Services; @@ -20,23 +25,21 @@ public interface ITaskScheduler Task ScheduleStatsTasks(); void ScheduleUpdaterTasks(); Task ScheduleKavitaPlusTasks(); - void ScanFolder(string folderPath, TimeSpan delay); + void ScanFolder(string folderPath, string originalPath, TimeSpan delay); void ScanFolder(string folderPath); - void ScanLibrary(int libraryId, bool force = false); - void ScanLibraries(bool force = false); + Task ScanLibrary(int libraryId, bool force = false); + Task ScanLibraries(bool force = false); void CleanupChapters(int[] chapterIds); - void RefreshMetadata(int libraryId, bool forceUpdate = true); - void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); - void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); + void RefreshMetadata(int libraryId, bool forceUpdate = true, bool forceColorscape = true); + void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false); + Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false); - void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); Task RunStatCollection(); - void ScanSiteThemes(); void CovertAllCoversToEncoding(); Task CleanupDbEntries(); Task CheckForUpdate(); - + Task SyncThemes(); } public class TaskScheduler : ITaskScheduler { @@ -57,12 +60,16 @@ public class TaskScheduler : ITaskScheduler private readonly IScrobblingService _scrobblingService; private readonly ILicenseService _licenseService; private readonly IExternalMetadataService _externalMetadataService; + private readonly ISmartCollectionSyncService _smartCollectionSyncService; + private readonly IWantToReadSyncService _wantToReadSyncService; + private readonly IEventHub _eventHub; public static BackgroundJobServer Client => new (); public const string ScanQueue = "scan"; public const string DefaultQueue = "default"; public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read"; public const string UpdateYearlyStatsTaskId = "update-yearly-stats"; + public const string SyncThemesTaskId = "sync-themes"; public const string CheckForUpdateId = "check-updates"; public const string CleanupDbTaskId = "cleanup-db"; public const string CleanupTaskId = "cleanup"; @@ -74,9 +81,12 @@ public class TaskScheduler : ITaskScheduler public const string ProcessProcessedScrobblingEventsId = "process-processed-scrobbling-events"; public const string LicenseCheckId = "license-check"; public const string KavitaPlusDataRefreshId = "kavita+-data-refresh"; + public const string KavitaPlusStackSyncId = "kavita+-stack-sync"; + public const string KavitaPlusWantToReadSyncId = "kavita+-want-to-read-sync"; - private static readonly ImmutableArray ScanTasks = + public static readonly ImmutableArray ScanTasks = ["ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"]; + private static readonly ImmutableArray NonCronOptions = ["disabled", "daily", "weekly"]; private static readonly Random Rnd = new Random(); @@ -91,7 +101,8 @@ public class TaskScheduler : ITaskScheduler ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, - IExternalMetadataService externalMetadataService) + IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, + IWantToReadSyncService wantToReadSyncService, IEventHub eventHub) { _cacheService = cacheService; _logger = logger; @@ -109,28 +120,40 @@ public class TaskScheduler : ITaskScheduler _scrobblingService = scrobblingService; _licenseService = licenseService; _externalMetadataService = externalMetadataService; + _smartCollectionSyncService = smartCollectionSyncService; + _wantToReadSyncService = wantToReadSyncService; + _eventHub = eventHub; } public async Task ScheduleTasks() { _logger.LogInformation("Scheduling reoccurring tasks"); + var setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Value; - if (setting != null) - { - var scanLibrarySetting = setting; - _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); - RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => _scannerService.ScanLibraries(false), - () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions); - } - else + if (IsInvalidCronSetting(setting)) { + _logger.LogError("Scan Task has invalid cron, defaulting to Daily"); RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), Cron.Daily, RecurringJobOptions); } + else + { + var scanLibrarySetting = setting; + _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); + RecurringJob.AddOrUpdate(ScanLibrariesTaskId, () => ScanLibraries(false), + () => CronConverter.ConvertToCronNotation(scanLibrarySetting), RecurringJobOptions); + } + setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Value; - if (setting != null) + if (IsInvalidCronSetting(setting)) + { + _logger.LogError("Backup Task has invalid cron, defaulting to Weekly"); + RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), + Cron.Weekly, RecurringJobOptions); + } + else { _logger.LogDebug("Scheduling Backup Task for {Setting}", setting); var schedule = CronConverter.ConvertToCronNotation(setting); @@ -142,25 +165,38 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), () => schedule, RecurringJobOptions); } - else - { - RecurringJob.AddOrUpdate(BackupTaskId, () => _backupService.BackupDatabase(), - Cron.Weekly, RecurringJobOptions); - } setting = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskCleanup)).Value; - _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); - RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), - CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); + if (IsInvalidCronSetting(setting)) + { + _logger.LogError("Cleanup Task has invalid cron, defaulting to Daily"); + RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), + Cron.Daily, RecurringJobOptions); + } + else + { + _logger.LogDebug("Scheduling Cleanup Task for {Setting}", setting); + RecurringJob.AddOrUpdate(CleanupTaskId, () => _cleanupService.Cleanup(), + CronConverter.ConvertToCronNotation(setting), RecurringJobOptions); + } + RecurringJob.AddOrUpdate(RemoveFromWantToReadTaskId, () => _cleanupService.CleanupWantToRead(), Cron.Daily, RecurringJobOptions); RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(), Cron.Monthly, RecurringJobOptions); + RecurringJob.AddOrUpdate(SyncThemesTaskId, () => SyncThemes(), + Cron.Daily, RecurringJobOptions); + await ScheduleKavitaPlusTasks(); } + private static bool IsInvalidCronSetting(string setting) + { + return setting == null || (!NonCronOptions.Contains(setting) && !CronHelper.IsValidCron(setting)); + } + public async Task ScheduleKavitaPlusTasks() { // KavitaPlus based (needs license check) @@ -169,23 +205,49 @@ public class TaskScheduler : ITaskScheduler { return; } + RecurringJob.AddOrUpdate(CheckScrobblingTokensId, () => _scrobblingService.CheckExternalAccessTokens(), Cron.Daily, RecurringJobOptions); BackgroundJob.Enqueue(() => _scrobblingService.CheckExternalAccessTokens()); // We also kick off an immediate check on startup - RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.HasActiveLicense(true), - LicenseService.Cron, RecurringJobOptions); - BackgroundJob.Enqueue(() => _licenseService.HasActiveLicense(true)); - // KavitaPlus Scrobbling (every 4 hours) + // Get the License Info (and cache it) on first load. This will internally cache the Github releases for the Version Service + await _licenseService.GetLicenseInfo(true); // Kick this off first to cache it then let it refresh every 9 hours (8 hour cache) + RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.GetLicenseInfo(false), + LicenseService.Cron, RecurringJobOptions); + + // KavitaPlus Scrobbling (every hour) RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - "0 */4 * * *", RecurringJobOptions); + "0 */1 * * *", RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); // Backfilling/Freshening Reviews/Rating/Recommendations RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId, - () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 4)), + () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 5)), RecurringJobOptions); + + // This shouldn't be so close to fetching data due to Rate limit concerns + RecurringJob.AddOrUpdate(KavitaPlusStackSyncId, + () => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(6, 10)), + RecurringJobOptions); + + RecurringJob.AddOrUpdate(KavitaPlusWantToReadSyncId, + () => _wantToReadSyncService.Sync(), Cron.Weekly(DayOfWeekHelper.Random()), + RecurringJobOptions); + } + + /// + /// Removes any Kavita+ Recurring Jobs + /// + public static void RemoveKavitaPlusTasks() + { + RecurringJob.RemoveIfExists(CheckScrobblingTokensId); + RecurringJob.RemoveIfExists(LicenseCheckId); + RecurringJob.RemoveIfExists(ProcessScrobblingEventsId); + RecurringJob.RemoveIfExists(ProcessProcessedScrobblingEventsId); + RecurringJob.RemoveIfExists(KavitaPlusDataRefreshId); + RecurringJob.RemoveIfExists(KavitaPlusStackSyncId); + RecurringJob.RemoveIfExists(KavitaPlusWantToReadSyncId); } #region StatsTasks @@ -193,7 +255,7 @@ public class TaskScheduler : ITaskScheduler public async Task ScheduleStatsTasks() { - var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; + var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; if (!allowStatCollection) { _logger.LogDebug("User has opted out of stat collection, not registering tasks"); @@ -204,10 +266,6 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions); } - public void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false) - { - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanLibrary(libraryId, forceUpdate)); - } /// /// Upon cancelling stat, we do report to the Stat service that we are no longer going to be reporting @@ -234,18 +292,6 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1)); } - public void ScanSiteThemes() - { - if (HasAlreadyEnqueuedTask("ThemeService", "Scan", Array.Empty(), ScanQueue)) - { - _logger.LogInformation("A Theme Scan is already running"); - return; - } - - _logger.LogInformation("Enqueueing Site Theme scan"); - BackgroundJob.Enqueue(() => _themeService.Scan()); - } - public void CovertAllCoversToEncoding() { var defaultParams = Array.Empty(); @@ -265,36 +311,48 @@ public class TaskScheduler : ITaskScheduler public void ScheduleUpdaterTasks() { _logger.LogInformation("Scheduling Auto-Update tasks"); - RecurringJob.AddOrUpdate(CheckForUpdateId, () => CheckForUpdate(), $"0 */{Rnd.Next(1, 2)} * * *", RecurringJobOptions); + RecurringJob.AddOrUpdate(CheckForUpdateId, () => CheckForUpdate(), $"0 */{Rnd.Next(4, 6)} * * *", RecurringJobOptions); BackgroundJob.Enqueue(() => CheckForUpdate()); } - public void ScanFolder(string folderPath, TimeSpan delay) + /// + /// Queue up a Scan folder for a folder from Library Watcher. + /// + /// + /// + /// + public void ScanFolder(string folderPath, string originalPath, TimeSpan delay) { var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); - if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder])) + var normalizedOriginal = Tasks.Scanner.Parser.Parser.NormalizePath(originalPath); + + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } + // Not sure where we should put this code, but we can get a bunch of ScanFolders when original has slight variations, like + // create a folder, add a new file, etc. All of these can be merged into just 1 request. + _logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder); - BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder), delay); + BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder, normalizedOriginal), delay); } public void ScanFolder(string folderPath) { var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); - if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", new object[] {normalizedFolder})) + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } _logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder); - _scannerService.ScanFolder(normalizedFolder); + _scannerService.ScanFolder(normalizedFolder, string.Empty); } #endregion @@ -308,18 +366,21 @@ public class TaskScheduler : ITaskScheduler /// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future. /// /// - public void ScanLibraries(bool force = false) + public async Task ScanLibraries(bool force = false) { if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { _logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours"); + // Send InfoEvent to UI as this is invoked my API BackgroundJob.Schedule(() => ScanLibraries(force), TimeSpan.FromHours(3)); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed", + $"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}")); return; } BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force)); } - public void ScanLibrary(int libraryId, bool force = false) + public async Task ScanLibrary(int libraryId, bool force = false) { if (HasScanTaskRunningForLibrary(libraryId)) { @@ -328,15 +389,18 @@ public class TaskScheduler : ITaskScheduler } if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { - _logger.LogInformation("A Library Scan is already running, rescheduling ScanLibrary in 3 hours"); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); + _logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours"); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan library task delayed", + $"A scan was ongoing during processing of the {library!.Name} scan task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}")); BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3)); return; } _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force)); + var jobId = 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.ContinueJobWith(jobId, () => _cleanupService.CleanupCacheDirectory()); } public void TurnOnScrobbling(int userId = 0) @@ -349,12 +413,12 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); } - public void RefreshMetadata(int libraryId, bool forceUpdate = true) + public void RefreshMetadata(int libraryId, bool forceUpdate = true, bool forceColorscape = true) { var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary", - new object[] {libraryId, true}) || + [libraryId, true, true]) || HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary", - new object[] {libraryId, false}); + [libraryId, false, false]); if (alreadyEnqueued) { _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); @@ -362,31 +426,35 @@ public class TaskScheduler : ITaskScheduler } _logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForLibrary(libraryId, forceUpdate)); + BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForLibrary(libraryId, forceUpdate, forceColorscape)); } - public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false) + public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false) { - if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", new object[] {libraryId, seriesId, forceUpdate})) + if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", [libraryId, seriesId, forceUpdate, forceColorscape])) { _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); return; } _logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId); - BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate)); + BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate, forceColorscape)); } - public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) + public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) { - if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, forceUpdate}, ScanQueue)) + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue)) { _logger.LogInformation("A duplicate request to scan series occured. Skipping"); return; } if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { + // BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); _logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes"); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan series task delayed: {series!.Name}", + $"A scan was ongoing during processing of the scan series task. Task has been rescheduled for 10 minutes: {DateTime.Now.AddMinutes(10)}")); BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10)); return; } @@ -395,9 +463,15 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _scannerService.ScanSeries(seriesId, forceUpdate)); } + /// + /// Calculates TimeToRead and bytes + /// + /// + /// + /// public void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false) { - if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", new object[] {libraryId, seriesId, forceUpdate})) + if (HasAlreadyEnqueuedTask("WordCountAnalyzerService", "ScanSeries", [libraryId, seriesId, forceUpdate])) { _logger.LogInformation("A duplicate request to scan series occured. Skipping"); return; @@ -418,6 +492,11 @@ public class TaskScheduler : ITaskScheduler await _versionUpdaterService.PushUpdate(update); } + public async Task SyncThemes() + { + await _themeService.SyncThemes(); + } + /// /// If there is an enqueued or scheduled task for method /// @@ -427,8 +506,14 @@ public class TaskScheduler : ITaskScheduler public static bool HasScanTaskRunningForLibrary(int libraryId, bool checkRunningJobs = true) { return - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, true}, ScanQueue, checkRunningJobs) || - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", new object[] {libraryId, false}, ScanQueue, checkRunningJobs); + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, true, true], ScanQueue, + checkRunningJobs) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, false, true], ScanQueue, + checkRunningJobs) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, true, false], ScanQueue, + checkRunningJobs) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanLibrary", [libraryId, false, false], ScanQueue, + checkRunningJobs); } /// @@ -440,10 +525,11 @@ public class TaskScheduler : ITaskScheduler public static bool HasScanTaskRunningForSeries(int seriesId, bool checkRunningJobs = true) { return - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, true}, ScanQueue, checkRunningJobs) || - HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", new object[] {seriesId, false}, ScanQueue, checkRunningJobs); + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, true], ScanQueue, checkRunningJobs) || + HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, false], ScanQueue, checkRunningJobs); } + /// /// Checks if this same invocation is already enqueued or scheduled /// @@ -452,6 +538,7 @@ public class TaskScheduler : ITaskScheduler /// object[] of arguments in the order they are passed to enqueued job /// Queue to check against. Defaults to "default" /// Check against running jobs. Defaults to false. + /// Check against arguments. Defaults to true. /// public static bool HasAlreadyEnqueuedTask(string className, string methodName, object[] args, string queue = DefaultQueue, bool checkRunningJobs = false) { @@ -483,6 +570,7 @@ public class TaskScheduler : ITaskScheduler return false; } + /// /// Checks against any jobs that are running or about to run /// diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index a95b9f108..e2ed61ba1 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -45,8 +45,6 @@ public class BackupService : IBackupService _backupFiles = new List() { "appsettings.json", - "Hangfire.db", // This is not used atm - "Hangfire-log.db", // This is not used atm "kavita.db", "kavita.db-shm", // This wont always be there "kavita.db-wal" // This wont always be there @@ -106,22 +104,29 @@ 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); await SendProgress(0.25F, "Copying cover images"); - await CopyCoverImagesToBackupDirectory(tempDirectory); - await SendProgress(0.5F, "Copying bookmarks"); + await SendProgress(0.35F, "Copying templates images"); + CopyTemplatesToBackupDirectory(tempDirectory); + await SendProgress(0.5F, "Copying bookmarks"); await CopyBookmarksToBackupDirectory(tempDirectory); await SendProgress(0.75F, "Copying themes"); - CopyThemesToBackupDirectory(tempDirectory); + await SendProgress(0.85F, "Copying favicons"); CopyFaviconsToBackupDirectory(tempDirectory); @@ -150,6 +155,11 @@ public class BackupService : IBackupService _directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, _directoryService.FileSystem.Path.Join(tempDirectory, "favicons")); } + private void CopyTemplatesToBackupDirectory(string tempDirectory) + { + _directoryService.CopyDirectoryToDirectory(_directoryService.TemplateDirectory, _directoryService.FileSystem.Path.Join(tempDirectory, "templates")); + } + private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) { var outputTempDir = Path.Join(tempDirectory, "covers"); @@ -169,6 +179,10 @@ public class BackupService : IBackupService _directoryService.CopyFilesToDirectory( chapterImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + var volumeImages = await _unitOfWork.VolumeRepository.GetCoverImagesForLockedVolumesAsync(); + _directoryService.CopyFilesToDirectory( + volumeImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); + var libraryImages = await _unitOfWork.LibraryRepository.GetAllCoverImagesAsync(); _directoryService.CopyFilesToDirectory( libraryImages.Select(s => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, s)), outputTempDir); diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 3aaa2c837..e39600c3f 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -8,8 +8,10 @@ using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; using API.Helpers; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -20,6 +22,7 @@ public interface ICleanupService Task Cleanup(); Task CleanupDbEntries(); void CleanupCacheAndTempDirectories(); + void CleanupCacheDirectory(); Task DeleteSeriesCoverImages(); Task DeleteChapterCoverImages(); Task DeleteTagCoverImages(); @@ -32,6 +35,11 @@ public interface ICleanupService /// /// Task CleanupWantToRead(); + + Task ConsolidateProgress(); + + Task CleanupMediaErrors(); + } /// /// Cleans up after operations on reoccurring basis @@ -73,13 +81,23 @@ public class CleanupService : ICleanupService _logger.LogInformation("Starting Cleanup"); await SendProgress(0F, "Starting cleanup"); + _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); + await SendProgress(0.1F, "Cleaning temp directory"); CleanupCacheAndTempDirectories(); + await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); + + await SendProgress(0.35F, "Consolidating Progress Events"); + await ConsolidateProgress(); + + await SendProgress(0.4F, "Consolidating Media Errors"); + await CleanupMediaErrors(); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); @@ -106,7 +124,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 +196,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. /// @@ -208,6 +243,108 @@ public class CleanupService : ICleanupService _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + /// + /// Find any progress events that have duplicate, find the highest page read event, then copy over information from that and delete others, to leave one. + /// + public async Task ConsolidateProgress() + { + _logger.LogInformation("Consolidating Progress Events"); + // AppUserProgress + var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress(); + + // Group by the unique identifiers that would make a progress entry unique + var duplicateGroups = allProgress + .GroupBy(p => new + { + p.AppUserId, + p.ChapterId, + }) + .Where(g => g.Count() > 1); + + foreach (var group in duplicateGroups) + { + // Find the entry with the highest pages read + var highestProgress = group + .OrderByDescending(p => p.PagesRead) + .ThenByDescending(p => p.LastModifiedUtc) + .First(); + + // Get the duplicate entries to remove (all except the highest progress) + var duplicatesToRemove = group + .Where(p => p.Id != highestProgress.Id) + .ToList(); + + // Copy over any non-null BookScrollId if the highest progress entry doesn't have one + if (string.IsNullOrEmpty(highestProgress.BookScrollId)) + { + var firstValidScrollId = duplicatesToRemove + .FirstOrDefault(p => !string.IsNullOrEmpty(p.BookScrollId)) + ?.BookScrollId; + + if (firstValidScrollId != null) + { + highestProgress.BookScrollId = firstValidScrollId; + highestProgress.MarkModified(); + } + } + + // Remove the duplicates + foreach (var duplicate in duplicatesToRemove) + { + _unitOfWork.AppUserProgressRepository.Remove(duplicate); + } + } + + // Save changes + await _unitOfWork.CommitAsync(); + } + + /// + /// Scans through Media Error and removes any entries that have been fixed and are within the DB (proper files where wordcount/pagecount > 0) + /// + public async Task CleanupMediaErrors() + { + try + { + List errorStrings = ["This archive cannot be read or not supported", "File format not supported"]; + var mediaErrors = await _unitOfWork.MediaErrorRepository.GetAllErrorsAsync(errorStrings); + _logger.LogInformation("Beginning consolidation of {Count} Media Errors", mediaErrors.Count); + + var pathToErrorMap = mediaErrors + .GroupBy(me => Parser.NormalizePath(me.FilePath)) + .ToDictionary( + group => group.Key, + group => group.ToList() // The same file can be duplicated (rare issue when network drives die out midscan) + ); + + var normalizedPaths = pathToErrorMap.Keys.ToList(); + + // Find all files that are valid + var validFiles = await _unitOfWork.DataContext.MangaFile + .Where(f => normalizedPaths.Contains(f.FilePath) && f.Pages > 0) + .Select(f => f.FilePath) + .ToListAsync(); + + var removalCount = 0; + foreach (var validFilePath in validFiles) + { + if (!pathToErrorMap.TryGetValue(validFilePath, out var mediaError)) continue; + + _unitOfWork.MediaErrorRepository.Remove(mediaError); + removalCount++; + } + + await _unitOfWork.CommitAsync(); + + _logger.LogInformation("Finished consolidation of {Count} Media Errors, Removed: {RemovalCount}", + mediaErrors.Count, removalCount); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception consolidating media errors"); + } + } + public async Task CleanupLogs() { _logger.LogInformation("Performing cleanup of logs directory"); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs new file mode 100644 index 000000000..c76bb99d1 --- /dev/null +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -0,0 +1,643 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.SignalR; +using EasyCaching.Core; +using Flurl; +using Flurl.Http; +using HtmlAgilityPack; +using Kavita.Common; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NetVips; + + +namespace API.Services.Tasks.Metadata; +#nullable enable + +public interface ICoverDbService +{ + Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat); + Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat); + Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url); + Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false); + Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); + Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false); +} + + +public class CoverDbService : ICoverDbService +{ + private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly IEasyCachingProviderFactory _cacheFactory; + private readonly IHostEnvironment _env; + private readonly IImageService _imageService; + private readonly IUnitOfWork _unitOfWork; + private readonly IEventHub _eventHub; + + private const string NewHost = "https://www.kavitareader.com/CoversDB/"; + + private static readonly string[] ValidIconRelations = { + "icon", + "apple-touch-icon", + "apple-touch-icon-precomposed", + "apple-touch-icon icon-precomposed" // ComicVine has it combined + }; + + /// + /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon) + /// + private static readonly Dictionary FaviconUrlMapper = new() + { + ["https://app.plex.tv"] = "https://plex.tv" + }; + /// + /// Cache of the publisher/favicon list + /// + private static readonly TimeSpan CacheDuration = TimeSpan.FromDays(1); + + public CoverDbService(ILogger logger, IDirectoryService directoryService, + IEasyCachingProviderFactory cacheFactory, IHostEnvironment env, IImageService imageService, + IUnitOfWork unitOfWork, IEventHub eventHub) + { + _logger = logger; + _directoryService = directoryService; + _cacheFactory = cacheFactory; + _env = env; + _imageService = imageService; + _unitOfWork = unitOfWork; + _eventHub = eventHub; + } + + public async Task DownloadFaviconAsync(string url, EncodeFormat encodeFormat) + { + // Parse the URL to get the domain (including subdomain) + var uri = new Uri(url); + var domain = uri.Host.Replace(Environment.NewLine, string.Empty); + var baseUrl = uri.Scheme + "://" + uri.Host; + + + var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon); + var res = await provider.GetAsync(baseUrl); + if (res.HasValue) + { + var sanitizedBaseUrl = baseUrl.Sanitize(); + _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", sanitizedBaseUrl); + throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check"); + } + + await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10)); + if (FaviconUrlMapper.TryGetValue(baseUrl, out var value)) + { + url = value; + } + + var correctSizeLink = string.Empty; + + try + { + var htmlContent = url.GetStringAsync().Result; + var htmlDocument = new HtmlDocument(); + htmlDocument.LoadHtml(htmlContent); + + var pngLinks = htmlDocument.DocumentNode.Descendants("link") + .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty))) + .Select(link => link.GetAttributeValue("href", string.Empty)) + .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain); + } + + try + { + if (string.IsNullOrEmpty(correctSizeLink)) + { + correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl); + } + if (string.IsNullOrEmpty(correctSizeLink)) + { + throw new KavitaException($"Could not grab favicon from {baseUrl}"); + } + + var finalUrl = correctSizeLink; + + // If starts with //, it's coming usually from an offsite cdn + if (correctSizeLink.StartsWith("//")) + { + finalUrl = "https:" + correctSizeLink; + } + else if (!correctSizeLink.StartsWith(uri.Scheme)) + { + finalUrl = Url.Combine(baseUrl, correctSizeLink); + } + + _logger.LogTrace("Fetching favicon from {Url}", finalUrl); + // Download the favicon.ico file using Flurl + var faviconStream = await finalUrl + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + // Create the destination file path + using var image = Image.PngloadStream(faviconStream); + var filename = ImageService.GetWebLinkFormat(baseUrl, encodeFormat); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + + _logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain); + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading favicon for {Domain}", domain); + throw; + } + } + + public async Task DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat) + { + try + { + var publisherLink = await FallbackToKavitaReaderPublisher(publisherName); + if (string.IsNullOrEmpty(publisherLink)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + _logger.LogTrace("Fetching publisher image from {Url}", publisherLink.Sanitize()); + // Download the publisher file using Flurl + var publisherStream = await publisherLink + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + // Create the destination file path + using var image = Image.NewFromStream(publisherStream); + var filename = ImageService.GetPublisherFormat(publisherName, encodeFormat); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + case EncodeFormat.WEBP: + image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + case EncodeFormat.AVIF: + image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + + _logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName.Sanitize()); + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName.Sanitize()); + throw; + } + } + + /// + /// Attempts to download the Person image from CoverDB while matching against metadata within the Person + /// + /// + /// + /// Person image (in correct directory) or null if not found/error + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + return await DownloadPersonImageAsync(person, encodeFormat, personImageLink); + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } + + return null; + } + + /// + /// Attempts to download the Person cover image from a Url + /// + /// + /// + /// + /// + /// + /// + public async Task DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url) + { + try + { + var personImageLink = await GetCoverPersonImagePath(person); + if (string.IsNullOrEmpty(personImageLink)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + + + var filename = await DownloadImageFromUrl(ImageService.GetPersonFormat(person.Id), encodeFormat, personImageLink); + + _logger.LogDebug("Person image for {PersonName} downloaded and saved successfully", person.Name); + + return filename; + } catch (Exception ex) + { + _logger.LogError(ex, "Error downloading image for {PersonName}", person.Name); + } + + return null; + } + + private async Task DownloadImageFromUrl(string filenameWithoutExtension, EncodeFormat encodeFormat, string url) + { + // Create the destination file path + var filename = filenameWithoutExtension + encodeFormat.GetExtension(); + var targetFile = Path.Combine(_directoryService.CoverImageDirectory, filename); + + // Ensure if file exists, we delete to overwrite + + _logger.LogTrace("Fetching person image from {Url}", url.Sanitize()); + // Download the file using Flurl + var personStream = await url + .AllowHttpStatus("2xx,304") + .GetStreamAsync(); + + using var image = Image.NewFromStream(personStream); + switch (encodeFormat) + { + case EncodeFormat.PNG: + image.Pngsave(targetFile); + break; + case EncodeFormat.WEBP: + image.Webpsave(targetFile); + break; + case EncodeFormat.AVIF: + image.Heifsave(targetFile); + break; + default: + throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null); + } + + return filename; + } + + private async Task GetCoverPersonImagePath(Person person) + { + var tempFile = Path.Join(_directoryService.LongTermCacheDirectory, "people.yml"); + + // Check if the file already exists and skip download in Development environment + if (File.Exists(tempFile)) + { + if (_env.IsDevelopment()) + { + _logger.LogInformation("Using existing people.yml file in Development environment"); + } + else + { + // Remove file if not in Development and file is older than 7 days + if (File.GetLastWriteTime(tempFile) < DateTime.Now.AddDays(-7)) + { + File.Delete(tempFile); + } + } + } + + // Download the file if it doesn't exist or was deleted due to age + if (!File.Exists(tempFile)) + { + var masterPeopleFile = await $"{NewHost}people/people.yml" + .DownloadFileAsync(_directoryService.LongTermCacheDirectory); + + if (!File.Exists(tempFile) || string.IsNullOrEmpty(masterPeopleFile)) + { + _logger.LogError("Could not download people.yml from Github"); + return null; + } + } + + + var coverDbRepository = new CoverDbRepository(tempFile); + + var coverAuthor = coverDbRepository.FindBestAuthorMatch(person); + if (coverAuthor == null || string.IsNullOrEmpty(coverAuthor.ImagePath)) + { + throw new KavitaException($"Could not grab person image for {person.Name}"); + } + + return $"{NewHost}{coverAuthor.ImagePath}"; + } + + private async Task FallbackToKavitaReaderFavicon(string baseUrl) + { + const string urlsFileName = "publishers.txt"; + var correctSizeLink = string.Empty; + var allOverrides = await GetCachedData(urlsFileName) ?? + await $"{NewHost}favicons/{urlsFileName}".GetStringAsync(); + + // Cache immediately + await CacheDataAsync(urlsFileName, allOverrides); + + + if (!string.IsNullOrEmpty(allOverrides)) + { + var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty); + var externalFile = allOverrides + .Split("\n") + .FirstOrDefault(url => + cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) || + cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty) + )); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab favicon from {baseUrl.Sanitize()}"); + } + + correctSizeLink = $"{NewHost}favicons/" + externalFile; + } + + return correctSizeLink; + } + + private async Task FallbackToKavitaReaderPublisher(string publisherName) + { + const string publisherFileName = "publishers.txt"; + var externalLink = string.Empty; + var allOverrides = await GetCachedData(publisherFileName) ?? + await $"{NewHost}publishers/{publisherFileName}".GetStringAsync(); + + // Cache immediately + await CacheDataAsync(publisherFileName, allOverrides); + + + if (!string.IsNullOrEmpty(allOverrides)) + { + var externalFile = allOverrides + .Split("\n") + .Select(publisherLine => + { + var tokens = publisherLine.Split("|"); + if (tokens.Length != 2) return null; + var aliases = tokens[0]; + // Multiple publisher aliases are separated by # + if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim()))) + { + return tokens[1]; + } + return null; + }) + .FirstOrDefault(url => !string.IsNullOrEmpty(url)); + + if (string.IsNullOrEmpty(externalFile)) + { + throw new KavitaException($"Could not grab publisher image for {publisherName}"); + } + + externalLink = $"{NewHost}publishers/" + externalFile; + } + + return externalLink; + } + + private async Task CacheDataAsync(string fileName, string? content) + { + if (content == null) return; + + try + { + var filePath = _directoryService.FileSystem.Path.Join(_directoryService.LongTermCacheDirectory, fileName); + await File.WriteAllTextAsync(filePath, content); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache {FileName}", fileName); + } + } + + + private async Task GetCachedData(string cacheFile) + { + // Form the full file path: + var filePath = _directoryService.FileSystem.Path.Join(_directoryService.LongTermCacheDirectory, cacheFile); + if (!File.Exists(filePath)) return null; + + var fileInfo = new FileInfo(filePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + return await File.ReadAllTextAsync(filePath); + } + + return null; + } + + /// + /// + /// + /// + /// + /// + /// Will check against all known null image placeholders to avoid writing it + public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false) + { + // TODO: Refactor checkNoImagePlaceholder bool to an action that evaluates how to process Image + if (!string.IsNullOrEmpty(url)) + { + var filePath = await CreateThumbnail(url, $"{ImageService.GetPersonFormat(person.Id)}", fromBase64); + + // Additional check to see if downloaded image is similar and we have a higher resolution + if (checkNoImagePlaceholder) + { + var matchRating = Path.Join(_directoryService.AssetsDirectory, "anilist-no-image-placeholder.jpg").GetSimilarity(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + + if (matchRating >= 0.9f) + { + if (string.IsNullOrEmpty(person.CoverImage)) + { + filePath = null; + } + else + { + filePath = Path.GetFileName(Path.Join(_directoryService.CoverImageDirectory, person.CoverImage)); + } + + } + } + + if (!string.IsNullOrEmpty(filePath)) + { + person.CoverImage = filePath; + person.CoverImageLocked = true; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + } + else + { + person.CoverImage = string.Empty; + person.CoverImageLocked = false; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); + } + } + + /// + /// Sets the series cover by url + /// + /// + /// + /// + /// If images are similar, will choose the higher quality image + public async Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + if (!string.IsNullOrEmpty(url)) + { + var filePath = await CreateThumbnail(url, $"{ImageService.GetSeriesFormat(series.Id)}", fromBase64); + + if (!string.IsNullOrEmpty(filePath)) + { + // Additional check to see if downloaded image is similar and we have a higher resolution + if (chooseBetterImage && !string.IsNullOrEmpty(series.CoverImage)) + { + try + { + var betterImage = Path.Join(_directoryService.CoverImageDirectory, series.CoverImage) + .GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + filePath = Path.GetFileName(betterImage); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue trying to choose a better cover image for Series: {SeriesName} ({SeriesId})", series.Name, series.Id); + } + } + + series.CoverImage = filePath; + series.CoverImageLocked = true; + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + } + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + } + else + { + series.CoverImage = null; + series.CoverImageLocked = false; + if (series.CoverImage == null) + { + _logger.LogDebug("[SeriesCoverImageBug] Setting Series Cover Image to null"); + } + _imageService.UpdateColorScape(series); + _unitOfWork.SeriesRepository.Update(series); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false); + } + } + + public async Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + if (!string.IsNullOrEmpty(url)) + { + var filePath = await CreateThumbnail(url, $"{ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId)}", fromBase64); + + if (!string.IsNullOrEmpty(filePath)) + { + // Additional check to see if downloaded image is similar and we have a higher resolution + if (chooseBetterImage && !string.IsNullOrEmpty(chapter.CoverImage)) + { + try + { + var betterImage = Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage) + .GetBetterImage(Path.Join(_directoryService.CoverImageDirectory, filePath))!; + filePath = Path.GetFileName(betterImage); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue trying to choose a better cover image for Chapter: {FileName} ({ChapterId})", chapter.Range, chapter.Id); + } + } + + chapter.CoverImage = filePath; + chapter.CoverImageLocked = true; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + } + } + else + { + chapter.CoverImage = null; + chapter.CoverImageLocked = false; + _imageService.UpdateColorScape(chapter); + _unitOfWork.ChapterRepository.Update(chapter); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, MessageFactoryEntityTypes.Chapter), false); + } + } + + private async Task CreateThumbnail(string url, string filename, bool fromBase64 = true) + { + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var encodeFormat = settings.EncodeMediaAs; + var coverImageSize = settings.CoverImageSize; + + if (fromBase64) + { + return _imageService.CreateThumbnailFromBase64(url, + filename, encodeFormat, coverImageSize.GetDimensions().Width); + } + + return await DownloadImageFromUrl(filename, encodeFormat, url); + } +} diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index 333c5ef18..bff7001bd 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -33,17 +33,19 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService private readonly IEventHub _eventHub; private readonly ICacheHelper _cacheHelper; private readonly IReaderService _readerService; + private readonly IMediaErrorService _mediaErrorService; private const int AverageCharactersPerWord = 5; public WordCountAnalyzerService(ILogger logger, IUnitOfWork unitOfWork, IEventHub eventHub, - ICacheHelper cacheHelper, IReaderService readerService) + ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService) { _logger = logger; _unitOfWork = unitOfWork; _eventHub = eventHub; _cacheHelper = cacheHelper; _readerService = readerService; + _mediaErrorService = mediaErrorService; } @@ -177,7 +179,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService var pageCounter = 1; try { - using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions); + using var book = await EpubReader.OpenBookAsync(filePath, BookService.LenientBookReaderOptions); var totalPages = book.Content.Html.Local; foreach (var bookPage in totalPages) @@ -188,7 +190,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress, ProgressEventType.Updated, useFileName ? filePath : series.Name)); - sum += await GetWordCountFromHtml(bookPage); + sum += await GetWordCountFromHtml(bookPage, filePath); pageCounter++; } @@ -215,6 +217,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService chapter.MinHoursToRead = est.MinHours; chapter.MaxHoursToRead = est.MaxHours; chapter.AvgHoursToRead = est.AvgHours; + foreach (var file in chapter.Files) { UpdateFileAnalysis(file); @@ -245,13 +248,23 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } - private static async Task GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile) + private async Task GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath) { - var doc = new HtmlDocument(); - doc.LoadHtml(await bookFile.ReadContentAsync()); + try + { + var doc = new HtmlDocument(); + doc.LoadHtml(await bookFile.ReadContentAsync()); - var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); - return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; + var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); + return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; + } + catch (EpubContentException ex) + { + _logger.LogError(ex, "Error when counting words in epub {EpubPath}", filePath); + await _mediaErrorService.ReportMediaIssueAsync(filePath, MediaErrorProducer.BookService, + $"Invalid Epub Metadata, {bookFile.FilePath} does not exist", ex.Message); + return 0; + } } } diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 00dbe135c..d2e6437a3 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -56,9 +56,9 @@ public class LibraryWatcher : ILibraryWatcher /// /// Counts within a time frame how many times the buffer became full. Is used to reschedule LibraryWatcher to start monitoring much later rather than instantly /// - private int _bufferFullCounter; - private int _restartCounter; - private DateTime _lastErrorTime = DateTime.MinValue; + private static int _bufferFullCounter; + private static int _restartCounter; + private static DateTime _lastErrorTime = DateTime.MinValue; /// /// Used to lock buffer Full Counter /// @@ -148,15 +148,30 @@ 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)))); + + var isDirectoryChange = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); + + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange], + checkRunningJobs: true)) + { + return; + } + + BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange)); } private void OnCreated(object sender, FileSystemEventArgs e) { - _logger.LogDebug("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); - BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name))); + _logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); + var isDirectoryChange = !_directoryService.FileSystem.File.Exists(e.Name); + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange], + checkRunningJobs: true)) + { + return; + } + BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange)); } /// @@ -167,7 +182,12 @@ 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); + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, true], + checkRunningJobs: true)) + { + return; + } BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true)); } @@ -258,21 +278,23 @@ public class LibraryWatcher : ILibraryWatcher _logger.LogTrace("Folder path: {FolderPath}", fullPath); if (string.IsNullOrEmpty(fullPath)) { - _logger.LogTrace("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath); + _logger.LogInformation("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath); return; } - _taskScheduler.ScanFolder(fullPath, _queueWaitTime); + _taskScheduler.ScanFolder(fullPath, filePath, _queueWaitTime); } catch (Exception ex) { _logger.LogError(ex, "[LibraryWatcher] An error occured when processing a watch event"); } - _logger.LogDebug("[LibraryWatcher] ProcessChange completed in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds); + _logger.LogTrace("[LibraryWatcher] ProcessChange completed in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds); } private string GetFolder(string filePath, IEnumerable libraryFolders) { + // TODO: I can optimize this to avoid a library scan and instead do a Series Scan by finding the series that has a lowestFolderPath higher or equal to the filePath + var parentDirectory = _directoryService.GetParentDirectoryName(filePath); _logger.LogTrace("[LibraryWatcher] Parent Directory: {ParentDirectory}", parentDirectory); if (string.IsNullOrEmpty(parentDirectory)) return string.Empty; @@ -285,10 +307,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])); } @@ -296,7 +318,7 @@ public class LibraryWatcher : ILibraryWatcher /// This is called via Hangfire to decrement the counter. Must work around a lock /// // ReSharper disable once MemberCanBePrivate.Global - public void UpdateLastBufferOverflow() + public static void UpdateLastBufferOverflow() { lock (Lock) { diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 6c1852846..c3f36ef2e 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -9,6 +11,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; @@ -29,11 +32,59 @@ public class ParsedSeries /// Format of the Series /// public required MangaFormat Format { get; init; } + /// + /// Has this Series changed or not aka do we need to process it or not. + /// + public bool HasChanged { get; set; } +} + +public class ScanResult +{ + /// + /// A list of files in the Folder. Empty if HasChanged = false + /// + public IList Files { get; set; } + /// + /// A nested folder from Library Root (at any level) + /// + public string Folder { get; set; } + /// + /// The library root + /// + public string LibraryRoot { get; set; } + /// + /// Was the Folder scanned or not. If not modified since last scan, this will be false and Files empty + /// + public bool HasChanged { get; set; } + /// + /// Set in Stage 2: Parsed Info from the Files + /// + public IList ParserInfos { get; set; } +} + +/// +/// The final product of ParseScannedFiles. This has all the processed parserInfo and is ready for tracking/processing into entities +/// +public class ScannedSeriesResult +{ + /// + /// Was the Folder scanned or not. If not modified since last scan, this will be false and indicates that upstream should count this as skipped + /// + public bool HasChanged { get; set; } + /// + /// The Parsed Series information used for tracking + /// + public ParsedSeries ParsedSeries { get; set; } + /// + /// Parsed files + /// + public IList ParsedInfos { get; set; } } public class SeriesModified { - public required string FolderPath { get; set; } + public required string? FolderPath { get; set; } + public required string? LowestFolderPath { get; set; } public required string SeriesName { get; set; } public DateTime LastScanned { get; set; } public MangaFormat Format { get; set; } @@ -68,119 +119,282 @@ public class ParseScannedFiles _eventHub = eventHub; } - /// /// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained /// /// Scan directory by directory and for each, call folderAction /// A dictionary mapping a normalized path to a list of to help scanner skip I/O /// A library folder or series folder - /// A callback async Task to be called once all files for each folder path are found /// If we should bypass any folder last write time checks on the scan and force I/O - public async Task ProcessFiles(string folderPath, bool scanDirectoryByDirectory, - IDictionary> seriesPaths, Func, string,Task> folderAction, Library library, bool forceCheck = false) + public async Task> ScanFiles(string folderPath, bool scanDirectoryByDirectory, + IDictionary> seriesPaths, Library library, bool forceCheck = false) { - string normalizedPath; var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex())); + + // If there are no library file types, skip scanning entirely + if (string.IsNullOrWhiteSpace(fileExtensions)) + { + return ArraySegment.Empty; + } + + var matcher = BuildMatcher(library); + + var result = new List(); + + // Not to self: this whole thing can be parallelized because we don't deal with any DB or global state if (scanDirectoryByDirectory) { - // This is used in library scan, so we should check first for a ignore file and use that here as well - var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(folderPath, DirectoryService.KavitaIgnoreFile); - var matcher = _directoryService.CreateMatcherFromFile(potentialIgnoreFile); - if (matcher != null) - { - _logger.LogWarning(".kavitaignore found! Ignore files is deprecated in favor of Library Settings. Please update and remove file at {Path}", potentialIgnoreFile); - } - - if (library.LibraryExcludePatterns.Count != 0) - { - matcher ??= new GlobMatcher(); - foreach (var pattern in library.LibraryExcludePatterns.Where(p => !string.IsNullOrEmpty(p.Pattern))) - { - - matcher.AddExclude(pattern.Pattern); - } - } - - - var directories = _directoryService.GetDirectories(folderPath, matcher).ToList(); - - foreach (var directory in directories) - { - normalizedPath = Parser.Parser.NormalizePath(directory); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) - { - await folderAction(new List(), directory); - } - else - { - // For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication - await folderAction(_directoryService.ScanFiles(directory, fileExtensions, matcher), directory); - } - } - - return; + return await ScanDirectories(folderPath, seriesPaths, library, forceCheck, matcher, result, fileExtensions); } - normalizedPath = Parser.Parser.NormalizePath(folderPath); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck)) - { - await folderAction(new List(), folderPath); - return; - } - // We need to calculate all folders till library root and see if any kavitaignores - var seriesMatcher = BuildIgnoreFromLibraryRoot(folderPath, seriesPaths); - - await folderAction(_directoryService.ScanFiles(folderPath, fileExtensions, seriesMatcher), folderPath); + return await ScanSingleDirectory(folderPath, seriesPaths, library, forceCheck, result, fileExtensions, matcher); } - /// - /// Used in ScanSeries, which enters at a lower level folder and hence needs a .kavitaignore from higher (up to root) to be built before - /// the scan takes place. - /// - /// - /// - /// A GlobMatter. Empty if not applicable - private GlobMatcher BuildIgnoreFromLibraryRoot(string folderPath, IDictionary> seriesPaths) + private async Task> ScanDirectories(string folderPath, IDictionary> seriesPaths, + Library library, bool forceCheck, GlobMatcher matcher, List result, string fileExtensions) { - var seriesMatcher = new GlobMatcher(); - try - { - var roots = seriesPaths[folderPath][0].LibraryRoots.Select(Parser.Parser.NormalizePath).ToList(); - var libraryFolder = roots.SingleOrDefault(folderPath.Contains); + var allDirectories = _directoryService.GetAllDirectories(folderPath, matcher) + .Select(Parser.Parser.NormalizePath) + .OrderByDescending(d => d.Length) + .ToList(); - if (string.IsNullOrEmpty(libraryFolder) || !Directory.Exists(folderPath)) + var processedDirs = new HashSet(); + + _logger.LogDebug("[ScannerService] Step 1.C Found {DirectoryCount} directories to process for {FolderPath}", allDirectories.Count, folderPath); + foreach (var directory in allDirectories) + { + // Don't process any folders where we've already scanned everything below + if (processedDirs.Any(d => d.StartsWith(directory + Path.AltDirectorySeparatorChar) || d.Equals(directory))) { - return seriesMatcher; + var hasChanged = !HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, directory, forceCheck); + // Skip this directory as we've already processed a parent unless there are loose files at that directory + // and they have changes + CheckSurfaceFiles(result, directory, folderPath, fileExtensions, matcher, hasChanged); + continue; } - var allParents = _directoryService.GetFoldersTillRoot(libraryFolder, folderPath); - var path = libraryFolder; - - // Apply the library root level kavitaignore - var potentialIgnoreFile = _directoryService.FileSystem.Path.Join(path, DirectoryService.KavitaIgnoreFile); - seriesMatcher.Merge(_directoryService.CreateMatcherFromFile(potentialIgnoreFile)); - - // Then apply kavitaignores for each folder down to where the series folder is - foreach (var folderPart in allParents.Reverse()) + // Skip directories ending with "Specials", let the parent handle it + if (directory.EndsWith("Specials", StringComparison.OrdinalIgnoreCase)) { - path = Parser.Parser.NormalizePath(Path.Join(libraryFolder, folderPart)); - potentialIgnoreFile = _directoryService.FileSystem.Path.Join(path, DirectoryService.KavitaIgnoreFile); - seriesMatcher.Merge(_directoryService.CreateMatcherFromFile(potentialIgnoreFile)); + // Log or handle that we are skipping this directory + _logger.LogDebug("Skipping {Directory} as it ends with 'Specials'", directory); + continue; + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); + + if (HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, directory, forceCheck)) + { + HandleUnchangedFolder(result, folderPath, directory); + } + else + { + PerformFullScan(result, directory, folderPath, fileExtensions, matcher); + } + + processedDirs.Add(directory); + } + + return result; + } + + /// + /// Checks against all folder paths on file if the last scanned is >= the directory's last write time, down to the second + /// + /// + /// + /// This should be normalized + /// + /// + private bool HasSeriesFolderNotChangedSinceLastScan(Library library, IDictionary> seriesPaths, string directory, bool forceCheck) + { + // Reverting code from: https://github.com/Kareadita/Kavita/pull/3619/files#diff-0625df477047ab9d8e97a900201f2f29b2dc0599ba58eb75cfbbd073a9f3c72f + // This is to be able to release hotfix and tackle this in appropriate time + + // With the bottom-up approach, this can report a false positive where a nested folder will get scanned even though a parent is the series + // This can't really be avoided. This is more likely to happen on Image chapter folder library layouts. + if (forceCheck || !seriesPaths.TryGetValue(directory, out var seriesList)) + { + return false; + } + + // if (forceCheck) + // { + // return false; + // } + + // TryGetSeriesList falls back to parent folders to match to seriesList + // var seriesList = TryGetSeriesList(library, seriesPaths, directory); + // if (seriesList == null) + // { + // return false; + // } + + foreach (var series in seriesList) + { + var lastWriteTime = _directoryService.GetLastWriteTime(series.LowestFolderPath!).Truncate(TimeSpan.TicksPerSecond); + var seriesLastScanned = series.LastScanned.Truncate(TimeSpan.TicksPerSecond); + if (seriesLastScanned < lastWriteTime) + { + return false; } } - catch (Exception ex) + + return true; + } + + private IList? TryGetSeriesList(Library library, IDictionary> seriesPaths, string directory) + { + if (seriesPaths.Count == 0) { - _logger.LogError(ex, - "[ScannerService] There was an error trying to find and apply .kavitaignores above the Series Folder. Scanning without them present"); + return null; } - return seriesMatcher; + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + if (library.Folders.Any(fp => fp.Path.Equals(directory))) + { + return null; + } + + if (seriesPaths.TryGetValue(directory, out var seriesList)) + { + return seriesList; + } + + return TryGetSeriesList(library, seriesPaths, _directoryService.GetParentDirectoryName(directory)); + } + + /// + /// Handles directories that haven't changed since the last scan. + /// + private void HandleUnchangedFolder(List result, string folderPath, string directory) + { + if (result.Exists(r => r.Folder == directory)) + { + _logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added, this indicates a bad layout issue", directory); + } + else + { + _logger.LogDebug("[ProcessFiles] Skipping {Directory} as it hasn't changed since last scan", directory); + result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); + } + } + + /// + /// Performs a full scan of the directory and adds it to the result. + /// + private void PerformFullScan(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher) + { + _logger.LogDebug("[ProcessFiles] Performing full scan on {Directory}", directory); + var files = _directoryService.ScanFiles(directory, fileExtensions, matcher); + if (files.Count == 0) + { + _logger.LogDebug("[ProcessFiles] Empty directory: {Directory}. Keeping empty will cause Kavita to scan this each time", directory); + } + result.Add(CreateScanResult(directory, folderPath, true, files)); + } + + /// + /// Performs a full scan of the directory and adds it to the result. + /// + private void CheckSurfaceFiles(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher, bool hasChanged) + { + var files = _directoryService.ScanFiles(directory, fileExtensions, matcher, SearchOption.TopDirectoryOnly); + if (files.Count == 0) + { + return; + } + // Revert of https://github.com/Kareadita/Kavita/pull/3629/files#diff-0625df477047ab9d8e97a900201f2f29b2dc0599ba58eb75cfbbd073a9f3c72f + // for Hotfix v0.8.5.x + result.Add(CreateScanResult(directory, folderPath, true, files)); + } + + /// + /// Scans a single directory and processes the scan result. + /// + private async Task> ScanSingleDirectory(string folderPath, IDictionary> seriesPaths, Library library, bool forceCheck, List result, + string fileExtensions, GlobMatcher matcher) + { + var normalizedPath = Parser.Parser.NormalizePath(folderPath); + var libraryRoot = + library.Folders.FirstOrDefault(f => + normalizedPath.Contains(Parser.Parser.NormalizePath(f.Path)))?.Path ?? + folderPath; + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent(normalizedPath, library.Name, ProgressEventType.Updated)); + + if (HasSeriesFolderNotChangedSinceLastScan(library, seriesPaths, normalizedPath, forceCheck)) + { + result.Add(CreateScanResult(folderPath, libraryRoot, false, ArraySegment.Empty)); + } + else + { + result.Add(CreateScanResult(folderPath, libraryRoot, true, + _directoryService.ScanFiles(folderPath, fileExtensions, matcher))); + } + + return result; + } + + private static GlobMatcher BuildMatcher(Library library) + { + var matcher = new GlobMatcher(); + foreach (var pattern in library.LibraryExcludePatterns.Where(p => !string.IsNullOrEmpty(p.Pattern))) + { + matcher.AddExclude(pattern.Pattern); + } + + return matcher; + } + + private static ScanResult CreateScanResult(string folderPath, string libraryRoot, bool hasChanged, + IList files) + { + return new ScanResult() + { + Files = files, + Folder = Parser.Parser.NormalizePath(folderPath), + LibraryRoot = libraryRoot, + HasChanged = hasChanged + }; + } + + /// + /// Processes scanResults to track all series across the combined results. + /// Ensures series are correctly grouped even if they span multiple folders. + /// + /// A collection of scan results + /// A concurrent dictionary to store the tracked series + private void TrackSeriesAcrossScanResults(IList scanResults, ConcurrentDictionary> scannedSeries) + { + // Flatten all ParserInfos from scanResults + var allInfos = scanResults.SelectMany(sr => sr.ParserInfos).ToList(); + + // Iterate through each ParserInfo and track the series + foreach (var info in allInfos) + { + if (info == null) continue; + + try + { + TrackSeries(scannedSeries, info); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] Exception occurred during tracking {FilePath}. Skipping this file", info?.FullFilePath); + } + } } /// - /// Attempts to either add a new instance of a show mapping to the _scannedSeries bag or adds to an existing. + /// Attempts to either add a new instance of a series mapping to the _scannedSeries bag or adds to an existing. /// This will check if the name matches an existing series name (multiple fields) /// /// A localized list of a series' parsed infos @@ -192,6 +406,8 @@ public class ParseScannedFiles // Check if normalized info.Series already exists and if so, update info to use that name instead info.Series = MergeName(scannedSeries, info); + // BUG: This will fail for Solo Leveling & Solo Leveling (Manga) + var normalizedSeries = info.Series.ToNormalized(); var normalizedSortSeries = info.SeriesSort.ToNormalized(); var normalizedLocalizedSeries = info.LocalizedSeries.ToNormalized(); @@ -209,7 +425,7 @@ public class ParseScannedFiles NormalizedName = normalizedSeries }; - scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => + scannedSeries.AddOrUpdate(existingKey, [info], (_, oldValue) => { oldValue ??= new List(); if (!oldValue.Contains(info)) @@ -222,13 +438,13 @@ public class ParseScannedFiles } catch (Exception ex) { - _logger.LogCritical(ex, "[ScannerService] {SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); + _logger.LogCritical("[ScannerService] {SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); foreach (var seriesKey in scannedSeries.Keys.Where(ps => ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) || ps.NormalizedName.Equals(normalizedLocalizedSeries) || ps.NormalizedName.Equals(normalizedSortSeries)))) { - _logger.LogCritical("[ScannerService] Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name); + _logger.LogCritical("[ScannerService] Matches: '{SeriesName}' matches on '{SeriesKey}'", info.Series, seriesKey.Name); } } } @@ -267,11 +483,12 @@ public class ParseScannedFiles } catch (Exception ex) { - _logger.LogCritical(ex, "[ScannerService] Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); + _logger.LogCritical("[ScannerService] Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); var values = scannedSeries.Where(p => (p.Key.NormalizedName.ToNormalized() == normalizedSeries || p.Key.NormalizedName.ToNormalized() == normalizedLocalSeries) && p.Key.Format == info.Format); + foreach (var pair in values) { _logger.LogCritical("[ScannerService] Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name); @@ -282,7 +499,6 @@ public class ParseScannedFiles return info.Series; } - /// /// This will process series by folder groups. This is used solely by ScanSeries /// @@ -290,107 +506,135 @@ public class ParseScannedFiles /// /// If true, does a directory scan first (resulting in folders being tackled in parallel), else does an immediate scan files /// A map of Series names -> existing folder paths to handle skipping folders - /// Action which returns if the folder was skipped and the infos from said folder /// Defaults to false /// - public async Task ScanLibrariesForSeries(Library library, - IEnumerable folders, bool isLibraryScan, - IDictionary> seriesPaths, Func>, Task>? processSeriesInfos, bool forceCheck = false) + public async Task> ScanLibrariesForSeries(Library library, + IList folders, bool isLibraryScan, + IDictionary> seriesPaths, bool forceCheck = false) { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); - foreach (var folderPath in folders) + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count); + var processedScannedSeries = new ConcurrentBag(); + + foreach (var folder in folders) { try { - await ProcessFiles(folderPath, isLibraryScan, seriesPaths, ProcessFolder, library, forceCheck); + await ScanAndParseFolder(folder, library, isLibraryScan, seriesPaths, processedScannedSeries, forceCheck); } catch (ArgumentException ex) { - _logger.LogError(ex, "[ScannerService] The directory '{FolderPath}' does not exist", folderPath); + _logger.LogError(ex, "[ScannerService] The directory '{FolderPath}' does not exist", folder); } } - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", library.Name, ProgressEventType.Ended)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent("File Scan Done", library.Name, ProgressEventType.Ended)); - async Task ProcessFolder(IList files, string folder) - { - var normalizedFolder = Parser.Parser.NormalizePath(folder); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedFolder, forceCheck)) - { - var parsedInfos = seriesPaths[normalizedFolder].Select(fp => new ParserInfo() - { - Series = fp.SeriesName, - Format = fp.Format, - }).ToList(); - if (processSeriesInfos != null) - await processSeriesInfos.Invoke(new Tuple>(true, parsedInfos)); - _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed since last scan", folder); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent("Skipped " + normalizedFolder, library.Name, ProgressEventType.Updated)); - return; - } - - _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 or is no longer in this location", folder); - return; - } - - var scannedSeries = new ConcurrentDictionary>(); - var infos = files - .Select(file => _readingItemService.ParseFile(file, folder, library.Type)) - .Where(info => info != null) - .ToList(); - - - MergeLocalizedSeriesWithSeries(infos); - - foreach (var info in infos) - { - 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 && processSeriesInfos != null) - { - await processSeriesInfos.Invoke(new Tuple>(false, scannedSeries[series])); - } - } - } + return processedScannedSeries.ToList(); } /// - /// Checks against all folder paths on file if the last scanned is >= the directory's last write down to the second + /// Helper method to scan and parse a folder /// + /// + /// + /// /// - /// + /// /// - /// - private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string normalizedFolder, bool forceCheck = false) + private async Task ScanAndParseFolder(string folderPath, Library library, + bool isLibraryScan, IDictionary> seriesPaths, + ConcurrentBag processedScannedSeries, bool forceCheck) { - if (forceCheck) return false; + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath); + var scanResults = await ScanFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); - return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= - _directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond)); + // Aggregate the scanned series across all scanResults + var scannedSeries = new ConcurrentDictionary>(); + + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath); + foreach (var scanResult in scanResults) + { + await ParseFiles(scanResult, seriesPaths, library); + } + + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.D: Merge any localized series with series {Folder}", library.Name, folderPath); + scanResults = MergeLocalizedSeriesAcrossScanResults(scanResults); + + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.E: Group all parsed data into logical Series", library.Name); + TrackSeriesAcrossScanResults(scanResults, scannedSeries); + + + // Now transform and add to processedScannedSeries AFTER everything is processed + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.F: Generate Sort Order for Series and Finalize", library.Name); + GenerateProcessedScannedSeries(scannedSeries, scanResults, processedScannedSeries); } /// - /// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so, - /// rewrites the infos with series name instead of the localized name, so they stack. + /// Processes and generates the final results for processedScannedSeries after updating sort order. + /// + /// A concurrent dictionary of tracked series and their parsed infos + /// List of all scan results, used to determine if any series has changed + /// A thread-safe concurrent bag of processed series results + private void GenerateProcessedScannedSeries(ConcurrentDictionary> scannedSeries, IList scanResults, ConcurrentBag processedScannedSeries) + { + // First, update the sort order for all series + UpdateSeriesSortOrder(scannedSeries); + + // Now, generate the final processed scanned series results + CreateFinalSeriesResults(scannedSeries, scanResults, processedScannedSeries); + } + + /// + /// Updates the sort order for all series in the scannedSeries dictionary. + /// + /// A concurrent dictionary of tracked series and their parsed infos + private void UpdateSeriesSortOrder(ConcurrentDictionary> scannedSeries) + { + foreach (var series in scannedSeries.Keys) + { + if (scannedSeries[series].Count <= 0) continue; + + try + { + UpdateSortOrder(scannedSeries, series); // Call to method that updates sort order + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] Issue occurred while setting IssueOrder for series {SeriesName}", series.Name); + } + } + } + + /// + /// Generates the final processed scanned series results after processing the sort order. + /// + /// A concurrent dictionary of tracked series and their parsed infos + /// List of all scan results, used to determine if any series has changed + /// The list where processed results will be added + private static void CreateFinalSeriesResults(ConcurrentDictionary> scannedSeries, + IList scanResults, ConcurrentBag processedScannedSeries) + { + foreach (var series in scannedSeries.Keys) + { + if (scannedSeries[series].Count <= 0) continue; + + processedScannedSeries.Add(new ScannedSeriesResult + { + HasChanged = scanResults.Any(sr => sr.HasChanged), // Combine HasChanged flag across all scanResults + ParsedSeries = series, + ParsedInfos = scannedSeries[series] + }); + } + } + + /// + /// Merges localized series with the series field across all scan results. + /// Combines ParserInfos from all scanResults and processes them collectively + /// to ensure consistent series names. /// /// /// Accel World v01.cbz has Series "Accel World" and Localized Series "World of Acceleration" @@ -398,47 +642,263 @@ public class ParseScannedFiles /// After running this code, we'd have: /// World of Acceleration v02.cbz having Series "Accel World" and Localized Series of "World of Acceleration" /// - /// A collection of ParserInfos - private void MergeLocalizedSeriesWithSeries(IReadOnlyCollection infos) + /// A collection of scan results + /// A new list of scan results with merged series + private IList MergeLocalizedSeriesAcrossScanResults(IList scanResults) { - var hasLocalizedSeries = infos.Any(i => !string.IsNullOrEmpty(i.LocalizedSeries)); - if (!hasLocalizedSeries) return; + // Flatten all ParserInfos across scanResults + var allInfos = scanResults.SelectMany(sr => sr.ParserInfos).ToList(); - var localizedSeries = infos - .Where(i => !i.IsSpecial) + // Filter relevant infos (non-special and with localized series) + var relevantInfos = GetRelevantInfos(allInfos); + + if (relevantInfos.Count == 0) return scanResults; + + // Get distinct localized series and process each one + var distinctLocalizedSeries = relevantInfos .Select(i => i.LocalizedSeries) .Distinct() - .FirstOrDefault(i => !string.IsNullOrEmpty(i)); - if (string.IsNullOrEmpty(localizedSeries)) return; + .ToList(); - // NOTE: If we have multiple series in a folder with a localized title, then this will fail. It will group into one series. User needs to fix this themselves. - string? nonLocalizedSeries; - // Normalize this as many of the cases is a capitalization difference - var nonLocalizedSeriesFound = infos - .Where(i => !i.IsSpecial) - .Select(i => i.Series).DistinctBy(Parser.Parser.Normalize).ToList(); - if (nonLocalizedSeriesFound.Count == 1) + foreach (var localizedSeries in distinctLocalizedSeries) { - nonLocalizedSeries = nonLocalizedSeriesFound[0]; + if (string.IsNullOrEmpty(localizedSeries)) continue; + + // Process the localized series for merging + ProcessLocalizedSeries(scanResults, allInfos, relevantInfos, localizedSeries); + } + + // Remove or clear any scan results that now have no ParserInfos after merging + return scanResults.Where(sr => sr.ParserInfos.Count > 0).ToList(); + } + + private static List GetRelevantInfos(List allInfos) + { + return allInfos + .Where(i => !i.IsSpecial && !string.IsNullOrEmpty(i.LocalizedSeries)) + .GroupBy(i => i.Format) + .SelectMany(g => g.ToList()) + .ToList(); + } + + private void ProcessLocalizedSeries(IList scanResults, List allInfos, List relevantInfos, string localizedSeries) + { + var seriesForLocalized = GetSeriesForLocalized(relevantInfos, localizedSeries); + if (seriesForLocalized.Count == 0) return; + + var nonLocalizedSeries = GetNonLocalizedSeries(seriesForLocalized, localizedSeries); + if (nonLocalizedSeries == null) return; + + // Remap and update relevant ParserInfos + RemapSeries(scanResults, allInfos, localizedSeries, nonLocalizedSeries); + + } + + private static List GetSeriesForLocalized(List relevantInfos, string localizedSeries) + { + return relevantInfos + .Where(i => i.LocalizedSeries == localizedSeries) + .DistinctBy(r => r.Series) + .Select(r => r.Series) + .ToList(); + } + + private string? GetNonLocalizedSeries(List seriesForLocalized, string localizedSeries) + { + switch (seriesForLocalized.Count) + { + case 1: + return seriesForLocalized[0]; + case <= 2: + return seriesForLocalized.FirstOrDefault(s => !s.Equals(Parser.Parser.Normalize(localizedSeries))); + default: + _logger.LogError( + "[ScannerService] Multiple series detected across scan results that contain localized series. " + + "This will cause them to group incorrectly. Please separate series into their own dedicated folder: {LocalizedSeries}", + string.Join(", ", seriesForLocalized) + ); + return null; + } + } + + private static void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) + { + // If the series names are identical, no remapping is needed (rare but valid) + if (localizedSeries.ToNormalized().Equals(nonLocalizedSeries.ToNormalized())) + { + return; + } + + // Find all infos that need to be remapped from the localized series to the non-localized series + var normalizedLocalizedSeries = localizedSeries.ToNormalized(); + var seriesToBeRemapped = allInfos.Where(i => i.Series.ToNormalized().Equals(normalizedLocalizedSeries)).ToList(); + + foreach (var infoNeedingMapping in seriesToBeRemapped) + { + infoNeedingMapping.Series = nonLocalizedSeries; + + // Find the scan result containing the localized info + var localizedScanResult = scanResults.FirstOrDefault(sr => sr.ParserInfos.Contains(infoNeedingMapping)); + if (localizedScanResult == null) continue; + + // Remove the localized series from this scan result + localizedScanResult.ParserInfos.Remove(infoNeedingMapping); + + // Find the scan result that should be merged with + var nonLocalizedScanResult = scanResults.FirstOrDefault(sr => sr.ParserInfos.Any(pi => pi.Series == nonLocalizedSeries)); + + if (nonLocalizedScanResult == null) continue; + + // Add the remapped info to the non-localized scan result + nonLocalizedScanResult.ParserInfos.Add(infoNeedingMapping); + + // Assign the higher folder path (i.e., the one closer to the root) + //nonLocalizedScanResult.Folder = DirectoryService.GetDeepestCommonPath(localizedScanResult.Folder, nonLocalizedScanResult.Folder); + } + } + + /// + /// For a given ScanResult, sets the ParserInfos on the result + /// + /// + /// + /// + private async Task ParseFiles(ScanResult result, IDictionary> seriesPaths, Library library) + { + var normalizedFolder = Parser.Parser.NormalizePath(result.Folder); + + // If folder hasn't changed, generate fake ParserInfos + if (!result.HasChanged) + { + result.ParserInfos = seriesPaths[normalizedFolder] + .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) + .ToList(); + + // // We are certain TryGetSeriesList will return a valid result here, if the series wasn't present yet. It will have been changed. + // result.ParserInfos = TryGetSeriesList(library, seriesPaths, normalizedFolder)! + // .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) + // .ToList(); + + _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed", normalizedFolder); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent($"Skipped {normalizedFolder}", library.Name, ProgressEventType.Updated)); + return; + } + + var files = result.Files; + var fileCount = files.Count; + + if (fileCount == 0) + { + _logger.LogInformation("[ScannerService] {Folder} is empty or has no matching file types", normalizedFolder); + result.ParserInfos = ArraySegment.Empty; + return; + } + + _logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, normalizedFolder); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent($"{fileCount} files in {normalizedFolder}", library.Name, ProgressEventType.Updated)); + + // Parse files into ParserInfos + if (fileCount < 100) + { + // Process files sequentially + result.ParserInfos = files + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) + .Where(info => info != null) + .ToList()!; } else { - // There can be a case where there are multiple series in a folder that causes merging. - if (nonLocalizedSeriesFound.Count > 2) - { - _logger.LogError("[ScannerService] There are multiple series within one folder that contain localized series. This will cause them to group incorrectly. Please separate series into their own dedicated folder or ensure there is only 2 potential series (localized and series): {LocalizedSeries}", string.Join(", ", nonLocalizedSeriesFound)); - } - nonLocalizedSeries = nonLocalizedSeriesFound.Find(s => !s.Equals(localizedSeries)); + // Process files in parallel + var tasks = files.Select(file => Task.Run(() => + _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))); + + var infos = await Task.WhenAll(tasks); + result.ParserInfos = infos.Where(info => info != null).ToList()!; } + } - if (nonLocalizedSeries == null) return; - var normalizedNonLocalizedSeries = nonLocalizedSeries.ToNormalized(); - foreach (var infoNeedingMapping in infos.Where(i => - !i.Series.ToNormalized().Equals(normalizedNonLocalizedSeries))) + private static void UpdateSortOrder(ConcurrentDictionary> scannedSeries, ParsedSeries series) + { + // Set the Sort order per Volume + var volumes = scannedSeries[series].GroupBy(info => info.Volumes); + foreach (var volume in volumes) { - infoNeedingMapping.Series = nonLocalizedSeries; - infoNeedingMapping.LocalizedSeries = localizedSeries; + 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; + + // Handle specials with SpecialIndex + if (specialTreatment && hasAnySpMarker) + { + chapters = infos + .OrderBy(info => info.SpecialIndex) + .ToList(); + + foreach (var chapter in chapters) + { + chapter.IssueOrder = counter; + counter++; + } + continue; + } + + // Handle specials without SpecialIndex (natural order) + if (specialTreatment) + { + chapters = infos + .OrderByNatural(info => Parser.Parser.RemoveExtensionIfSupported(info.Filename)!) + .ToList(); + + foreach (var chapter in chapters) + { + chapter.IssueOrder = counter; + counter++; + } + continue; + } + + // Ensure chapters are sorted numerically when possible, otherwise push unparseable to the end + chapters = infos + .OrderBy(info => float.TryParse(info.Chapters, NumberStyles.Any, CultureInfo.InvariantCulture, out var val) ? val : float.MaxValue) + .ToList(); + + counter = 0f; + var prevIssue = string.Empty; + foreach (var chapter in chapters) + { + // Use MinNumber in case there is a range, as otherwise sort order will cause it to be processed last + var chapterNum = + $"{Parser.Parser.MinNumberFromRange(chapter.Chapters).ToString(CultureInfo.InvariantCulture)}"; + if (float.TryParse(chapterNum, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedChapter)) + { + // Parsed successfully, use the numeric value + counter = parsedChapter; + chapter.IssueOrder = counter; + + // Increment for next chapter (unless the next has a similar value, then add 0.1) + if (!string.IsNullOrEmpty(prevIssue) && float.TryParse(prevIssue, NumberStyles.Any, CultureInfo.InvariantCulture, out var prevIssueFloat) && parsedChapter.Is(prevIssueFloat)) + { + counter += 0.1f; // bump if same value as the previous issue + } + prevIssue = $"{parsedChapter.ToString(CultureInfo.InvariantCulture)}"; + } + else + { + // Unparsed chapters: use the current counter and bump for the next + if (!string.IsNullOrEmpty(prevIssue) && prevIssue == counter.ToString(CultureInfo.InvariantCulture)) + { + counter += 0.1f; // bump if same value as the previous issue + } + chapter.IssueOrder = counter; + counter++; + prevIssue = chapter.Chapters; + } + } } } } diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs new file mode 100644 index 000000000..1462ab3d3 --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; +#nullable enable + +/// +/// This is the basic parser for handling Manga/Comic/Book libraries. This was previously DefaultParser before splitting each parser +/// into their own classes. +/// +public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService) +{ + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + { + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. + if (type != LibraryType.Image && Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null; + + if (Parser.IsImage(filePath)) + { + return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo); + } + + var ret = new ParserInfo() + { + Filename = Path.GetFileName(filePath), + Format = Parser.ParseFormat(filePath), + Title = Parser.RemoveExtensionIfSupported(fileName)!, + FullFilePath = Parser.NormalizePath(filePath), + Series = Parser.ParseSeries(fileName, type), + ComicInfo = comicInfo, + Chapters = Parser.ParseChapter(fileName, type), + Volumes = Parser.ParseVolume(fileName, type), + }; + + if (ret.Series == string.Empty || Parser.IsImage(filePath)) + { + // Try to parse information out of each folder all the way to rootPath + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + var edition = Parser.ParseEdition(fileName); + if (!string.IsNullOrEmpty(edition)) + { + ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic); + ret.Edition = edition; + } + + var isSpecial = Parser.IsSpecial(fileName, type); + // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that + // could cause a problem as Omake is a special term, but there is valid volume/chapter information. + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && isSpecial) + { + ret.IsSpecial = true; + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder + } + + // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name + if (Parser.HasSpecialMarker(fileName)) + { + ret.IsSpecial = true; + ret.SpecialIndex = Parser.ParseSpecialIndex(fileName); + ret.Chapters = Parser.DefaultChapter; + ret.Volumes = Parser.SpecialVolume; + + // NOTE: This uses rootPath. LibraryRoot works better for manga, but it's not always that way. + // It might be worth writing some logic if the file is a special, to take the folder above the Specials/ + // if present + var tempRootPath = rootPath; + if (rootPath.EndsWith("Specials") || rootPath.EndsWith("Specials/")) + { + tempRootPath = rootPath.Replace("Specials", string.Empty).TrimEnd('/'); + } + + // Check if the folder the file exists in is Specials/ and if so, take the parent directory as series (cleaned) + var fileDirectory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(fileDirectory) && + (fileDirectory.EndsWith("Specials", StringComparison.OrdinalIgnoreCase) || + fileDirectory.EndsWith("Specials/", StringComparison.OrdinalIgnoreCase))) + { + ret.Series = Parser.CleanTitle(Directory.GetParent(fileDirectory)?.Name ?? string.Empty); + } + else + { + ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); + } + ret.Title = Parser.CleanSpecialTitle(fileName); + } + + if (string.IsNullOrEmpty(ret.Series)) + { + ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); + } + + // Pdfs may have .pdf in the series name, remove that + if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) + { + ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); + } + + // 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) + { + ret.Volumes = Parser.SpecialVolume; + } + + return ret.Series == string.Empty ? null : ret; + } + + /// + /// Applicable for everything but ComicVine and Image library types + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + return type != LibraryType.ComicVine && type != LibraryType.Image; + } +} diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs new file mode 100644 index 000000000..499e554ef --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -0,0 +1,62 @@ +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; + +public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService) +{ + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + { + var info = bookService.ParseInfo(filePath); + if (info == null) return null; + + info.ComicInfo = comicInfo; + + // We need a special piece of code to override the Series IF there is a special marker in the filename for epub files + if (info.IsSpecial && info.Volumes is "0" or "0.0" && info.ComicInfo.Series != info.Series) + { + info.Series = info.ComicInfo.Series; + } + + // This catches when original library type is Manga/Comic and when parsing with non + if (Parser.ParseVolume(info.Series, type) != Parser.LooseLeafVolume) + { + var hasVolumeInTitle = !Parser.ParseVolume(info.Title, type) + .Equals(Parser.LooseLeafVolume); + var hasVolumeInSeries = !Parser.ParseVolume(info.Series, type) + .Equals(Parser.LooseLeafVolume); + + 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, type); + info.Volumes = Parser.ParseVolume(info.Title, type); + } + else + { + var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); + info.Merge(info2); + if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type) + .Equals(Parser.LooseLeafVolume)) + { + // Override the Series name so it groups appropriately + info.Series = info2.Series; + } + } + } + + return string.IsNullOrEmpty(info.Series) ? null : info; + } + + /// + /// Only applicable for Epub files + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + return Parser.IsEpub(filePath); + } +} diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs new file mode 100644 index 000000000..f632bcd59 --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -0,0 +1,134 @@ +using System.IO; +using System.Linq; +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; +#nullable enable + +/// +/// Responsible for Parsing ComicVine Comics. +/// +/// +public class ComicVineParser(IDirectoryService directoryService) : DefaultParser(directoryService) +{ + /// + /// This Parser generates Series name to be defined as Series + first Issue Volume, so "Batman (2020)". + /// + /// + /// + /// + /// + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + { + if (type != LibraryType.ComicVine) return null; + + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + // Mylar often outputs cover.jpg, ignore it by default + if (string.IsNullOrEmpty(fileName) || Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null; + + var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; + + var info = new ParserInfo() + { + Filename = Path.GetFileName(filePath), + Format = Parser.ParseFormat(filePath), + Title = Parser.RemoveExtensionIfSupported(fileName)!, + FullFilePath = Parser.NormalizePath(filePath), + Series = string.Empty, + ComicInfo = comicInfo, + Chapters = Parser.ParseChapter(fileName, type), + Volumes = Parser.ParseVolume(fileName, type) + }; + + // See if we can formulate the name from the ComicInfo + if (!string.IsNullOrEmpty(info.ComicInfo?.Series) && !string.IsNullOrEmpty(info.ComicInfo?.Volume)) + { + info.Series = $"{info.ComicInfo.Series} ({info.ComicInfo.Volume})"; + } + + if (string.IsNullOrEmpty(info.Series)) + { + // Check if we need to fallback to the Folder name AND that the folder matches the format "Series (Year)" + var directories = directoryService.GetFoldersTillRoot(rootPath, filePath).ToList(); + if (directories.Count > 0) + { + foreach (var directory in directories) + { + if (!Parser.IsSeriesAndYear(directory)) continue; + info.Series = directory; + info.Volumes = Parser.ParseYearFromSeries(directory); + break; + } + + // When there was at least one directory and we failed to parse the series, this is the final fallback + if (string.IsNullOrEmpty(info.Series)) + { + info.Series = Parser.CleanTitle(directories[0], true); + } + } + else + { + if (Parser.IsSeriesAndYear(directoryName)) + { + info.Series = directoryName; + info.Volumes = Parser.ParseYearFromSeries(directoryName); + } + } + } + + // Check if this is a Special/Annual + info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type); + + // Patch in other information from ComicInfo + UpdateFromComicInfo(info); + + if (string.IsNullOrEmpty(info.Series)) + { + info.Series = Parser.CleanTitle(directoryName, true); + } + + + return string.IsNullOrEmpty(info.Series) ? null : info; + } + + /// + /// Only applicable for ComicVine library type + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + 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 3c25cc73e..679d6a031 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -1,5 +1,6 @@ using System.IO; using System.Linq; +using API.Data.Metadata; using API.Entities.Enums; namespace API.Services.Tasks.Scanner.Parser; @@ -7,213 +8,26 @@ namespace API.Services.Tasks.Scanner.Parser; public interface IDefaultParser { - ParserInfo? Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga); + ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret); + bool IsApplicable(string filePath, LibraryType type); } /// /// This is an implementation of the Parser that is the basis for everything /// -public class DefaultParser : IDefaultParser +public abstract class DefaultParser(IDirectoryService directoryService) : IDefaultParser { - private readonly IDirectoryService _directoryService; - - public DefaultParser(IDirectoryService directoryService) - { - _directoryService = directoryService; - } /// - /// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed + /// Parses information out of a file path. Can fallback to using directory name if Series couldn't be parsed /// from filename. /// /// /// Root folder - /// Defaults to Manga. Allows different Regex to be used for parsing. + /// Allows different Regex to be used for parsing. /// or null if Series was empty - public ParserInfo? Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga) - { - var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); - - // We can now remove this as there is the ability to turn off images for non-image libraries - // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. - if (type != LibraryType.Image && Parser.IsCoverImage(_directoryService.FileSystem.Path.GetFileName(filePath))) return null; - - var ret = new ParserInfo() - { - Filename = Path.GetFileName(filePath), - Format = Parser.ParseFormat(filePath), - Title = Path.GetFileNameWithoutExtension(fileName), - FullFilePath = filePath, - Series = string.Empty - }; - - // If library type is Image or this is not a cover image in a non-image library, then use dedicated parsing mechanism - if (type == LibraryType.Image || Parser.IsImage(filePath)) - { - // TODO: We can move this up one level (out of DefaultParser - If we do different Parsers) - return ParseImage(filePath, rootPath, ret); - } - - if (type == LibraryType.Magazine) - { - return ParseMagazine(filePath, rootPath, ret); - } - - - // This will be called if the epub is already parsed once then we call and merge the information, if the - if (Parser.IsEpub(filePath)) - { - ret.Chapters = Parser.ParseChapter(fileName); - ret.Series = Parser.ParseSeries(fileName); - ret.Volumes = Parser.ParseVolume(fileName); - } - else - { - ret.Chapters = type == LibraryType.Comic - ? Parser.ParseComicChapter(fileName) - : Parser.ParseChapter(fileName); - ret.Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName); - ret.Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName); - } - - if (ret.Series == string.Empty || Parser.IsImage(filePath)) - { - // Try to parse information out of each folder all the way to rootPath - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); - } - - var edition = Parser.ParseEdition(fileName); - if (!string.IsNullOrEmpty(edition)) - { - ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic); - ret.Edition = edition; - } - - var isSpecial = type == LibraryType.Comic ? Parser.IsComicSpecial(fileName) : Parser.IsMangaSpecial(fileName); - // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that - // could cause a problem as Omake is a special term, but there is valid volume/chapter information. - if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.DefaultVolume && isSpecial) - { - ret.IsSpecial = true; - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder - } - - // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name - if (Parser.HasSpecialMarker(fileName)) - { - ret.IsSpecial = true; - ret.Chapters = Parser.DefaultChapter; - ret.Volumes = Parser.DefaultVolume; - - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); - } - - if (string.IsNullOrEmpty(ret.Series)) - { - ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); - } - - // Pdfs may have .pdf in the series name, remove that - if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) - { - ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); - } - - return ret.Series == string.Empty ? null : ret; - } - - private ParserInfo ParseMagazine(string filePath, string rootPath, ParserInfo ret) - { - // Try to parse Series from the filename - var libraryPath = _directoryService.FileSystem.DirectoryInfo.New(rootPath).Parent?.FullName ?? rootPath; - var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); - ret.Series = Parser.ParseMagazineSeries(fileName); - ret.Volumes = Parser.ParseMagazineVolume(fileName); - ret.Chapters = Parser.ParseMagazineChapter(fileName); - - if (string.IsNullOrEmpty(ret.Series) || (string.IsNullOrEmpty(ret.Chapters) && string.IsNullOrEmpty(ret.Volumes))) - { - // Fallback to the parent folder. We can also likely grab Volume (year) from here - var folders = _directoryService.GetFoldersTillRoot(libraryPath, filePath).ToList(); - // Usually the LAST folder is the Series and everything up to can have Volume - - - if (string.IsNullOrEmpty(ret.Series)) - { - ret.Series = Parser.CleanTitle(folders[^1]); - } - var hasGeoCode = !string.IsNullOrEmpty(Parser.ParseGeoCode(ret.Series)); - foreach (var folder in folders[..^1]) - { - if (ret.Volumes == Parser.DefaultVolume) - { - var vol = Parser.ParseYear(folder); - if (!string.IsNullOrEmpty(vol) && vol != folder) - { - ret.Volumes = vol; - } - } - - // If folder has a language code in it, then we add that to the Series (Wired (UK)) - if (!hasGeoCode) - { - var geoCode = Parser.ParseGeoCode(folder); - if (!string.IsNullOrEmpty(geoCode)) - { - ret.Series = $"{ret.Series} ({geoCode})"; - hasGeoCode = true; - } - } - - } - } - return ret; - } - - private ParserInfo ParseImage(string filePath, string rootPath, ParserInfo ret) - { - ret.Volumes = Parser.DefaultVolume; - ret.Chapters = Parser.DefaultChapter; - var directoryName = _directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; - ret.Series = directoryName; - - ParseFromFallbackFolders(filePath, rootPath, LibraryType.Image, ref ret); - - - if (IsEmptyOrDefault(ret.Volumes, ret.Chapters)) - { - ret.IsSpecial = true; - } - else - { - var parsedVolume = Parser.ParseVolume(ret.Filename); - var parsedChapter = Parser.ParseChapter(ret.Filename); - if (IsEmptyOrDefault(ret.Volumes, string.Empty) && !parsedVolume.Equals(Parser.DefaultVolume)) - { - ret.Volumes = parsedVolume; - } - if (IsEmptyOrDefault(string.Empty, ret.Chapters) && !parsedChapter.Equals(Parser.DefaultChapter)) - { - ret.Chapters = parsedChapter; - } - } - - - // Override the series name, as fallback folders needs it to try and parse folder name - if (string.IsNullOrEmpty(ret.Series) || ret.Series.Equals(directoryName)) - { - ret.Series = Parser.CleanTitle(directoryName, replaceSpecials: false); - } - - return ret; - } - - private static bool IsEmptyOrDefault(string volumes, string chapters) - { - return (string.IsNullOrEmpty(chapters) || chapters == Parser.DefaultChapter) && - (string.IsNullOrEmpty(volumes) || volumes == Parser.DefaultVolume); - } + public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); /// /// Fills out by trying to parse volume, chapters, and series from folders @@ -224,14 +38,14 @@ public class DefaultParser : IDefaultParser /// Expects a non-null ParserInfo which this method will populate public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret) { - var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath) - .Where(f => !Parser.IsMangaSpecial(f)) + var fallbackFolders = directoryService.GetFoldersTillRoot(rootPath, filePath) + .Where(f => !Parser.IsSpecial(f, type)) .ToList(); if (fallbackFolders.Count == 0) { - var rootFolderName = _directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; - var series = Parser.ParseSeries(rootFolderName); + var rootFolderName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; + var series = Parser.ParseSeries(rootFolderName, type); if (string.IsNullOrEmpty(series)) { @@ -250,16 +64,18 @@ public class DefaultParser : IDefaultParser { var folder = fallbackFolders[i]; - var parsedVolume = type is LibraryType.Manga ? Parser.ParseVolume(folder) : Parser.ParseComicVolume(folder); - var parsedChapter = type is LibraryType.Manga ? Parser.ParseChapter(folder) : Parser.ParseComicChapter(folder); + var parsedVolume = Parser.ParseVolume(folder, type); + var parsedChapter = Parser.ParseChapter(folder, type); - if (!parsedVolume.Equals(Parser.DefaultVolume) || !parsedChapter.Equals(Parser.DefaultChapter)) + if (!parsedVolume.Equals(Parser.LooseLeafVolume) || !parsedChapter.Equals(Parser.DefaultChapter)) { - if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Parser.DefaultVolume)) && !string.IsNullOrEmpty(parsedVolume) && !parsedVolume.Equals(Parser.DefaultVolume)) + if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Parser.LooseLeafVolume)) + && !string.IsNullOrEmpty(parsedVolume) && !parsedVolume.Equals(Parser.LooseLeafVolume)) { ret.Volumes = parsedVolume; } - if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) && !string.IsNullOrEmpty(parsedChapter) && !parsedChapter.Equals(Parser.DefaultChapter)) + if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) + && !string.IsNullOrEmpty(parsedChapter) && !parsedChapter.Equals(Parser.DefaultChapter)) { ret.Chapters = parsedChapter; } @@ -268,7 +84,7 @@ public class DefaultParser : IDefaultParser // Generally users group in series folders. Let's try to parse series from the top folder if (!folder.Equals(ret.Series) && i == fallbackFolders.Count - 1) { - var series = Parser.ParseSeries(folder); + var series = Parser.ParseSeries(folder, type); if (string.IsNullOrEmpty(series)) { @@ -284,4 +100,48 @@ public class DefaultParser : IDefaultParser } } } + + protected 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.ComicInfo.Number)) + { + info.Chapters = info.ComicInfo.Number; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Series)) + { + info.Series = info.ComicInfo.Series.Trim(); + } + 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; + } + + // Patch is SeriesSort from ComicInfo + if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort)) + { + info.SeriesSort = info.ComicInfo.SeriesSort.Trim(); + } + + } + + public abstract bool IsApplicable(string filePath, LibraryType type); + + protected static bool IsEmptyOrDefault(string volumes, string chapters) + { + return (string.IsNullOrEmpty(chapters) || chapters == Parser.DefaultChapter) && + (string.IsNullOrEmpty(volumes) || volumes == Parser.LooseLeafVolume); + } } diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs new file mode 100644 index 000000000..415533631 --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -0,0 +1,55 @@ +using System.IO; +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; +#nullable enable + +public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService) +{ + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + { + if (!IsApplicable(filePath, type)) return null; + + var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name; + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + var ret = new ParserInfo + { + Series = directoryName, + Volumes = Parser.LooseLeafVolume, + Chapters = Parser.DefaultChapter, + ComicInfo = comicInfo, + Format = MangaFormat.Image, + Filename = Path.GetFileName(filePath), + FullFilePath = Parser.NormalizePath(filePath), + Title = fileName, + }; + ParseFromFallbackFolders(filePath, libraryRoot, LibraryType.Image, ref ret); + + if (IsEmptyOrDefault(ret.Volumes, ret.Chapters)) + { + ret.IsSpecial = true; + ret.Volumes = Parser.SpecialVolume; + } + + // Override the series name, as fallback folders needs it to try and parse folder name + if (string.IsNullOrEmpty(ret.Series) || ret.Series.Equals(directoryName)) + { + ret.Series = Parser.CleanTitle(directoryName); + } + + + return string.IsNullOrEmpty(ret.Series) ? null : ret; + } + + /// + /// Only applicable for Image files and Image library type + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + return type == LibraryType.Image && Parser.IsImage(filePath); + } +} diff --git a/API/Services/Tasks/Scanner/Parser/MagazineParser.cs b/API/Services/Tasks/Scanner/Parser/MagazineParser.cs new file mode 100644 index 000000000..886ec33c9 --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/MagazineParser.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Linq; +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; + +public class MagazineParser(IDirectoryService directoryService) : DefaultParser(directoryService) +{ + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, + ComicInfo? comicInfo = null) + { + if (!IsApplicable(filePath, type)) return null; + + var ret = new ParserInfo + { + Volumes = Parser.LooseLeafVolume, + Chapters = Parser.DefaultChapter, + ComicInfo = comicInfo, + Format = MangaFormat.Image, + Filename = Path.GetFileName(filePath), + FullFilePath = Parser.NormalizePath(filePath), + Series = string.Empty, + }; + + // Try to parse Series from the filename + var libraryPath = directoryService.FileSystem.DirectoryInfo.New(rootPath).Parent?.FullName ?? rootPath; + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + ret.Series = Parser.ParseMagazineSeries(fileName); + ret.Volumes = Parser.ParseMagazineVolume(fileName); + ret.Chapters = Parser.ParseMagazineChapter(fileName); + + if (string.IsNullOrEmpty(ret.Series) || (string.IsNullOrEmpty(ret.Chapters) && string.IsNullOrEmpty(ret.Volumes))) + { + // Fallback to the parent folder. We can also likely grab Volume (year) from here + var folders = directoryService.GetFoldersTillRoot(libraryPath, filePath).ToList(); + // Usually the LAST folder is the Series and everything up to can have Volume + + + if (string.IsNullOrEmpty(ret.Series)) + { + ret.Series = Parser.CleanTitle(folders[^1]); + } + var hasGeoCode = !string.IsNullOrEmpty(Parser.ParseGeoCode(ret.Series)); + foreach (var folder in folders[..^1]) + { + if (ret.Volumes == Parser.LooseLeafVolume) + { + var vol = Parser.ParseYear(folder); // TODO: This might be better as YearFromSeries + if (!string.IsNullOrEmpty(vol) && vol != folder) + { + ret.Volumes = vol; + } + } + + // If folder has a language code in it, then we add that to the Series (Wired (UK)) + if (!hasGeoCode) + { + var geoCode = Parser.ParseGeoCode(folder); + if (!string.IsNullOrEmpty(geoCode)) + { + ret.Series = $"{ret.Series} ({geoCode})"; + hasGeoCode = true; + } + } + + } + } + + return ret; + } + + /// + /// Only applicable for Image files and Image library type + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + return type == LibraryType.Magazine && Parser.IsPdf(filePath); + } + +} diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index fa1a042e2..18330089c 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -13,11 +13,20 @@ namespace API.Services.Tasks.Scanner.Parser; public static partial class Parser { - public const string DefaultChapter = "0"; - public const string DefaultVolume = "0"; + // NOTE: If you change this, don't forget to change in the UI (see Series Detail) + public const string DefaultChapter = "-100000"; // -2147483648 + public const string LooseLeafVolume = "-100000"; + public const int DefaultChapterNumber = -100_000; + public const int LooseLeafVolumeNumber = -100_000; + /// + /// The Volume Number of Specials to reside in + /// + public const int SpecialVolumeNumber = 100_000; + public const string SpecialVolume = "100000"; + public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500); - public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser + public const string ImageFileExtensions = @"(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt"; public const string EpubFileExtension = @"\.epub"; public const string PdfFileExtension = @"\.pdf"; @@ -36,30 +45,26 @@ public static partial class Parser "One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel", "GN", "FCBD", "Giant Size"); - private static readonly char[] LeadingZeroesTrimChars = new[] { '0' }; + private static readonly char[] LeadingZeroesTrimChars = ['0']; - private static readonly char[] SpacesAndSeparators = { '\0', '\t', '\r', ' ', '-', ','}; + private static readonly char[] SpacesAndSeparators = ['\0', '\t', '\r', ' ', '-', ',']; private const string Number = @"\d+(\.\d)?"; private const string NumberRange = Number + @"(-" + Number + @")?"; /// - /// non greedy matching of a string where parenthesis are balanced + /// non-greedy matching of a string where parenthesis are balanced /// public const string BalancedParen = @"(?:[^()]|(?\()|(?<-open>\)))*?(?(open)(?!))"; /// - /// non greedy matching of a string where square brackets are balanced + /// non-greedy matching of a string where square brackets are balanced /// public const string BalancedBracket = @"(?:[^\[\]]|(?\[)|(?<-open>\]))*?(?(open)(?!))"; /// /// Matches [Complete], release tags like [kmts] but not [ Complete ] or [kmts ] /// private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(? - /// Common regex patterns present in both Comics and Mangas - /// - private const string CommonSpecial = @"Specials?|One[- ]?Shot|Extra(?:\sChapter)?(?=\s)|Art Collection|Side Stories|Bonus"; [GeneratedRegex(@"^\d+$")] private static partial Regex IsNumberRegex(); @@ -68,48 +73,138 @@ public static partial class Parser /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data /// /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face - public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" - + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", + public static readonly Regex FontSrcUrlRegex = new(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" + + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", MatchOptions, RegexTimeout); /// /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import /// - public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", + public static readonly Regex CssImportUrlRegex = new("(@import\\s([\"|']|url\\([\"|']))(?[^'\"]+)([\"|']\\)?);", MatchOptions | RegexOptions.Multiline, RegexTimeout); /// /// Misc css image references, like background-image: url(), border-image, or list-style-image /// /// Original prepend: (background|border|list-style)-image:\s?)? - public static readonly Regex CssImageUrlRegex = new Regex(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", + public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", MatchOptions, RegexTimeout); - private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, + private static readonly Regex ImageRegex = new(ImageFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, + private static readonly Regex ArchiveFileRegex = new(ArchiveFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", + private static readonly Regex ComicInfoArchiveRegex = new(@"\.cbz|\.cbr|\.cb7|\.cbt", MatchOptions, RegexTimeout); - private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, + private static readonly Regex XmlRegex = new(XmlRegexExtensions, MatchOptions, RegexTimeout); - private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, + private static readonly Regex BookFileRegex = new(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? + /// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be + /// added on a case-by-case basis. + /// + private static readonly Regex NormalizeRegex = new(@"[^\p{L}0-9\+!*!+]", + MatchOptions, RegexTimeout); + + /// + /// Supports Batman (2020) or Batman (2) + /// + private static readonly Regex SeriesAndYearRegex = new(@"^\D+\s\((?\d+)\)$", MatchOptions, RegexTimeout); /// /// Recognizes the Special token only /// - private static readonly Regex SpecialTokenRegex = new Regex(@"SP\d+", + private static readonly Regex SpecialTokenRegex = new(@"SP\d+", MatchOptions, RegexTimeout); - #region Manga - private static readonly Regex[] MangaSeriesRegex = new[] - { + private static readonly Regex[] MangaVolumeRegex = + [ + // 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+)( |_)", + MatchOptions, RegexTimeout), + // Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake + new Regex( + @"^(?.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+(Vol(ume)?\.?(\s|_)?)(?\d+(\.\d+)?)(.+?|$)", + MatchOptions, RegexTimeout), + // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 + new Regex( + @"(?.*)(\b|_)(?!\[)v(?" + NumberRange + @")(?!\])", + MatchOptions, RegexTimeout), + // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 + new Regex( + @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", + MatchOptions, RegexTimeout), + // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) + new Regex( + @"(vol\.? ?)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Tonikaku Cawaii [Volume 11].cbz + new Regex( + @"(volume )(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Tower Of God S01 014 (CBT) (digital).cbz + new Regex( + @"(?.*)(\b|_|)(S(?\d+))", + MatchOptions, RegexTimeout), + // vol_001-1.cbz for MangaPy default naming convention + new Regex( + @"(vol_)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + + // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 + new Regex( + @"第(?\d+)(卷|册)", + MatchOptions, RegexTimeout), + // Chinese Volume: 卷n -> Volume n, 册n -> Volume n + new Regex( + @"(卷|册)(?\d+)", + MatchOptions, RegexTimeout), + // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + new Regex( + @"제?(?\d+(\.\d+)?)(권|회|화|장)", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, + new Regex( + @"시즌(?\d+\-?\d+)", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"(?\d+(\-|~)?\d+?)시즌", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"시즌(?\d+(\-|~)?\d+?)", + MatchOptions, RegexTimeout), + // Japanese Volume: n巻 -> Volume n + new Regex( + @"(?\d+(?:(\-)\d+)?)巻", + MatchOptions, RegexTimeout), + // Russian Volume: Том n -> Volume n, Тома n -> Volume + new Regex( + @"Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", + MatchOptions, RegexTimeout), + // Russian Volume: n Том -> Volume n + new Regex( + @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", + MatchOptions, RegexTimeout) + ]; + + private static readonly Regex[] MangaSeriesRegex = + [ + // 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+)?)", @@ -139,7 +234,7 @@ public static partial class Parser // [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz, // Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake new Regex( - @"^(?.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+Vol(ume)?\.?(\d+|tbd|\s\d).+?", + @"^(?.+?)(?:\s*|_|\-\s*)+(?:Ch(?:apter|\.|)\s*\d+(?:\.\d+)?(?:\s*|_|\-\s*)+)?Vol(?:ume|\.|)\s*(?:\d+|tbd)(?:\s|_|\-\s*).+", MatchOptions, RegexTimeout), // Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip new Regex( @@ -148,7 +243,7 @@ public static partial 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( @@ -175,7 +270,7 @@ public static partial class Parser RegexTimeout), //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] new Regex( - @"(?.*)(\bc\d+\b)", + @"(?.*?)(? Volume n new Regex( @"(?.+?)第(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), + MatchOptions, RegexTimeout) - }; - private static readonly Regex[] MangaVolumeRegex = new[] - { - // Dance in the Vampire Bund v16-17 - new Regex( - @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", - MatchOptions, RegexTimeout), - // Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake - new Regex( - @"^(?.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+(Vol(ume)?\.?(\s|_)?)(?\d+(\.\d+)?)(.+?|$)", - MatchOptions, RegexTimeout), - // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 - new Regex( - @"(?.*)(\b|_)(?!\[)v(?" + NumberRange + @")(?!\])", - MatchOptions, RegexTimeout), - // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 - new Regex( - @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", - MatchOptions, RegexTimeout), - // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) - new Regex( - @"(vol\.? ?)(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Tonikaku Cawaii [Volume 11].cbz - new Regex( - @"(volume )(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Tower Of God S01 014 (CBT) (digital).cbz - new Regex( - @"(?.*)(\b|_|)(S(?\d+))", - MatchOptions, RegexTimeout), - // vol_001-1.cbz for MangaPy default naming convention - new Regex( - @"(vol_)(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), + ]; - // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 + private static readonly Regex[] ComicSeriesRegex = + [ + // Thai Volume: เล่ม n -> Volume n new Regex( - @"第(?\d+)(卷|册)", + @"(?.+?)(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", MatchOptions, RegexTimeout), - // Chinese Volume: 卷n -> Volume n, 册n -> Volume n - new Regex( - @"(卷|册)(?\d+)", - MatchOptions, RegexTimeout), - // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) - new Regex( - @"제?(?\d+(\.\d)?)(권|회|화|장)", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, - new Regex( - @"시즌(?\d+\-?\d+)", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, n시즌 -> season n - new Regex( - @"(?\d+(\-|~)?\d+?)시즌", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, n시즌 -> season n - new Regex( - @"시즌(?\d+(\-|~)?\d+?)", - MatchOptions, RegexTimeout), - // Japanese Volume: n巻 -> Volume n - new Regex( - @"(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), - // Russian Volume: Том n -> Volume n, Тома n -> Volume - new Regex( - @"Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", - MatchOptions, RegexTimeout), - // Russian Volume: n Том -> Volume n - new Regex( - @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; - private static readonly Regex[] MangaChapterRegex = new[] - { - // Historys Strongest Disciple Kenichi_v11_c90-98.zip, ...c90.5-100.5 - new Regex( - @"(\b|_)(c|ch)(\.?\s?)(?(\d+(\.\d)?)(-c?\d+(\.\d)?)?)", - MatchOptions, RegexTimeout), - // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip - new Regex( - @"v\d+\.(\s|_)(?\d+(?:.\d+|-\d+)?)", - MatchOptions, RegexTimeout), - // Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz (Rare case, if causes issue remove) - new Regex( - @"^(?.*)(?: |_)#(?\d+)", - MatchOptions, RegexTimeout), - // Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10 - new Regex( - @"^(?!Vol)(?.*)\s?(?\d+(?:\.?[\d-]+)?)", - MatchOptions, RegexTimeout), - // Russian Chapter: Главы n -> Chapter n - new Regex( - @"(Глава|глава|Главы|Глава)(\.?)(\s|_)?(?\d+(?:.\d+|-\d+)?)", - MatchOptions, RegexTimeout), - - // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz - new Regex( - @"^(?.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", - MatchOptions, RegexTimeout), - // Tower Of God S01 014 (CBT) (digital).cbz - new Regex( - @"(?.*)\sS(?\d+)\s(?\d+(?:.\d+|-\d+)?)", - MatchOptions, RegexTimeout), - // Beelzebub_01_[Noodles].zip, Beelzebub_153b_RHS.zip - new Regex( - @"^((?!v|vo|vol|Volume).)*(\s|_)(?\.?\d+(?:.\d+|-\d+)?)(?b)?(\s|_|\[|\()", - MatchOptions, RegexTimeout), - // Yumekui-Merry_DKThias_Chapter21.zip - new Regex( - @"Chapter(?\d+(-\d+)?)", //(?:.\d+|-\d+)? - MatchOptions, RegexTimeout), - // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar - new Regex( - @"(?.*)(\s|_)(vol\d+)?(\s|_)Chp\.? ?(?\d+)", - MatchOptions, RegexTimeout), - // Vol 1 Chapter 2 - new Regex( - @"(?((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?\d+)", - MatchOptions, RegexTimeout), - // Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话 - new Regex( - @"第(?\d+)话", - MatchOptions, RegexTimeout), - // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 - new Regex( - @"제?(?\d+\.?\d+)(회|화|장)", - MatchOptions, RegexTimeout), - // Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話 - new Regex( - @"第?(?\d+(?:\.\d+|-\d+)?)話", - MatchOptions, RegexTimeout), - // Russian Chapter: n Главa -> Chapter n - new Regex( - @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", - MatchOptions, RegexTimeout), - }; - private static readonly Regex MangaEditionRegex = new Regex( - // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz - // To Love Ru v01 Uncensored (Ch.001-007) - @"\b(?:Omnibus(?:\s?Edition)?|Uncensored)\b", - MatchOptions, RegexTimeout - ); - - private static readonly Regex MangaSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|Omake)\b", - MatchOptions, RegexTimeout - ); - - #endregion - - #region Comic - private static readonly Regex[] ComicSeriesRegex = new[] - { // Russian Volume: Том n -> Volume n, Тома n -> Volume new Regex( @"(?.+?)Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", @@ -518,11 +466,15 @@ public static partial class Parser // MUST BE LAST: Batman & Daredevil - King of New York new Regex( @"^(?.*)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicVolumeRegex = new[] - { + private static readonly Regex[] ComicVolumeRegex = + [ + // 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 + @")", @@ -554,11 +506,15 @@ public static partial class Parser // Russian Volume: n Том -> Volume n new Regex( @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout), - }; + MatchOptions, RegexTimeout) + ]; - private static readonly Regex[] ComicChapterRegex = new[] - { + private static readonly Regex[] ComicChapterRegex = + [ + // 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+)", @@ -619,22 +575,101 @@ public static partial class Parser // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) new Regex( @"^(?.+?)-(chapter-)?(?\d+)", + MatchOptions, RegexTimeout) + ]; + + private static readonly Regex[] MangaChapterRegex = + [ + // 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)?)?)", + MatchOptions, RegexTimeout), + // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + new Regex( + @"v\d+\.(\s|_)(?\d+(?:.\d+|-\d+)?)", + MatchOptions, RegexTimeout), + // Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz (Rare case, if causes issue remove) + new Regex( + @"^(?.*)(?: |_)#(?\d+)", + MatchOptions, RegexTimeout), + // Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10 + new Regex( + @"^(?!Vol)(?.*)\s?(?\d+(?:\.?[\d-]+)?)", + MatchOptions, RegexTimeout), + // Russian Chapter: Главы n -> Chapter n + new Regex( + @"(Глава|глава|Главы|Глава)(\.?)(\s|_)?(?\d+(?:.\d+|-\d+)?)", MatchOptions, RegexTimeout), - }; - private static readonly Regex ComicSpecialRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - $@"\b(?:{CommonSpecial}|\d.+?(\W|-|^)Annual|Annual(\W|-|$)|Book \d.+?|Compendium(\W|-|$|\s.+?)|Omnibus(\W|-|$|\s.+?)|FCBD \d.+?|Absolute(\W|-|$|\s.+?)|Preview(\W|-|$|\s.+?)|Hors[ -]S[ée]rie|TPB|HS|THS)\b", + // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz + new Regex( + @"^(?.+?)(?\d+(?:\.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", + MatchOptions, RegexTimeout), + // Tower Of God S01 014 (CBT) (digital).cbz + new Regex( + @"(?.*)\sS(?\d+)\s(?\d+(?:.\d+|-\d+)?)", + MatchOptions, RegexTimeout), + // Beelzebub_01_[Noodles].zip, Beelzebub_153b_RHS.zip + new Regex( + @"^((?!v|vo|vol|Volume).)*(\s|_)(?\.?\d+(?:.\d+|-\d+)?)(?b)?(\s|_|\[|\()", + MatchOptions, RegexTimeout), + // Yumekui-Merry_DKThias_Chapter21.zip + new Regex( + @"Chapter(?\d+(-\d+)?)", //(?:.\d+|-\d+)? + MatchOptions, RegexTimeout), + // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar + new Regex( + @"(?.*)(\s|_)(vol\d+)?(\s|_)Chp\.? ?(?\d+)", + MatchOptions, RegexTimeout), + // Vol 1 Chapter 2 + new Regex( + @"(?((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?\d+)", + MatchOptions, RegexTimeout), + // Chinese Chapter: 第n话 -> Chapter n, 【TFO汉化&Petit汉化】迷你偶像漫画第25话 + new Regex( + @"第(?\d+)话", + MatchOptions, RegexTimeout), + // Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44 + new Regex( + @"제?(?\d+\.?\d+)(회|화|장)", + MatchOptions, RegexTimeout), + // Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル ~高校生のSMごっこ~ 第1話 + new Regex( + @"第?(?\d+(?:\.\d+|-\d+)?)話", + MatchOptions, RegexTimeout), + // Russian Chapter: n Главa -> Chapter n + new Regex( + @"(?!Том)(?\d+(?:\.\d+|-\d+)?)(\s|_)(Глава|глава|Главы|Глава)", + MatchOptions, RegexTimeout) + ]; + + private static readonly Regex MangaEditionRegex = new Regex( + // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz + // To Love Ru v01 Uncensored (Ch.001-007) + @"\b(?:Omnibus(?:\s?Edition)?|Uncensored)\b", MatchOptions, RegexTimeout ); - private static readonly Regex EuropeanComicRegex = new Regex( - // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. - @"\b(?:Bd[-\s]Fr)\b", + // Matches anything between balanced parenthesis, tags between brackets, {} and {Complete} + private static readonly Regex CleanupRegex = new Regex( + $@"(?:\({BalancedParen}\)|{TagsInBrackets}|\{{\}}|\{{Complete\}})", MatchOptions, RegexTimeout ); - #endregion + // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. + private static readonly Regex SpecialMarkerRegex = new Regex( + @"SP\d+", + MatchOptions, RegexTimeout + ); + + private static readonly Regex EmptySpaceRegex = new Regex( + @"\s{2,}", + MatchOptions, RegexTimeout + ); #region Magazine @@ -692,7 +727,7 @@ public static partial class Parser MatchOptions, RegexTimeout), }; - private static readonly Regex YearRegex = new Regex( + private static readonly Regex YearRegex = new( @"(\b|\s|_)[1-9]{1}\d{3}(\b|\s|_)", MatchOptions, RegexTimeout ); @@ -700,24 +735,6 @@ public static partial class Parser #endregion - // Matches anything between balanced parenthesis, tags between brackets, {} and {Complete} - private static readonly Regex CleanupRegex = new Regex( - $@"(?:\({BalancedParen}\)|{TagsInBrackets}|\{{\}}|\{{Complete\}})", - MatchOptions, RegexTimeout - ); - - // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. - private static readonly Regex SpecialMarkerRegex = new Regex( - @"SP\d+", - MatchOptions, RegexTimeout - ); - - private static readonly Regex EmptySpaceRegex = new Regex( - @"\s{2,}", - MatchOptions, RegexTimeout - ); - - public static MangaFormat ParseFormat(string filePath) { @@ -740,24 +757,25 @@ public static partial class Parser /// /// /// - public static bool HasSpecialMarker(string filePath) + public static bool HasSpecialMarker(string? filePath) { + if (string.IsNullOrEmpty(filePath)) return false; return SpecialMarkerRegex.IsMatch(filePath); } - public static bool IsMangaSpecial(string filePath) + public static int ParseSpecialIndex(string filePath) { - filePath = ReplaceUnderscores(filePath); - return MangaSpecialRegex.IsMatch(filePath); + var match = SpecialMarkerRegex.Match(filePath).Value.Replace("SP", string.Empty); + if (string.IsNullOrEmpty(match)) return 0; + return int.Parse(match); } - public static bool IsComicSpecial(string filePath) + public static bool IsSpecial(string? filePath, LibraryType type) { - filePath = ReplaceUnderscores(filePath); - return ComicSpecialRegex.IsMatch(filePath); + return HasSpecialMarker(filePath); } - public static string ParseSeries(string filename) + private static string ParseMangaSeries(string filename) { foreach (var regex in MangaSeriesRegex) { @@ -765,7 +783,11 @@ public static partial class Parser var group = matches .Select(match => match.Groups["Series"]) .FirstOrDefault(group => group.Success && group != Match.Empty); - if (group != null) return CleanTitle(group.Value); + + if (group != null) + { + return CleanTitle(group.Value); + } } return string.Empty; @@ -798,7 +820,7 @@ public static partial class Parser return string.Empty; } - public static string ParseVolume(string filename) + public static string ParseMangaVolume(string filename) { foreach (var regex in MangaVolumeRegex) { @@ -813,7 +835,7 @@ public static partial class Parser } } - return DefaultVolume; + return LooseLeafVolume; } public static string ParseComicVolume(string filename) @@ -831,9 +853,10 @@ public static partial class Parser } } - return DefaultVolume; + return LooseLeafVolume; } + public static string ParseMagazineVolume(string filename) { foreach (var regex in MagazineVolumeRegex) @@ -848,7 +871,7 @@ public static partial class Parser } } - return DefaultVolume; + return LooseLeafVolume; } private static string[] CreateCountryCodes() @@ -934,11 +957,6 @@ public static partial class Parser return null; } - public static string? ParseYear(string? value) - { - if (string.IsNullOrEmpty(value)) return value; - return YearRegex.Match(value).Value; - } private static string FormatValue(string value, bool hasPart) { @@ -949,6 +967,7 @@ public static partial class Parser var tokens = value.Split("-"); var from = RemoveLeadingZeroes(tokens[0]); + if (tokens.Length != 2) return from; // Occasionally users will use c01-c02 instead of c01-02, clean any leftover c @@ -960,7 +979,49 @@ public static partial class Parser return $"{from}-{to}"; } - public static string ParseChapter(string filename) + public static string ParseSeries(string filename, LibraryType type) + { + return type switch + { + LibraryType.Manga => ParseMangaSeries(filename), + LibraryType.Comic => ParseComicSeries(filename), + LibraryType.Book => ParseMangaSeries(filename), + LibraryType.Image => ParseMangaSeries(filename), + LibraryType.LightNovel => ParseMangaSeries(filename), + LibraryType.ComicVine => ParseComicSeries(filename), + _ => string.Empty + }; + } + + public static string ParseVolume(string filename, LibraryType type) + { + return type switch + { + LibraryType.Manga => ParseMangaVolume(filename), + LibraryType.Comic => ParseComicVolume(filename), + LibraryType.Book => ParseMangaVolume(filename), + LibraryType.Image => ParseMangaVolume(filename), + LibraryType.LightNovel => ParseMangaVolume(filename), + LibraryType.ComicVine => ParseComicVolume(filename), + _ => LooseLeafVolume + }; + } + + public static string ParseChapter(string filename, LibraryType type) + { + return type switch + { + LibraryType.Manga => ParseMangaChapter(filename), + LibraryType.Comic => ParseComicChapter(filename), + LibraryType.Book => ParseMangaChapter(filename), + LibraryType.Image => ParseMangaChapter(filename), + LibraryType.LightNovel => ParseMangaChapter(filename), + LibraryType.ComicVine => ParseComicChapter(filename), + _ => DefaultChapter + }; + } + + private static string ParseMangaChapter(string filename) { foreach (var regex in MangaChapterRegex) { @@ -989,7 +1050,7 @@ public static partial class Parser return $"{value}.5"; } - public static string ParseComicChapter(string filename) + private static string ParseComicChapter(string filename) { foreach (var regex in ComicChapterRegex) { @@ -1016,22 +1077,6 @@ public static partial class Parser return title; } - private static string RemoveMangaSpecialTags(string title) - { - return MangaSpecialRegex.Replace(title, string.Empty); - } - - private static string RemoveEuropeanTags(string title) - { - return EuropeanComicRegex.Replace(title, string.Empty); - } - - private static string RemoveComicSpecialTags(string title) - { - return ComicSpecialRegex.Replace(title, string.Empty); - } - - /// /// Translates _ -> spaces, trims front and back of string, removes release groups @@ -1043,27 +1088,13 @@ public static partial class Parser /// /// - public static string CleanTitle(string title, bool isComic = false, bool replaceSpecials = true) + public static string CleanTitle(string title, bool isComic = false) { title = ReplaceUnderscores(title); title = RemoveEditionTagHolders(title); - if (replaceSpecials) - { - if (isComic) - { - title = RemoveComicSpecialTags(title); - title = RemoveEuropeanTags(title); - } - else - { - title = RemoveMangaSpecialTags(title); - } - } - - title = title.Trim(SpacesAndSeparators); title = EmptySpaceRegex.Replace(title, " "); @@ -1131,35 +1162,52 @@ public static partial class Parser { try { - if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout)) + // Check if the range string is not null or empty + if (string.IsNullOrEmpty(range) || !Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout)) { - return (float) 0.0; + return 0.0f; } - var tokens = range.Replace("_", string.Empty).Split("-"); - return tokens.Min(t => t.AsFloat()); + // Check if there is a range or not + if (NumberRangeRegex().IsMatch(range)) + { + + var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries); + return tokens.Min(t => t.AsFloat()); + } + + return range.AsFloat(); } - catch + catch (Exception) { - return (float) 0.0; + return 0.0f; } } + public static float MaxNumberFromRange(string range) { try { - if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout)) + // Check if the range string is not null or empty + if (string.IsNullOrEmpty(range) || !Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout)) { - return (float) 0.0; + return 0.0f; } - var tokens = range.Replace("_", string.Empty).Split("-"); - return tokens.Max(t => t.AsFloat()); + // Check if there is a range or not + if (NumberRangeRegex().IsMatch(range)) + { + + var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries); + return tokens.Max(t => t.AsFloat()); + } + + return range.AsFloat(); } - catch + catch (Exception) { - return (float) 0.0; + return 0.0f; } } @@ -1177,11 +1225,6 @@ public static partial class Parser { if (string.IsNullOrEmpty(name)) return name; var cleaned = SpecialTokenRegex.Replace(name.Replace('_', ' '), string.Empty).Trim(); - var lastIndex = cleaned.LastIndexOf('.'); - if (lastIndex > 0) - { - cleaned = cleaned.Substring(0, cleaned.LastIndexOf('.')).Trim(); - } return string.IsNullOrEmpty(cleaned) ? name : cleaned; } @@ -1199,7 +1242,7 @@ public static partial class Parser } /// - /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc and that if a full path, the filename + /// Validates that a Path doesn't start with certain blacklisted folders, like __MACOSX, @Recently-Snapshot, etc. and that if a full path, the filename /// doesn't start with ._, which is a metadata file on MACOSX. /// /// @@ -1209,6 +1252,7 @@ public static partial class Parser return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg") || path.StartsWith("#recycle") + || path.Contains(".yacreaderlibrary") || path.Contains(".caltrash"); } @@ -1281,10 +1325,52 @@ public static partial class Parser // NOTE: This is failing for //localhost:5000/api/book/29919/book-resources?file=OPS/images/tick1.jpg var importFile = match.Groups["Filename"].Value; - if (!importFile.Contains("?")) return importFile; + if (!importFile.Contains('?')) return importFile; } return null; } + /// + /// If the name matches exactly Series (Volume digits) + /// + /// + /// + public static bool IsSeriesAndYear(string? name) + { + return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name); + } + + /// + /// Extracts year from Series (Year) + /// + /// + /// + public static string ParseYearFromSeries(string? name) + { + if (string.IsNullOrEmpty(name)) return string.Empty; + var match = SeriesAndYearRegex.Match(name); + return !match.Success ? string.Empty : match.Groups["Year"].Value; + } + + public static string ParseYear(string? value) + { + return string.IsNullOrEmpty(value) ? string.Empty : YearRegex.Match(value).Value; + } + + public static string? RemoveExtensionIfSupported(string? filename) + { + if (string.IsNullOrEmpty(filename)) return filename; + + if (SupportedExtensionsRegex().IsMatch(filename)) + { + return SupportedExtensionsRegex().Replace(filename, string.Empty); + } + return filename; + } + + [GeneratedRegex(SupportedExtensions)] + private static partial Regex SupportedExtensionsRegex(); + [GeneratedRegex(@"\d-{1}\d")] + private static partial Regex NumberRangeRegex(); } diff --git a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs index 8cd81cf6d..2a1540234 100644 --- a/API/Services/Tasks/Scanner/Parser/ParserInfo.cs +++ b/API/Services/Tasks/Scanner/Parser/ParserInfo.cs @@ -60,6 +60,10 @@ public class ParserInfo /// If the file contains no volume/chapter information or contains Special Keywords /// public bool IsSpecial { get; set; } + /// + /// If the file has a Special Marker explicitly, this will contain the index + /// + public int SpecialIndex { get; set; } = 0; /// /// Used for specials or books, stores what the UI should show. @@ -67,13 +71,19 @@ public class ParserInfo /// public string Title { get; set; } = string.Empty; + /// + /// This can be filled in from ComicInfo.xml during scanning. Will update the SortOrder field on . + /// Falls back to Parsed Chapter number + /// + public float IssueOrder { get; set; } + /// /// If the ParserInfo has the IsSpecial tag or both volumes and chapters are default aka 0 /// /// public bool IsSpecialInfo() { - return (IsSpecial || (Volumes == Parser.DefaultVolume && Chapters == Parser.DefaultChapter)); + return (IsSpecial || (Volumes == Parser.LooseLeafVolume && Chapters == Parser.DefaultChapter)); } /// @@ -91,7 +101,7 @@ public class ParserInfo { if (info2 == null) return; Chapters = string.IsNullOrEmpty(Chapters) || Chapters == Parser.DefaultChapter ? info2.Chapters: Chapters; - Volumes = string.IsNullOrEmpty(Volumes) || Volumes == Parser.DefaultVolume ? info2.Volumes : Volumes; + Volumes = string.IsNullOrEmpty(Volumes) || Volumes == Parser.LooseLeafVolume ? info2.Volumes : Volumes; Edition = string.IsNullOrEmpty(Edition) ? info2.Edition : Edition; Title = string.IsNullOrEmpty(Title) ? info2.Title : Title; Series = string.IsNullOrEmpty(Series) ? info2.Series : Series; diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs new file mode 100644 index 000000000..bc12e2c77 --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -0,0 +1,130 @@ +using System.IO; +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; + +public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService) +{ + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + { + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + var ret = new ParserInfo + { + Filename = Path.GetFileName(filePath), + Format = Parser.ParseFormat(filePath), + Title = Parser.RemoveExtensionIfSupported(fileName)!, + FullFilePath = Parser.NormalizePath(filePath), + Series = string.Empty, + ComicInfo = comicInfo, + Chapters = Parser.ParseChapter(fileName, type) + }; + + if (type == LibraryType.Book) + { + ret.Chapters = Parser.DefaultChapter; + } + + ret.Series = Parser.ParseSeries(fileName, type); + ret.Volumes = Parser.ParseVolume(fileName, type); + + if (ret.Series == string.Empty) + { + // Try to parse information out of each folder all the way to rootPath + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + var edition = Parser.ParseEdition(fileName); + if (!string.IsNullOrEmpty(edition)) + { + ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic); + ret.Edition = edition; + } + + var isSpecial = Parser.IsSpecial(fileName, type); + // We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that + // could cause a problem as Omake is a special term, but there is valid volume/chapter information. + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && isSpecial) + { + ret.IsSpecial = true; + // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + // If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name + if (Parser.HasSpecialMarker(fileName)) + { + ret.IsSpecial = true; + ret.SpecialIndex = Parser.ParseSpecialIndex(fileName); + ret.Chapters = Parser.DefaultChapter; + ret.Volumes = Parser.SpecialVolume; + + var tempRootPath = rootPath; + if (rootPath.EndsWith("Specials") || rootPath.EndsWith("Specials/")) + { + tempRootPath = rootPath.Replace("Specials", string.Empty).TrimEnd('/'); + } + + ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); + } + + // Patch in other information from ComicInfo + UpdateFromComicInfo(ret); + + if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Title = comicInfo.Title.Trim(); + } + + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) + { + ret.IsSpecial = true; + ret.Chapters = Parser.DefaultChapter; + ret.Volumes = Parser.SpecialVolume; + ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + } + + if (type == LibraryType.Book && comicInfo != null) + { + // For books, fall back to the Title for Series. + if (!string.IsNullOrEmpty(comicInfo.Series)) + { + ret.Series = comicInfo.Series.Trim(); + } + else if (!string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Series = comicInfo.Title.Trim(); + } + } + + if (string.IsNullOrEmpty(ret.Series)) + { + ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic); + } + + // Pdfs may have .pdf in the series name, remove that + if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) + { + ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); + } + + // v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number + if (ret.IsSpecial) + { + ret.Volumes = $"{Parser.SpecialVolumeNumber}"; + } + + return string.IsNullOrEmpty(ret.Series) ? null : ret; + } + + /// + /// Only applicable for PDF files + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + return Parser.IsPdf(filePath); + } +} diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 6500c88d8..59721fe61 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -10,6 +10,8 @@ using API.Data.Metadata; using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Entities.Person; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -19,6 +21,7 @@ using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; using Kavita.Common; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks.Scanner; @@ -26,20 +29,7 @@ namespace API.Services.Tasks.Scanner; public interface IProcessSeries { - /// - /// Do not allow this Prime to be invoked by multiple threads. It will break the DB. - /// - /// - Task Prime(); - Task ProcessSeriesAsync(IList parsedInfos, Library library, bool forceUpdate = false); - void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false); - - // These exists only for Unit testing - void UpdateSeriesMetadata(Series series, Library library); - void UpdateVolumes(Series series, IList parsedInfos, bool forceUpdate = false); - void UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false); - void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false); - void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false); + Task ProcessSeriesAsync(IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false); } /// @@ -56,19 +46,15 @@ public class ProcessSeries : IProcessSeries private readonly IFileService _fileService; private readonly IMetadataService _metadataService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; - private readonly ICollectionTagService _collectionTagService; private readonly IReadingListService _readingListService; private readonly IExternalMetadataService _externalMetadataService; - private Dictionary _genres; - private IList _people; - private Dictionary _tags; - private Dictionary _collectionTags; public ProcessSeries(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, IDirectoryService directoryService, ICacheHelper cacheHelper, IReadingItemService readingItemService, IFileService fileService, IMetadataService metadataService, IWordCountAnalyzerService wordCountAnalyzerService, - ICollectionTagService collectionTagService, IReadingListService readingListService, IExternalMetadataService externalMetadataService) + IReadingListService readingListService, + IExternalMetadataService externalMetadataService) { _unitOfWork = unitOfWork; _logger = logger; @@ -79,31 +65,12 @@ public class ProcessSeries : IProcessSeries _fileService = fileService; _metadataService = metadataService; _wordCountAnalyzerService = wordCountAnalyzerService; - _collectionTagService = collectionTagService; _readingListService = readingListService; _externalMetadataService = externalMetadataService; - - - _genres = new Dictionary(); - _people = new List(); - _tags = new Dictionary(); - _collectionTags = new Dictionary(); } - /// - /// Invoke this before processing any series, just once to prime all the needed data during a scan - /// - public async Task Prime() - { - _genres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToDictionary(t => t.NormalizedTitle); - _people = await _unitOfWork.PersonRepository.GetAllPeople(); - _tags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToDictionary(t => t.NormalizedTitle); - _collectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync(CollectionTagIncludes.SeriesMetadata)) - .ToDictionary(t => t.NormalizedTitle); - } - - public async Task ProcessSeriesAsync(IList parsedInfos, Library library, bool forceUpdate = false) + public async Task ProcessSeriesAsync(IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false) { if (!parsedInfos.Any()) return; @@ -111,43 +78,23 @@ public class ProcessSeries : IProcessSeries var scanWatch = Stopwatch.StartNew(); var seriesName = parsedInfos[0].Series; await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Updated, seriesName)); - _logger.LogInformation("[ScannerService] Beginning series update on {SeriesName}", seriesName); + MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Updated, seriesName, totalToProcess)); + _logger.LogInformation("[ScannerService] Beginning series update on {SeriesName}, Forced: {ForceUpdate}", seriesName, forceUpdate); // Check if there is a Series var firstInfo = parsedInfos[0]; Series? series; try { + // There is an opportunity to allow duplicate series here. Like if One is in root/marvel/batman and another is root/dc/batman + // by changing to a ToList() and if multiple, doing a firstInfo.FirstFolder/RootFolder type check series = await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(firstInfo.Series, firstInfo.LocalizedSeries, library.Id, firstInfo.Format); } catch (Exception ex) { - var seriesCollisions = await _unitOfWork.SeriesRepository.GetAllSeriesByAnyName(firstInfo.LocalizedSeries, string.Empty, library.Id, firstInfo.Format); - - seriesCollisions = seriesCollisions.Where(collision => - collision.Name != firstInfo.Series || collision.LocalizedName != firstInfo.LocalizedSeries).ToList(); - - if (seriesCollisions.Count > 1) - { - var firstCollision = seriesCollisions[0]; - var secondCollision = seriesCollisions[1]; - - var tableRows = $"Name: {firstCollision.Name}Name: {secondCollision.Name}" + - $"Localized: {firstCollision.LocalizedName}Localized: {secondCollision.LocalizedName}" + - $"Filename: {Parser.Parser.NormalizePath(firstCollision.FolderPath)}Filename: {Parser.Parser.NormalizePath(secondCollision.FolderPath)}"; - - var htmlTable = $"{string.Join(string.Empty, tableRows)}
Series 1Series 2
"; - - _logger.LogError(ex, "Scanner found a Series {SeriesName} which matched another Series {LocalizedName} in a different folder parallel to Library {LibraryName} root folder. This is not allowed. Please correct", - firstInfo.Series, firstInfo.LocalizedSeries, library.Name); - - await _eventHub.SendMessageAsync(MessageFactory.Error, - MessageFactory.ErrorEvent($"Library {library.Name} Series collision on {firstInfo.Series}", - htmlTable)); - } + await ReportDuplicateSeriesLookup(library, firstInfo, ex); return; } @@ -164,12 +111,12 @@ public class ProcessSeries : IProcessSeries try { - _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); + _logger.LogInformation("[ScannerService] Processing series {SeriesName} with {Count} files", series.OriginalName, parsedInfos.Count); // parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort) var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo); - UpdateVolumes(series, parsedInfos, forceUpdate); + await UpdateVolumes(series, parsedInfos, forceUpdate); series.Pages = series.Volumes.Sum(v => v.Pages); series.NormalizedName = series.Name.ToNormalized(); @@ -200,7 +147,7 @@ public class ProcessSeries : IProcessSeries series.NormalizedLocalizedName = series.LocalizedName.ToNormalized(); } - UpdateSeriesMetadata(series, library); + await UpdateSeriesMetadata(series, library); // Update series FolderPath here await UpdateSeriesFolderPath(parsedInfos, library, series); @@ -213,20 +160,22 @@ public class ProcessSeries : IProcessSeries { await _unitOfWork.CommitAsync(); } + catch (DbUpdateConcurrencyException ex) + { + _logger.LogCritical(ex, + "[ScannerService] There was an issue writing to the database for series {SeriesName}", + series.Name); + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent($"There was an issue writing to the DB for Series {series.OriginalName}", + ex.Message)); + return; + } catch (Exception ex) { await _unitOfWork.RollbackAsync(); _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the database for series {SeriesName}", series.Name); - _logger.LogTrace("[ScannerService] Series Metadata Dump: {@Series}", series.Metadata); - _logger.LogTrace("[ScannerService] People Dump: {@People}", _people - .Select(p => - new {p.Id, p.Name, SeriesMetadataIds = - p.SeriesMetadatas?.Select(m => m.Id), - ChapterMetadataIds = - p.ChapterMetadatas?.Select(m => m.Id) - .ToList()})); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent($"There was an issue writing to the DB for Series {series.OriginalName}", @@ -234,18 +183,27 @@ public class ProcessSeries : IProcessSeries return; } + // Process reading list after commit as we need to commit per list - await _readingListService.CreateReadingListsFromSeries(series, library); + if (library.ManageReadingLists) + { + await _readingListService.CreateReadingListsFromSeries(series, library); + } + 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)"); - await _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type); - await _unitOfWork.ExternalSeriesMetadataRepository.LinkRecommendationsToSeries(series); + BackgroundJob.Enqueue(() => + _externalMetadataService.FetchSeriesMetadata(series.Id, series.Library.Type)); + await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, series.LibraryId), false); } + else + { + await _unitOfWork.ExternalSeriesMetadataRepository.LinkRecommendationsToSeries(series); + } _logger.LogInformation("[ScannerService] Finished series update on {SeriesName} in {Milliseconds} ms", seriesName, scanWatch.ElapsedMilliseconds); } @@ -253,18 +211,46 @@ public class ProcessSeries : IProcessSeries catch (Exception ex) { _logger.LogError(ex, "[ScannerService] There was an exception updating series for {SeriesName}", series.Name); + return; } - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - await _metadataService.GenerateCoversForSeries(series, settings.EncodeMediaAs, settings.CoverImageSize); - EnqueuePostSeriesProcessTasks(series.LibraryId, series.Id); + await _metadataService.GenerateCoversForSeries(series.LibraryId, series.Id, false, false); + 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); + + seriesCollisions = seriesCollisions.Where(collision => + collision.Name != firstInfo.Series || collision.LocalizedName != firstInfo.LocalizedSeries).ToList(); + + if (seriesCollisions.Count > 1) + { + var firstCollision = seriesCollisions[0]; + var secondCollision = seriesCollisions[1]; + + var tableRows = $"Name: {firstCollision.Name}Name: {secondCollision.Name}" + + $"Localized: {firstCollision.LocalizedName}Localized: {secondCollision.LocalizedName}" + + $"Filename: {Parser.Parser.NormalizePath(firstCollision.FolderPath)}Filename: {Parser.Parser.NormalizePath(secondCollision.FolderPath)}"; + + var htmlTable = $"{string.Join(string.Empty, tableRows)}
Series 1Series 2
"; + + _logger.LogError(ex, "[ScannerService] Scanner found a Series {SeriesName} which matched another Series {LocalizedName} in a different folder parallel to Library {LibraryName} root folder. This is not allowed. Please correct, scan will abort", + firstInfo.Series, firstInfo.LocalizedSeries, library.Name); + + await _eventHub.SendMessageAsync(MessageFactory.Error, + MessageFactory.ErrorEvent($"Library {library.Name} Series collision on {firstInfo.Series}", + htmlTable)); + } } private async Task UpdateSeriesFolderPath(IEnumerable parsedInfos, Library library, Series series) { - var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(library.Folders.Select(l => l.Path), - parsedInfos.Select(f => f.FullFilePath).ToList()); + var libraryFolders = library.Folders.Select(l => Parser.Parser.NormalizePath(l.Path)).ToList(); + var seriesFiles = parsedInfos.Select(f => Parser.Parser.NormalizePath(f.FullFilePath)).ToList(); + var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryFolders, seriesFiles); if (seriesDirs.Keys.Count == 0) { _logger.LogCritical( @@ -278,27 +264,33 @@ public class ProcessSeries : IProcessSeries // Don't save FolderPath if it's a library Folder if (!library.Folders.Select(f => f.Path).Contains(seriesDirs.Keys.First())) { + // BUG: FolderPath can be a level higher than it needs to be. I'm not sure why it's like this, but I thought it should be one level lower. + // I think it's like this because higher level is checked or not checked. But i think we can do both series.FolderPath = Parser.Parser.NormalizePath(seriesDirs.Keys.First()); _logger.LogDebug("Updating {Series} FolderPath to {FolderPath}", series.Name, series.FolderPath); } } + + var lowestFolder = _directoryService.FindLowestDirectoriesFromFiles(libraryFolders, seriesFiles); + if (!string.IsNullOrEmpty(lowestFolder)) + { + series.LowestFolderPath = lowestFolder; + _logger.LogDebug("Updating {Series} LowestFolderPath to {FolderPath}", series.Name, series.LowestFolderPath); + } } - public void EnqueuePostSeriesProcessTasks(int libraryId, int seriesId, bool forceUpdate = false) - { - BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(libraryId, seriesId, forceUpdate)); - } - public void UpdateSeriesMetadata(Series series, Library library) + private async Task UpdateSeriesMetadata(Series series, Library library) { series.Metadata ??= new SeriesMetadataBuilder().Build(); var firstChapter = SeriesService.GetFirstChapterForMetadata(series); var firstFile = firstChapter?.Files.FirstOrDefault(); if (firstFile == null) return; - if (Parser.Parser.IsPdf(firstFile.FilePath)) return; - var chapters = series.Volumes.SelectMany(volume => volume.Chapters).ToList(); + var chapters = series.Volumes + .SelectMany(volume => volume.Chapters) + .ToList(); // Update Metadata based on Chapter metadata if (!series.Metadata.ReleaseYearLocked) @@ -307,47 +299,22 @@ public class ProcessSeries : IProcessSeries } // Set the AgeRating as highest in all the comicInfos - if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); + if (!series.Metadata.AgeRatingLocked) + { + series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating); - // Count (aka expected total number of chapters or volumes from metadata) across all chapters - series.Metadata.TotalCount = chapters.Max(chapter => chapter.TotalCount); - // The actual number of count's defined across all chapter's metadata - series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); - - var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name)); - var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range)); - - // Single books usually don't have a number in their Range (filename) - if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) - { - series.Metadata.MaxCount = 1; - } else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial) - { - // If a series has a TotalCount of 1 (or no total count) and there is only a Special, mark it as Complete - series.Metadata.MaxCount = series.Metadata.TotalCount; - } else if ((maxChapter == 0 || maxChapter > series.Metadata.TotalCount) && maxVolume <= series.Metadata.TotalCount) - { - series.Metadata.MaxCount = maxVolume; - } else if (maxVolume == series.Metadata.TotalCount) - { - series.Metadata.MaxCount = maxVolume; - } else - { - series.Metadata.MaxCount = maxChapter; - } - - if (!series.Metadata.PublicationStatusLocked) - { - series.Metadata.PublicationStatus = PublicationStatus.OnGoing; - if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + // Get the MetadataSettings and apply Age Rating Mappings here + var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto(); + var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title)); + var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings); + if (updatedRating > series.Metadata.AgeRating) { - series.Metadata.PublicationStatus = PublicationStatus.Completed; - } else if (series.Metadata.TotalCount > 0 && series.Metadata.MaxCount > 0) - { - series.Metadata.PublicationStatus = PublicationStatus.Ended; + series.Metadata.AgeRating = updatedRating; } } + DeterminePublicationStatus(series, chapters); + if (!string.IsNullOrEmpty(firstChapter?.Summary) && !series.Metadata.SummaryLocked) { series.Metadata.Summary = firstChapter.Summary; @@ -358,198 +325,366 @@ public class ProcessSeries : IProcessSeries series.Metadata.Language = firstChapter.Language; } + if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections) { - _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); - foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - var normalizedName = Parser.Parser.Normalize(collection); - if (!_collectionTags.TryGetValue(normalizedName, out var tag)) - { - tag = _collectionTagService.CreateTag(collection); - _collectionTags.Add(normalizedName, tag); - } + await UpdateCollectionTags(series, firstChapter); + } - _collectionTagService.AddTagToSeriesMetadata(tag, series.Metadata); + #region PeopleAndTagsAndGenres + if (!series.Metadata.WriterLocked) + { + var personSw = Stopwatch.StartNew(); + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); + } + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count); } + + if (!series.Metadata.ColoristLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Colorist)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Colorist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + } + } + + if (!series.Metadata.PublisherLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Publisher)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Publisher)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + } + } + + if (!series.Metadata.CoverArtistLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.CoverArtist)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.CoverArtist)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + } + } + + if (!series.Metadata.CharacterLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Character)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Character)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + } + } + + if (!series.Metadata.EditorLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Editor)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Editor)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + } + } + + if (!series.Metadata.InkerLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Inker)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Inker)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + } + } + + if (!series.Metadata.ImprintLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Imprint)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Imprint)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + } + } + + if (!series.Metadata.TeamLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Team)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Team)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + } + } + + if (!series.Metadata.LocationLocked && !series.Metadata.AllKavitaPlus(PersonRole.Location)) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Location)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + } + } + + if (!series.Metadata.LettererLocked && !series.Metadata.AllKavitaPlus(PersonRole.Letterer)) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Letterer)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Location)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + } + } + + if (!series.Metadata.PencillerLocked && !series.Metadata.AllKavitaPlus(PersonRole.Penciller)) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Penciller)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Penciller)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + } + } + + if (!series.Metadata.TranslatorLocked && !series.Metadata.AllKavitaPlus(PersonRole.Translator)) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Translator)).ToList(); + if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Translator)) + { + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + } + } + + + if (!series.Metadata.TagsLocked) + { + var tags = chapters.SelectMany(c => c.Tags).ToList(); + UpdateSeriesMetadataTags(series.Metadata.Tags, tags); } if (!series.Metadata.GenresLocked) { var genres = chapters.SelectMany(c => c.Genres).ToList(); - GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres.ToList(), genres, genre => - { - series.Metadata.Genres.Remove(genre); - }); + UpdateSeriesMetadataGenres(series.Metadata.Genres, genres); } - - #region People - - // Handle People - foreach (var chapter in chapters) - { - if (!series.Metadata.WriterLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Writer)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.CoverArtistLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.CoverArtist)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.PublisherLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Publisher)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.CharacterLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Character)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.ColoristLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Colorist)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.EditorLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Editor)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.InkerLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Inker)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.LettererLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Letterer)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.PencillerLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Penciller)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.TranslatorLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Translator)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - - if (!series.Metadata.TagsLocked) - { - foreach (var tag in chapter.Tags) - { - TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag); - } - } - - if (!series.Metadata.GenresLocked) - { - foreach (var genre in chapter.Genres) - { - GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre); - } - } - } - // NOTE: The issue here is that people is just from chapter, but series metadata might already have some people on it - // I might be able to filter out people that are in locked fields? - var people = chapters.SelectMany(c => c.People).ToList(); - PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People.ToList(), - people, person => - { - switch (person.Role) - { - case PersonRole.Writer: - if (!series.Metadata.WriterLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Penciller: - if (!series.Metadata.PencillerLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Inker: - if (!series.Metadata.InkerLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Colorist: - if (!series.Metadata.ColoristLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Letterer: - if (!series.Metadata.LettererLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.CoverArtist: - if (!series.Metadata.CoverArtistLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Editor: - if (!series.Metadata.EditorLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Publisher: - if (!series.Metadata.PublisherLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Character: - if (!series.Metadata.CharacterLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Translator: - if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Other: - default: - series.Metadata.People.Remove(person); - break; - } - }); - #endregion } - public void UpdateVolumes(Series series, IList parsedInfos, bool forceUpdate = false) + /// + /// Ensure that we don't overwrite Person metadata when all metadata is coming from Kavita+ metadata match functionality + /// + /// + /// + /// + /// + private static bool ShouldUpdatePeopleForRole(Series series, List chapterPeople, PersonRole role) + { + if (chapterPeople.Count == 0) return false; + + // If metadata already has this role, but all entries are from KavitaPlus, we should retain them + if (series.Metadata.AnyOfRole(role)) + { + var existingPeople = series.Metadata.People.Where(p => p.Role == role); + + // If all existing people are KavitaPlus but new chapter people exist, we should still update + if (existingPeople.All(p => p.KavitaPlusConnection)) + { + return false; // Ensure we don't remove KavitaPlus people + } + + return true; // Default case: metadata exists, and it's okay to update + } + + return true; + } + + private async Task UpdateCollectionTags(Series series, Chapter firstChapter) + { + // Get the default admin to associate these tags to + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); + if (defaultAdmin == null) return; + + _logger.LogInformation("Collection tag(s) found for {SeriesName}, updating collections", series.Name); + var sw = Stopwatch.StartNew(); + + foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + // Try to find an existing collection tag by its normalized name + var normalizedCollectionName = collection.ToNormalized(); + var collectionTag = defaultAdmin.Collections.FirstOrDefault(c => c.NormalizedTitle == normalizedCollectionName); + + // If the collection tag does not exist, create a new one + if (collectionTag == null) + { + _logger.LogDebug("Creating new collection tag for {Tag}", collection); + + collectionTag = new AppUserCollectionBuilder(collection).Build(); + defaultAdmin.Collections.Add(collectionTag); + + _unitOfWork.UserRepository.Update(defaultAdmin); + + await _unitOfWork.CommitAsync(); + } + + // Check if the Series is already associated with this collection + if (collectionTag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName))) + { + continue; + } + + // Add the series to the collection tag + collectionTag.Items.Add(series); + + // Update the collection age rating + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collectionTag); + } + + _logger.LogTrace("[TIME] Kavita took {Time} ms to process collections on Series: {Name}", sw.ElapsedMilliseconds, series.Name); + } + + + private static void UpdateSeriesMetadataTags(ICollection metadataTags, IList chapterTags) + { + // Create a HashSet of normalized titles for faster lookups + var chapterTagTitles = new HashSet(chapterTags.Select(t => t.NormalizedTitle)); + + // Remove any tags from metadataTags that are not part of chapterTags + var tagsToRemove = metadataTags + .Where(mt => !chapterTagTitles.Contains(mt.NormalizedTitle)) + .ToList(); + + if (tagsToRemove.Count > 0) + { + foreach (var tagToRemove in tagsToRemove) + { + metadataTags.Remove(tagToRemove); + } + } + + // Create a HashSet of metadataTags normalized titles for faster lookup + var metadataTagTitles = new HashSet(metadataTags.Select(mt => mt.NormalizedTitle)); + + // Add any tags from chapterTags that do not already exist in metadataTags + foreach (var tag in chapterTags) + { + if (!metadataTagTitles.Contains(tag.NormalizedTitle)) + { + metadataTags.Add(tag); + } + } + } + + private static void UpdateSeriesMetadataGenres(ICollection metadataGenres, IList chapterGenres) + { + // Create a HashSet of normalized titles for chapterGenres for fast lookup + var chapterGenreTitles = new HashSet(chapterGenres.Select(g => g.NormalizedTitle)); + + // Remove any genres from metadataGenres that are not present in chapterGenres + var genresToRemove = metadataGenres + .Where(mg => !chapterGenreTitles.Contains(mg.NormalizedTitle)) + .ToList(); + + foreach (var genreToRemove in genresToRemove) + { + metadataGenres.Remove(genreToRemove); + } + + // Create a HashSet of metadataGenres normalized titles for fast lookup + var metadataGenreTitles = new HashSet(metadataGenres.Select(mg => mg.NormalizedTitle)); + + // Add any genres from chapterGenres that are not already in metadataGenres + foreach (var genre in chapterGenres) + { + if (!metadataGenreTitles.Contains(genre.NormalizedTitle)) + { + metadataGenres.Add(genre); + } + } + } + + + + private async Task UpdateSeriesMetadataPeople(SeriesMetadata metadata, ICollection metadataPeople, + IEnumerable chapterPeople, PersonRole role) + { + await PersonHelper.UpdateSeriesMetadataPeopleAsync(metadata, metadataPeople, chapterPeople, role, _unitOfWork); + } + + private void DeterminePublicationStatus(Series series, List chapters) + { + try + { + // Count (aka expected total number of chapters or volumes from metadata) across all chapters + series.Metadata.TotalCount = chapters.Max(chapter => chapter.TotalCount); + // The actual number of count's defined across all chapter's metadata + series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count); + + var nonSpecialVolumes = series.Volumes + .Where(v => v.MaxNumber.IsNot(Parser.Parser.SpecialVolumeNumber)) + .ToList(); + + var maxVolume = (int)(nonSpecialVolumes.Any() ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); + var maxChapter = (int)chapters.Max(c => c.MaxNumber); + + // Single books usually don't have a number in their Range (filename) + if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) + { + series.Metadata.MaxCount = 1; + } + else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial) + { + // If a series has a TotalCount of 1 (or no total count) and there is only a Special, mark it as Complete + series.Metadata.MaxCount = series.Metadata.TotalCount; + } + else if ((maxChapter == Parser.Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && + maxVolume <= series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else if (maxVolume == series.Metadata.TotalCount) + { + series.Metadata.MaxCount = maxVolume; + } + else + { + series.Metadata.MaxCount = maxChapter; + } + + if (!series.Metadata.PublicationStatusLocked) + { + series.Metadata.PublicationStatus = PublicationStatus.OnGoing; + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + series.Metadata.PublicationStatus = PublicationStatus.Completed; + } + else if (series.Metadata.TotalCount > 0 && series.Metadata.MaxCount > 0) + { + series.Metadata.PublicationStatus = PublicationStatus.Ended; + } + } + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an issue determining Publication Status"); + series.Metadata.PublicationStatus = PublicationStatus.OnGoing; + } + } + + private async Task UpdateVolumes(Series series, IList parsedInfos, bool forceUpdate = false) { // Add new volumes and update chapters per volume var distinctVolumes = parsedInfos.DistinctVolumes(); - _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); foreach (var volumeNumber in distinctVolumes) { Volume? volume; try { - volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); + // With the Name change to be formatted, Name no longer working because Name returns "1" and volumeNumber is "1.0", so we use LookupName as the original + volume = series.Volumes.SingleOrDefault(s => s.LookupName == volumeNumber); } catch (Exception ex) { + // 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"); } @@ -561,55 +696,49 @@ public class ProcessSeries : IProcessSeries series.Volumes.Add(volume); } - volume.Name = volumeNumber; + volume.LookupName = volumeNumber; + volume.Name = volume.GetNumberTitle(); - _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(series, volume, infos, forceUpdate); - volume.Pages = volume.Chapters.Sum(c => c.Pages); - // Update all the metadata on the Chapters - foreach (var chapter in volume.Chapters) - { - var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) continue; - try - { - var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); - UpdateChapterFromComicInfo(chapter, firstChapterInfo?.ComicInfo, forceUpdate); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was some issue when updating chapter's metadata"); - } - } + await UpdateChapters(series, volume, infos, forceUpdate); + volume.Pages = volume.Chapters.Sum(c => c.Pages); } // Remove existing volumes that aren't in parsedInfos - var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList(); - if (series.Volumes.Count != nonDeletedVolumes.Count) - { - _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", - (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); - var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); - foreach (var volume in deletedVolumes) - { - var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? string.Empty; - if (!string.IsNullOrEmpty(file) && _directoryService.FileSystem.File.Exists(file)) - { - _logger.LogInformation( - "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", - file); - } - - _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); - } - - series.Volumes = nonDeletedVolumes; - } + RemoveVolumes(series, parsedInfos); } - public void UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) + private void RemoveVolumes(Series series, IList parsedInfos) + { + + var nonDeletedVolumes = series.Volumes + .Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.LookupName)) + .ToList(); + if (series.Volumes.Count == nonDeletedVolumes.Count) return; + + + _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); + var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); + foreach (var volume in deletedVolumes) + { + var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? string.Empty; + if (!string.IsNullOrEmpty(file) && _directoryService.FileSystem.File.Exists(file)) + { + // This can happen when file is renamed and volume is removed + _logger.LogInformation( + "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk (usually volume marker removed) File: {File}", + file); + } + + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + } + + series.Volumes = nonDeletedVolumes; + } + + private async Task UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) { // Add new chapters foreach (var info in parsedInfos) @@ -640,46 +769,112 @@ public class ProcessSeries : IProcessSeries chapter.UpdateFrom(info); } - if (chapter == null) continue; + // Add files - var specialTreatment = info.IsSpecialInfo(); AddOrUpdateFileForChapter(chapter, info, forceUpdate); + chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters).ToString(CultureInfo.InvariantCulture); - chapter.Range = specialTreatment ? info.Filename : info.Chapters; + chapter.MinNumber = Parser.Parser.MinNumberFromRange(info.Chapters); + chapter.MaxNumber = Parser.Parser.MaxNumberFromRange(info.Chapters); + chapter.Range = chapter.GetNumberTitle(); + + if (!chapter.SortOrderLocked) + { + chapter.SortOrder = info.IssueOrder; + } + + if (float.TryParse(chapter.Title, CultureInfo.InvariantCulture, out _)) + { + // If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong. + chapter.Title = chapter.GetNumberTitle(); + } + + try + { + await UpdateChapterFromComicInfo(chapter, info.ComicInfo, forceUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was some issue when updating chapter's metadata"); + } + } + RemoveChapters(volume, parsedInfos); + } + + private void RemoveChapters(Volume volume, IList parsedInfos) + { + // Chapters to remove after enumeration + var chaptersToRemove = new List(); + + var existingChapters = volume.Chapters; + + // Extract the directories (without filenames) from parserInfos + var parsedDirectories = parsedInfos + .Select(p => Path.GetDirectoryName(p.FullFilePath)) + .Distinct() + .ToList(); - // Remove chapters that aren't in parsedInfos or have no files linked - var existingChapters = volume.Chapters.ToList(); foreach (var existingChapter in existingChapters) { - if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) + var chapterFileDirectories = existingChapter.Files + .Select(f => Path.GetDirectoryName(f.FilePath)) + .Distinct() + .ToList(); + + var hasMatchingDirectory = chapterFileDirectories.Exists(dir => parsedDirectories.Contains(dir)); + + if (hasMatchingDirectory) { - _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); - volume.Chapters.Remove(existingChapter); + existingChapter.Files = existingChapter.Files + .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); + + if (existingChapter.Files.Count != 0) continue; + + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", + existingChapter.Range, volume.Name, parsedInfos[0].Series); + chaptersToRemove.Add(existingChapter); // Mark chapter for removal } 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)) - .OrderByNatural(f => f.FilePath).ToList(); - existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); + var filesExist = existingChapter.Files.Any(f => File.Exists(f.FilePath)); + if (filesExist) continue; + + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist", + existingChapter.Range, volume.Name, parsedInfos[0].Series); + chaptersToRemove.Add(existingChapter); // Mark chapter for removal } } + + // Remove chapters after the loop to avoid modifying the collection during enumeration + foreach (var chapter in chaptersToRemove) + { + volume.Chapters.Remove(chapter); + } } - public void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false) + + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false) { chapter.Files ??= new List(); var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); var fileInfo = _directoryService.FileSystem.FileInfo.New(info.FullFilePath); if (existingFile != null) { + // TODO: I wonder if we can simplify this force check. existingFile.Format = info.Format; + if (!forceUpdate && !_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; + 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 } @@ -694,28 +889,30 @@ public class ProcessSeries : IProcessSeries } } - public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false) + private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false) { if (comicInfo == null) return; var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; - _logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath); + var sw = Stopwatch.StartNew(); + if (!chapter.AgeRatingLocked) + { + chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); + } - chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); - - if (!string.IsNullOrEmpty(comicInfo.Title)) + if (!chapter.TitleNameLocked && !string.IsNullOrEmpty(comicInfo.Title)) { chapter.TitleName = comicInfo.Title.Trim(); } - if (!string.IsNullOrEmpty(comicInfo.Summary)) + if (!chapter.SummaryLocked && !string.IsNullOrEmpty(comicInfo.Summary)) { chapter.Summary = comicInfo.Summary; } - if (!string.IsNullOrEmpty(comicInfo.LanguageISO)) + if (!chapter.LanguageLocked && !string.IsNullOrEmpty(comicInfo.LanguageISO)) { chapter.Language = comicInfo.LanguageISO; } @@ -753,15 +950,13 @@ public class ProcessSeries : IProcessSeries if (!string.IsNullOrEmpty(comicInfo.Web)) { chapter.WebLinks = string.Join(",", comicInfo.Web - .Split(",") - .Where(s => !string.IsNullOrEmpty(s)) - .Select(s => s.Trim()) + .Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ); // TODO: For each weblink, try to parse out some MetadataIds and store in the Chapter directly for matching (CBL) } - if (!string.IsNullOrEmpty(comicInfo.Isbn)) + if (!chapter.ISBNLocked && !string.IsNullOrEmpty(comicInfo.Isbn)) { chapter.ISBN = comicInfo.Isbn; } @@ -774,171 +969,144 @@ public class ProcessSeries : IProcessSeries // This needs to check against both Number and Volume to calculate Count chapter.Count = comicInfo.CalculatedCount(); - void AddPerson(Person person) - { - PersonHelper.AddPersonIfNotExists(chapter.People, person); - } - void AddGenre(Genre genre, bool newTag) - { - chapter.Genres.Add(genre); - } - - void AddTag(Tag tag, bool added) - { - chapter.Tags.Add(tag); - } - - - if (comicInfo.Year > 0) + if (!chapter.ReleaseDateLocked && comicInfo.Year > 0) { var day = Math.Max(comicInfo.Day, 1); var month = Math.Max(comicInfo.Month, 1); chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day); } - var people = GetTagValues(comicInfo.Colorist); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); - UpdatePeople(people, PersonRole.Colorist, AddPerson); - - people = GetTagValues(comicInfo.Characters); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); - UpdatePeople(people, PersonRole.Character, AddPerson); - - - people = GetTagValues(comicInfo.Translator); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); - UpdatePeople(people, PersonRole.Translator, AddPerson); - - - people = GetTagValues(comicInfo.Writer); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); - UpdatePeople(people, PersonRole.Writer, AddPerson); - - people = GetTagValues(comicInfo.Editor); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); - UpdatePeople(people, PersonRole.Editor, AddPerson); - - people = GetTagValues(comicInfo.Inker); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); - UpdatePeople(people, PersonRole.Inker, AddPerson); - - people = GetTagValues(comicInfo.Letterer); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); - UpdatePeople(people, PersonRole.Letterer, AddPerson); - - people = GetTagValues(comicInfo.Penciller); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); - UpdatePeople(people, PersonRole.Penciller, AddPerson); - - people = GetTagValues(comicInfo.CoverArtist); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); - UpdatePeople(people, PersonRole.CoverArtist, AddPerson); - - people = GetTagValues(comicInfo.Publisher); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); - UpdatePeople(people, PersonRole.Publisher, AddPerson); - - var genres = GetTagValues(comicInfo.Genre); - GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, - genres.Select(g => new GenreBuilder(g).Build()).ToList()); - UpdateGenre(genres, AddGenre); - - var tags = GetTagValues(comicInfo.Tags); - TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => new TagBuilder(t).Build()).ToList()); - UpdateTag(tags, AddTag); - } - - private static IList GetTagValues(string comicInfoTagSeparatedByComma) - { - // TODO: Move this to an extension and test it - if (string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) + if (!chapter.IsPersonRoleLocked(PersonRole.Colorist)) { - return ImmutableList.Empty; + var people = TagHelper.GetTagValues(comicInfo.Colorist); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Colorist); } - return comicInfoTagSeparatedByComma.Split(",") - .Select(s => s.Trim()) - .DistinctBy(Parser.Parser.Normalize) - .ToList(); + if (!chapter.IsPersonRoleLocked(PersonRole.Character)) + { + var people = TagHelper.GetTagValues(comicInfo.Characters); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Character); + } + + + if (!chapter.IsPersonRoleLocked(PersonRole.Translator)) + { + var people = TagHelper.GetTagValues(comicInfo.Translator); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Translator); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Writer)) + { + var personSw = Stopwatch.StartNew(); + var people = TagHelper.GetTagValues(comicInfo.Writer); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Writer); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Chapter: {File} for {Count} people", personSw.ElapsedMilliseconds, chapter.Files.First().FileName, people.Count); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Editor)) + { + var people = TagHelper.GetTagValues(comicInfo.Editor); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Editor); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Inker)) + { + var people = TagHelper.GetTagValues(comicInfo.Inker); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Inker); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Letterer)) + { + var people = TagHelper.GetTagValues(comicInfo.Letterer); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Letterer); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Penciller)) + { + var people = TagHelper.GetTagValues(comicInfo.Penciller); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Penciller); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.CoverArtist)) + { + var people = TagHelper.GetTagValues(comicInfo.CoverArtist); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.CoverArtist); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Publisher)) + { + var people = TagHelper.GetTagValues(comicInfo.Publisher); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Publisher); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Imprint)) + { + var people = TagHelper.GetTagValues(comicInfo.Imprint); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Imprint); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Team)) + { + var people = TagHelper.GetTagValues(comicInfo.Teams); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Team); + } + + if (!chapter.IsPersonRoleLocked(PersonRole.Location)) + { + var people = TagHelper.GetTagValues(comicInfo.Locations); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location); + } + + if (!chapter.GenresLocked) + { + var genres = TagHelper.GetTagValues(comicInfo.Genre); + await UpdateChapterGenres(chapter, genres); + } + + if (!chapter.TagsLocked) + { + var tags = TagHelper.GetTagValues(comicInfo.Tags); + await UpdateChapterTags(chapter, tags); + } + + _logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName); } - /// - /// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and - /// add an entry. For each person in name, the callback will be executed. - /// - /// This does not remove people if an empty list is passed into names - /// This is used to add new people to a list without worrying about duplicating rows in the DB - /// - /// - /// - private void UpdatePeople(IEnumerable names, PersonRole role, Action action) + private async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames) { - var allPeopleTypeRole = _people.Where(p => p.Role == role).ToList(); - - foreach (var name in names) + try { - var normalizedName = name.ToNormalized(); - var person = allPeopleTypeRole.Find(p => - p.NormalizedName != null && p.NormalizedName.Equals(normalizedName)); - - if (person == null) - { - person = new PersonBuilder(name, role).Build(); - _people.Add(person); - } - action(person); + await GenreHelper.UpdateChapterGenres(chapter, genreNames, _unitOfWork); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error updating the chapter genres"); } } - /// - /// - /// - /// - /// Executes for each tag - private void UpdateGenre(IEnumerable names, Action action) + + private async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames) { - foreach (var name in names) + try { - var normalizedName = name.ToNormalized(); - if (string.IsNullOrEmpty(normalizedName)) continue; - - _genres.TryGetValue(normalizedName, out var genre); - var newTag = genre == null; - if (newTag) - { - genre = new GenreBuilder(name).Build(); - _genres.Add(normalizedName, genre); - _unitOfWork.GenreRepository.Attach(genre); - } - - action(genre!, newTag); + await TagHelper.UpdateChapterTags(chapter, tagNames, _unitOfWork); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error updating the chapter tags"); } } - /// - /// - /// - /// - /// Callback for every item. Will give said item back and a bool if item was added - private void UpdateTag(IEnumerable names, Action action) + private async Task UpdateChapterPeopleAsync(Chapter chapter, IList people, PersonRole role) { - foreach (var name in names) + try { - if (string.IsNullOrEmpty(name.Trim())) continue; - - var normalizedName = name.ToNormalized(); - _tags.TryGetValue(normalizedName, out var tag); - - var added = tag == null; - if (tag == null) - { - tag = new TagBuilder(name).Build(); - _tags.Add(normalizedName, tag); - } - - action(tag, added); + await PersonHelper.UpdateChapterPeopleAsync(chapter, people, role, _unitOfWork); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an issue adding/updating a person"); } } - } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index c934deb10..e22ee4bb6 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -12,6 +12,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; @@ -33,7 +34,7 @@ public interface IScannerService [Queue(TaskScheduler.ScanQueue)] [DisableConcurrentExecution(60 * 60 * 60)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - Task ScanLibrary(int libraryId, bool forceUpdate = false); + Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSingleScan = true); [Queue(TaskScheduler.ScanQueue)] [DisableConcurrentExecution(60 * 60 * 60)] @@ -45,7 +46,7 @@ public interface IScannerService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true); - Task ScanFolder(string folder); + Task ScanFolder(string folder, string originalPath); Task AnalyzeFiles(); } @@ -76,6 +77,7 @@ public enum ScanCancelReason public class ScannerService : IScannerService { public const string Name = "ScannerService"; + private const int Timeout = 60 * 60 * 60; // 2.5 days private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IMetadataService _metadataService; @@ -86,8 +88,6 @@ public class ScannerService : IScannerService private readonly IProcessSeries _processSeries; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; - private readonly SemaphoreSlim _seriesProcessingSemaphore = new SemaphoreSlim(1, 1); - public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IMetadataService metadataService, ICacheService cacheService, IEventHub eventHub, IDirectoryService directoryService, IReadingItemService readingItemService, @@ -137,41 +137,47 @@ public class ScannerService : IScannerService /// Given a generic folder path, will invoke a Series scan or Library scan. ///
/// This will Schedule the job to run 1 minute in the future to allow for any close-by duplicate requests to be dropped - /// - public async Task ScanFolder(string folder) + /// Normalized folder + /// If invoked from LibraryWatcher, this maybe a nested folder and can allow for optimization + public async Task ScanFolder(string folder, string originalPath) { Series? series = null; try { - series = await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library); + series = await _unitOfWork.SeriesRepository.GetSeriesThatContainsLowestFolderPath(originalPath, + SeriesIncludes.Library) ?? + await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(originalPath, SeriesIncludes.Library) ?? + await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(folder, SeriesIncludes.Library); } catch (InvalidOperationException ex) { if (ex.Message.Equals("Sequence contains more than one element.")) { - _logger.LogCritical("[ScannerService] Multiple series map to this folder. Library scan will be used for ScanFolder"); + _logger.LogCritical(ex, "[ScannerService] Multiple series map to this folder or folder is at library root. Library scan will be used for ScanFolder"); } } - // TODO: Figure out why we have the library type restriction here - if (series != null && (series.Library.Type != LibraryType.Book || series.Library.Type != LibraryType.LightNovel)) + if (series != null) { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { - _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } + + _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder); BackgroundJob.Schedule(() => ScanSeries(series.Id, true), TimeSpan.FromMinutes(1)); return; } + // This is basically rework of what's already done in Library Watcher but is needed if invoked via API var parentDirectory = _directoryService.GetParentDirectoryName(folder); if (string.IsNullOrEmpty(parentDirectory)) return; var libraries = (await _unitOfWork.LibraryRepository.GetLibraryDtosAsync()).ToList(); var libraryFolders = libraries.SelectMany(l => l.Folders); - var libraryFolder = libraryFolders.Select(Scanner.Parser.Parser.NormalizePath).FirstOrDefault(f => f.Contains(parentDirectory)); + var libraryFolder = libraryFolders.Select(Parser.NormalizePath).FirstOrDefault(f => f.Contains(parentDirectory)); if (string.IsNullOrEmpty(libraryFolder)) return; var library = libraries.Find(l => l.Folders.Select(Parser.NormalizePath).Contains(libraryFolder)); @@ -180,10 +186,10 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id)) { - _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); + _logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); return; } - BackgroundJob.Schedule(() => ScanLibrary(library.Id, false), TimeSpan.FromMinutes(1)); + BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); } } @@ -193,28 +199,42 @@ public class ScannerService : IScannerService /// /// Not Used. Scan series will always force [Queue(TaskScheduler.ScanQueue)] + [DisableConcurrentExecution(Timeout)] + [AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true) { + if (TaskScheduler.HasAlreadyEnqueuedTask(Name, "ScanSeries", [seriesId, bypassFolderOptimizationChecks], TaskScheduler.ScanQueue)) + { + _logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Dropping request"); + return; + } + var sw = Stopwatch.StartNew(); - var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update - var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); + + var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); if (library == null) return; + var libraryPaths = library.Folders.Select(f => f.Path).ToList(); if (await ShouldScanSeries(seriesId, library, libraryPaths, series, true) != ScanCancelReason.NoCancel) { - BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(series.LibraryId, seriesId, false)); + BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(series.LibraryId, seriesId, false, false)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(library.Id, seriesId, bypassFolderOptimizationChecks)); return; } - var folderPath = series.FolderPath; + // TODO: We need to refactor this to handle the path changes better + var folderPath = series.LowestFolderPath ?? series.FolderPath; if (string.IsNullOrEmpty(folderPath) || !_directoryService.Exists(folderPath)) { // We don't care if it's multiple due to new scan loop enforcing all in one root directory - var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, files.Select(f => f.FilePath).ToList()); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, + files.Select(f => f.FilePath).ToList()); if (seriesDirs.Keys.Count == 0) { _logger.LogCritical("Scan Series has files spread outside a main series folder. Defaulting to library folder (this is expensive)"); @@ -240,29 +260,24 @@ public class ScannerService : IScannerService return; } - // If the series path doesn't exist anymore, it was either moved or renamed. We need to essentially delete it - var parsedSeries = new Dictionary>(); - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name)); - - await _processSeries.Prime(); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name, 1)); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); - var scanElapsedTime = await ScanFiles(library, new []{ folderPath }, false, TrackFiles, true); - _logger.LogInformation("ScanFiles for {Series} took {Time}", series.Name, scanElapsedTime); - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); - + var (scanElapsedTime, parsedSeries) = await ScanFiles(library, [folderPath], + false, true); + _logger.LogInformation("ScanFiles for {Series} took {Time} milliseconds", series.Name, scanElapsedTime); // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder RemoveParsedInfosNotForSeries(parsedSeries, series); - // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow - if (parsedSeries.Count == 0) - { + // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow + if (parsedSeries.Count == 0) + { var seriesFiles = (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id)); - if (!string.IsNullOrEmpty(series.FolderPath) && !seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath))) + if (!string.IsNullOrEmpty(series.FolderPath) && + !seriesFiles.Where(f => f.FilePath.Contains(series.FolderPath)).Any(m => File.Exists(m.FilePath))) { try { @@ -287,44 +302,57 @@ public class ScannerService : IScannerService await _unitOfWork.RollbackAsync(); return; } - // At this point, parsedSeries will have at least one key and we can perform the update. If it still doesn't, just return and don't do anything - if (parsedSeries.Count == 0) return; - } + } + // At this point, parsedSeries will have at least one key then we can perform the update. If it still doesn't, just return and don't do anything + // Don't allow any processing on files that aren't part of this series + var toProcess = parsedSeries.Keys.Where(key => + key.NormalizedName.Equals(series.NormalizedName) || + key.NormalizedName.Equals(series.OriginalName?.ToNormalized())) + .ToList(); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); + var seriesLeftToProcess = toProcess.Count; + foreach (var pSeries in toProcess) + { + // Process Series + var seriesProcessStopWatch = Stopwatch.StartNew(); + await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series); + seriesLeftToProcess--; + } + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name, 0)); // Tell UI that this series is done await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(library.Id, seriesId, series.Name)); await _metadataService.RemoveAbandonedMetadataKeys(); - //BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(series.LibraryId, seriesId, false)); - //BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(library.Id, seriesId, false)); - BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); - BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)); - return; - async Task TrackFiles(Tuple> parsedInfo) + BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(existingChapterIdsToClean)); + BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory)); + } + + private static Dictionary> TrackFoundSeriesAndFiles(IList seenSeries) + { + // Why does this only grab things that have changed? + var parsedSeries = new Dictionary>(); + foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0)) // && s.HasChanged { - var parsedFiles = parsedInfo.Item2; - if (parsedFiles.Count == 0) return; + var parsedFiles = series.ParsedInfos; + series.ParsedSeries.HasChanged = series.HasChanged; - var foundParsedSeries = new ParsedSeries() + if (series.HasChanged) { - Name = parsedFiles[0].Series, - NormalizedName = parsedFiles[0].Series.ToNormalized(), - Format = parsedFiles[0].Format - }; - - // For Scan Series, we need to filter out anything that isn't our Series - if (!foundParsedSeries.NormalizedName.Equals(series.NormalizedName) && !foundParsedSeries.NormalizedName.Equals(series.OriginalName?.ToNormalized())) - { - return; + parsedSeries.Add(series.ParsedSeries, parsedFiles); + } + else + { + parsedSeries.Add(series.ParsedSeries, []); } - - await _processSeries.ProcessSeriesAsync(parsedFiles, library, bypassFolderOptimizationChecks); - parsedSeries.Add(foundParsedSeries, parsedFiles); } + + return parsedSeries; } private async Task ShouldScanSeries(int seriesId, Library library, IList libraryPaths, Series series, bool bypassFolderChecks = false) @@ -416,7 +444,7 @@ public class ScannerService : IScannerService // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are if (folders.Any(f => !_directoryService.IsDriveMounted(f))) { - _logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); + _logger.LogCritical("[ScannerService] Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", @@ -430,14 +458,14 @@ public class ScannerService : IScannerService if (folders.Any(f => _directoryService.IsDirectoryEmpty(f))) { // That way logging and UI informing is all in one place with full context - _logger.LogError("Some of the root folders for the library are empty. " + + _logger.LogError("[ScannerService] Some of the root folders for the library are empty. " + "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan has be aborted. " + + "Scan has been aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent( $"Some of the root folders for the library, {libraryName}, are empty.", "Either your mount has been disconnected or you are trying to delete all series in the library. " + - "Scan has be aborted. " + + "Scan has been aborted. " + "Check that your mount is connected or change the library's root folder and rescan")); return false; @@ -447,16 +475,25 @@ public class ScannerService : IScannerService } [Queue(TaskScheduler.ScanQueue)] - [DisableConcurrentExecution(60 * 60 * 60)] + [DisableConcurrentExecution(Timeout)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibraries(bool forceUpdate = false) { - _logger.LogInformation("Starting Scan of All Libraries"); + _logger.LogInformation("[ScannerService] Starting Scan of All Libraries, Forced: {Forced}", forceUpdate); foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync()) { - await ScanLibrary(lib.Id, forceUpdate); + // BUG: This will trigger the first N libraries to scan over and over if there is always an interruption later in the chain + if (TaskScheduler.HasScanTaskRunningForLibrary(lib.Id)) + { + // We don't need to send SignalR event as this is a background job that user doesn't need insight into + _logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name); + await Task.Delay(TimeSpan.FromHours(4)); + } + + await ScanLibrary(lib.Id, forceUpdate, true); } - _logger.LogInformation("Scan of All Libraries Finished"); + + _logger.LogInformation("[ScannerService] Scan of All Libraries Finished"); } @@ -467,13 +504,16 @@ public class ScannerService : IScannerService ///
/// /// Defaults to false + /// Defaults to true. Is this a standalone invocation or is it in a loop? [Queue(TaskScheduler.ScanQueue)] - [DisableConcurrentExecution(60 * 60 * 60)] + [DisableConcurrentExecution(Timeout)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanLibrary(int libraryId, bool forceUpdate = false) + 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; @@ -485,77 +525,39 @@ public class ScannerService : IScannerService var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders as a library root, using series scan", library.Name); } - var totalFiles = 0; - var seenSeries = new List(); - - - await _processSeries.Prime(); - //var processTasks = new List>(); - - var scanElapsedTime = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, TrackFiles, forceUpdate); - - // NOTE: This runs sync after every file is scanned - // foreach (var task in processTasks) - // { - // await task(); - // } - - 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); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan & Parse Files", library.Name); + var (scanElapsedTime, parsedSeries) = await ScanFiles(library, libraryFolderPaths, + shouldUseLibraryScan, forceUpdate); + // We need to remove any keys where there is no actual parser info + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Process and Update Database", library.Name); + var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime); + UpdateLastScanned(library); _unitOfWork.LibraryRepository.Update(library); + + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Save Library", library.Name); if (await _unitOfWork.CommitAsync()) { if (totalFiles == 0) { _logger.LogInformation( "[ScannerService] Finished library scan of {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}. There were no changes", - seenSeries.Count, sw.ElapsedMilliseconds, library.Name); + parsedSeries.Count, sw.ElapsedMilliseconds, library.Name); } else { _logger.LogInformation( "[ScannerService] Finished library scan of {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", - totalFiles, seenSeries.Count, sw.ElapsedMilliseconds, library.Name); + 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(seenSeries, 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"); - } + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name); + await RemoveSeriesNotFound(parsedSeries, library); } else { @@ -563,70 +565,205 @@ public class ScannerService : IScannerService "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); } - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, string.Empty)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, string.Empty)); await _metadataService.RemoveAbandonedMetadataKeys(); - BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)); - return; + BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory)); + } - // Responsible for transforming parsedInfo into an actual ParsedSeries then calling the actual processing of the series - async Task TrackFiles(Tuple> parsedInfo) + private async Task RemoveSeriesNotFound(Dictionary> parsedSeries, Library library) + { + try { - var skippedScan = parsedInfo.Item1; - var parsedFiles = parsedInfo.Item2; - if (parsedFiles.Count == 0) return; + _logger.LogDebug("[ScannerService] Removing series that were not found during the scan"); - var foundParsedSeries = new ParsedSeries() - { - Name = parsedFiles[0].Series, - NormalizedName = Parser.Normalize(parsedFiles[0].Series), - Format = parsedFiles[0].Format, - }; + var removedSeries = await _unitOfWork.SeriesRepository.RemoveSeriesNotInList(parsedSeries.Keys.ToList(), library.Id); + _logger.LogDebug("[ScannerService] Found {Count} series to remove: {SeriesList}", + removedSeries.Count, string.Join(", ", removedSeries.Select(s => s.Name))); - if (skippedScan) + // Commit the changes + await _unitOfWork.CommitAsync(); + + // Notify for each removed series + foreach (var series in removedSeries) { - seenSeries.AddRange(parsedFiles.Select(pf => new ParsedSeries() - { - Name = pf.Series, - NormalizedName = Parser.Normalize(pf.Series), - Format = pf.Format - })); - return; + await _eventHub.SendMessageAsync( + MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(series.Id, series.Name, series.LibraryId), + false + ); } - totalFiles += parsedFiles.Count; - - - seenSeries.Add(foundParsedSeries); - await _seriesProcessingSemaphore.WaitAsync(); - try - { - await _processSeries.ProcessSeriesAsync(parsedFiles, library, forceUpdate); - } - finally - { - _seriesProcessingSemaphore.Release(); - } + _logger.LogDebug("[ScannerService] Series removal process completed"); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] Error during series cleanup. Please check logs and rescan"); } } - private async Task ScanFiles(Library library, IEnumerable dirs, - bool isLibraryScan, Func>, Task>? processSeriesInfos = null, bool forceChecks = false) + private async Task ProcessParsedSeries(bool forceUpdate, Dictionary> parsedSeries, Library library, long scanElapsedTime) + { + // Iterate over the dictionary and remove only the ParserInfos that don't need processing + var toProcess = new Dictionary>(); + var scanSw = Stopwatch.StartNew(); + + foreach (var series in parsedSeries) + { + if (!series.Key.HasChanged) + { + _logger.LogDebug("{Series} hasn't changed", series.Key.Name); + continue; + } + + // Filter out ParserInfos where FullFilePath is empty (i.e., folder not modified) + var validInfos = series.Value.Where(info => !string.IsNullOrEmpty(info.Filename)).ToList(); + + if (validInfos.Count != 0) + { + toProcess[series.Key] = validInfos; + } + } + + if (toProcess.Count > 0) + { + // For all Genres in the ParserInfos, do a bulk check against the DB on what is not in the DB and create them + // This will ensure all Genres are pre-created and allow our Genre lookup (and Priming) to be much simpler. It will be slower, but more consistent. + var allGenres = toProcess + .SelectMany(s => s.Value + .SelectMany(p => p.ComicInfo?.Genre? + .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries + .Select(g => g.Trim()) // Trim each genre + .Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres + ?? [])); // Handle null Genre or ComicInfo safely + + await CreateAllGenresAsync(allGenres.Distinct().ToList()); + + var allTags = toProcess + .SelectMany(s => s.Value + .SelectMany(p => p.ComicInfo?.Tags? + .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries + .Select(g => g.Trim()) // Trim each genre + .Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres + ?? [])); // Handle null Tag or ComicInfo safely + + await CreateAllTagsAsync(allTags.Distinct().ToList()); + } + + var totalFiles = 0; + var seriesLeftToProcess = toProcess.Count; + _logger.LogInformation("[ScannerService] Found {SeriesCount} Series that need processing in {Time} ms", toProcess.Count, scanSw.ElapsedMilliseconds + scanElapsedTime); + + foreach (var pSeries in toProcess) + { + totalFiles += pSeries.Value.Count; + var seriesProcessStopWatch = Stopwatch.StartNew(); + await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate); + _logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series); + seriesLeftToProcess--; + } + + + 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, IList dirs, + bool isLibraryScan, bool forceChecks = false) { var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService, _eventHub); var scanWatch = Stopwatch.StartNew(); - await scanner.ScanLibrariesForSeries(library, dirs, - isLibraryScan, await _unitOfWork.SeriesRepository.GetFolderPathMap(library.Id), processSeriesInfos, forceChecks); + var processedSeries = await scanner.ScanLibrariesForSeries(library, dirs, + isLibraryScan, await _unitOfWork.SeriesRepository.GetFolderPathMap(library.Id), forceChecks); var scanElapsedTime = scanWatch.ElapsedMilliseconds; - return scanElapsedTime; + var parsedSeries = TrackFoundSeriesAndFiles(processedSeries); + + return Tuple.Create(scanElapsedTime, parsedSeries); } - public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) + /// + /// Given a list of all Genres, generates new Genre entries for any that do not exist. + /// Does not delete anything, that will be handled by nightly task + /// + /// + private async Task CreateAllGenresAsync(ICollection genres) { - return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries)); + _logger.LogInformation("[ScannerService] Attempting to pre-save all Genres"); + + try + { + // Pass the non-normalized genres directly to the repository + var nonExistingGenres = await _unitOfWork.GenreRepository.GetAllGenresNotInListAsync(genres); + + // Create and attach new genres using the non-normalized names + foreach (var genre in nonExistingGenres) + { + var newGenre = new GenreBuilder(genre).Build(); + _unitOfWork.GenreRepository.Attach(newGenre); + } + + // Commit changes + if (nonExistingGenres.Count > 0) + { + await _unitOfWork.CommitAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an unknown issue when pre-saving all Genres"); + } } + /// + /// Given a list of all Tags, generates new Tag entries for any that do not exist. + /// Does not delete anything, that will be handled by nightly task + /// + /// + private async Task CreateAllTagsAsync(ICollection tags) + { + _logger.LogInformation("[ScannerService] Attempting to pre-save all Tags"); + + try + { + // Pass the non-normalized tags directly to the repository + var nonExistingTags = await _unitOfWork.TagRepository.GetAllTagsNotInListAsync(tags); + + // Create and attach new genres using the non-normalized names + foreach (var tag in nonExistingTags) + { + var newTag = new TagBuilder(tag).Build(); + _unitOfWork.TagRepository.Attach(newTag); + } + + // Commit changes + if (nonExistingTags.Count > 0) + { + await _unitOfWork.CommitAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an unknown issue when pre-saving all Tags"); + } + } } diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index 730900c16..3dca14ab9 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -1,35 +1,108 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.Json.Serialization; using System.Threading.Tasks; using API.Data; +using API.DTOs.Theme; using API.Entities; using API.Entities.Enums.Theme; using API.Extensions; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; +using Flurl.Http; +using HtmlAgilityPack; using Kavita.Common; -using Microsoft.AspNetCore.Authorization; +using Kavita.Common.EnvironmentInfo; +using MarkdownDeep; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace API.Services.Tasks; #nullable enable +internal class GitHubContent +{ + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("path")] + public string Path { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonPropertyName("download_url")] + [JsonProperty("download_url")] + public string DownloadUrl { get; set; } + + [JsonProperty("sha")] + public string Sha { get; set; } +} + +/// +/// The readme of the Theme repo +/// +internal class ThemeMetadata +{ + public string Author { get; set; } + public string AuthorUrl { get; set; } + public string Description { get; set; } + public Version LastCompatible { get; set; } +} + + public interface IThemeService { Task GetContent(int themeId); - Task Scan(); Task UpdateDefault(int themeId); + /// + /// Browse theme repo for themes to download + /// + /// + Task> GetDownloadableThemes(); + + Task DownloadRepoTheme(DownloadableSiteThemeDto dto); + Task DeleteTheme(int siteThemeId); + Task CreateThemeFromFile(string tempFile, string username); + Task SyncThemes(); } + + public class ThemeService : IThemeService { private readonly IDirectoryService _directoryService; private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; + private readonly IFileService _fileService; + private readonly ILogger _logger; + private readonly Markdown _markdown = new(); + private readonly IMemoryCache _cache; + private readonly MemoryCacheEntryOptions _cacheOptions; - public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) + private const string GithubBaseUrl = "https://api.github.com"; + + /// + /// Used for refreshing metadata around themes + /// + private const string GithubReadme = "https://raw.githubusercontent.com/Kareadita/Themes/main/README.md"; + + public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, + IEventHub eventHub, IFileService fileService, ILogger logger, IMemoryCache cache) { _directoryService = directoryService; _unitOfWork = unitOfWork; _eventHub = eventHub; + _fileService = fileService; + _logger = logger; + _cache = cache; + + _cacheOptions = new MemoryCacheEntryOptions() + .SetSize(1) + .SetAbsoluteExpiration(TimeSpan.FromMinutes(30)); } /// @@ -39,8 +112,7 @@ public class ThemeService : IThemeService /// public async Task GetContent(int themeId) { - var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); - if (theme == null) throw new KavitaException("theme-doesnt-exist"); + var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId) ?? throw new KavitaException("theme-doesnt-exist"); var themeFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName); if (string.IsNullOrEmpty(themeFile) || !_directoryService.FileSystem.File.Exists(themeFile)) throw new KavitaException("theme-doesnt-exist"); @@ -48,78 +120,366 @@ public class ThemeService : IThemeService return await _directoryService.FileSystem.File.ReadAllTextAsync(themeFile); } - /// - /// Scans the site theme directory for custom css files and updates what the system has on store - /// - public async Task Scan() + public async Task> GetDownloadableThemes() { - _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); - var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); - var themeFiles = _directoryService - .GetFilesWithExtension(Scanner.Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") - .Where(name => !reservedNames.Contains(name.ToNormalized()) && !name.Contains(" ")) - .ToList(); + const string cacheKey = "browse"; + // Avoid a duplicate Dark issue some users faced during migration + var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()) + .GroupBy(k => k.Name) + .ToDictionary(g => g.Key, g => g.First()); - var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); - - // First remove any files from allThemes that are User Defined and not on disk - var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList(); - foreach (var userTheme in userThemes) + if (_cache.TryGetValue(cacheKey, out List? themes) && themes != null) { - var filepath = Scanner.Parser.Parser.NormalizePath( - _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName)); - if (_directoryService.FileSystem.File.Exists(filepath)) continue; - - // I need to do the removal different. I need to update all user preferences to use DefaultTheme - allThemes.Remove(userTheme); - await RemoveTheme(userTheme); - } - - // Add new custom themes - var allThemeNames = allThemes.Select(t => t.NormalizedName).ToList(); - foreach (var themeFile in themeFiles) - { - var themeName = - _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile).ToNormalized(); - if (allThemeNames.Contains(themeName)) continue; - - _unitOfWork.SiteThemeRepository.Add(new SiteTheme() + foreach (var t in themes) { - Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile), - NormalizedName = themeName, - FileName = _directoryService.FileSystem.Path.GetFileName(themeFile), - Provider = ThemeProvider.User, - IsDefault = false, - }); - - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, - ProgressEventType.Updated)); + t.AlreadyDownloaded = existingThemes.ContainsKey(t.Name); + } + return themes; } + // Fetch contents of the Native Themes directory + var themesContents = await GetDirectoryContent("Native%20Themes"); - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - } + // Filter out directories + var themeDirectories = themesContents.Where(c => c.Type == "dir").ToList(); - // if there are no default themes, reselect Dark as default - var postSaveThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); - if (!postSaveThemes.Exists(t => t.IsDefault)) + // Get the Readme and augment the theme data + var themeMetadata = await GetReadme(); + + var themeDtos = new List(); + foreach (var themeDir in themeDirectories) { - var defaultThemeName = Seed.DefaultThemes.Single(t => t.IsDefault).NormalizedName; - var theme = postSaveThemes.SingleOrDefault(t => t.NormalizedName == defaultThemeName); - if (theme != null) + var themeName = themeDir.Name.Trim(); + + // Fetch contents of the theme directory + var themeContents = await GetDirectoryContent(themeDir.Path); + + + // Find css and preview files + var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css")); + var previewUrls = GetPreviewUrls(themeContents); + + if (cssFile == null) continue; + + var cssUrl = cssFile.DownloadUrl; + + + var dto = new DownloadableSiteThemeDto() { - theme.IsDefault = true; - _unitOfWork.SiteThemeRepository.Update(theme); - await _unitOfWork.CommitAsync(); + Name = themeName, + CssUrl = cssUrl, + CssFile = cssFile.Name, + PreviewUrls = previewUrls, + Sha = cssFile.Sha, + Path = themeDir.Path, + }; + + if (themeMetadata.TryGetValue(themeName, out var metadata)) + { + dto.Author = metadata.Author; + dto.LastCompatibleVersion = metadata.LastCompatible.ToString(); + dto.IsCompatible = BuildInfo.Version <= metadata.LastCompatible; + dto.AlreadyDownloaded = existingThemes.ContainsKey(themeName); + dto.Description = metadata.Description; } + themeDtos.Add(dto); } + _cache.Set(cacheKey, themeDtos, _cacheOptions); + + return themeDtos; + } + + private static List GetPreviewUrls(IEnumerable themeContents) + { + return themeContents + .Where(c => Parser.IsImage(c.Name) ) + .Select(p => p.DownloadUrl) + .ToList(); + } + + private static async Task> GetDirectoryContent(string path) + { + var json = await $"{GithubBaseUrl}/repos/Kareadita/Themes/contents/{path}" + .WithHeader("Accept", "application/vnd.github+json") + .WithHeader("User-Agent", "Kavita") + .GetStringAsync(); + + return string.IsNullOrEmpty(json) ? [] : JsonConvert.DeserializeObject>(json); + } + + /// + /// Returns a map of all Native Themes names mapped to their metadata + /// + /// + private async Task> GetReadme() + { + // Try and delete a Readme file if it already exists + var existingReadmeFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, "README.md"); + if (_directoryService.FileSystem.File.Exists(existingReadmeFile)) + { + _directoryService.DeleteFiles([existingReadmeFile]); + } + + var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory); + + // Read file into Markdown + var htmlContent = _markdown.Transform(await _directoryService.FileSystem.File.ReadAllTextAsync(tempDownloadFile)); + var htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(htmlContent); + + // Find the table of Native Themes + var tableContent = htmlDoc.DocumentNode + .SelectSingleNode("//h2[contains(text(),'Native Themes')]/following-sibling::p").InnerText; + + // Initialize dictionary to store theme metadata + var themes = new Dictionary(); + + + // Split the table content by rows + var rows = tableContent.Split("\r\n").Select(row => row.Trim()).Where(row => !string.IsNullOrWhiteSpace(row)).ToList(); + + // Parse each row in the Native Themes table + foreach (var row in rows.Skip(2)) + { + + var cells = row.Split('|').Skip(1).Select(cell => cell.Trim()).ToList(); + + // Extract information from each cell + var themeName = cells[0]; + var authorName = cells[1]; + var description = cells[2]; + var compatibility = Version.Parse(cells[3]); + + // Create ThemeMetadata object + var themeMetadata = new ThemeMetadata + { + Author = authorName, + Description = description, + LastCompatible = compatibility + }; + + // Add theme metadata to dictionary + themes.Add(themeName, themeMetadata); + } + + return themes; + } + + + private async Task DownloadSiteTheme(DownloadableSiteThemeDto dto) + { + if (string.IsNullOrEmpty(dto.Sha)) + { + throw new ArgumentException("SHA cannot be null or empty for already downloaded themes."); + } + + _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); + var existingTempFile = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, + _directoryService.FileSystem.FileInfo.New(dto.CssUrl).Name); + _directoryService.DeleteFiles([existingTempFile]); + + var tempDownloadFile = await dto.CssUrl.DownloadFileAsync(_directoryService.TempDirectory); + + // Validate the hash on the downloaded file + // if (!_fileService.ValidateSha(tempDownloadFile, dto.Sha)) + // { + // throw new KavitaException("Cannot download theme, hash does not match"); + // } + + _directoryService.CopyFileToDirectory(tempDownloadFile, _directoryService.SiteThemeDirectory); + var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, dto.CssFile); + + return finalLocation; + } + + + public async Task DownloadRepoTheme(DownloadableSiteThemeDto dto) + { + + // Validate we don't have a collision with existing or existing doesn't already exist + var existingThemes = _directoryService.ScanFiles(_directoryService.SiteThemeDirectory, string.Empty); + if (existingThemes.Any(f => Path.GetFileName(f) == dto.CssFile)) + { + // This can happen if you delete then immediately download (to refresh). We should just delete the old file and download. Users can always rollback their version with github directly + _directoryService.DeleteFiles(existingThemes.Where(f => Path.GetFileName(f) == dto.CssFile)); + } + + var finalLocation = await DownloadSiteTheme(dto); + + // Create a new entry and note that this is downloaded + var theme = new SiteTheme() + { + Name = dto.Name, + NormalizedName = dto.Name.ToNormalized(), + FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation), + Provider = ThemeProvider.Custom, + IsDefault = false, + GitHubPath = dto.Path, + Description = dto.Description, + PreviewUrls = string.Join('|', dto.PreviewUrls), + Author = dto.Author, + ShaHash = dto.Sha, + CompatibleVersion = dto.LastCompatibleVersion, + }; + _unitOfWork.SiteThemeRepository.Add(theme); + + await _unitOfWork.CommitAsync(); + + // Inform about the new theme await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name, + ProgressEventType.Ended)); + return theme; + } + + public async Task SyncThemes() + { + var themes = await _unitOfWork.SiteThemeRepository.GetThemes(); + var themeMetadata = await GetReadme(); + foreach (var theme in themes) + { + await SyncTheme(theme, themeMetadata); + } + _logger.LogInformation("Sync Themes complete"); + } + + /// + /// If the Theme is from the Theme repo, see if there is a new version that is compatible + /// + /// + /// The Readme information + private async Task SyncTheme(SiteTheme? theme, IDictionary themeMetadata) + { + // Given a theme, first validate that it is applicable + if (theme == null || theme.Provider == ThemeProvider.System || string.IsNullOrEmpty(theme.GitHubPath)) + { + _logger.LogInformation("Cannot Sync {ThemeName} as it is not valid", theme?.Name); + return; + } + + if (new Version(theme.CompatibleVersion) > BuildInfo.Version) + { + _logger.LogDebug("{ThemeName} theme supports a more up-to-date version ({Version}) of Kavita. Please update", theme.Name, theme.CompatibleVersion); + return; + } + + + var themeContents = await GetDirectoryContent(theme.GitHubPath); + var cssFile = themeContents.FirstOrDefault(c => c.Name.EndsWith(".css")); + + if (cssFile == null) return; + + // Update any metadata + if (themeMetadata.TryGetValue(theme.Name, out var metadata)) + { + theme.Description = metadata.Description; + theme.Author = metadata.Author; + theme.CompatibleVersion = metadata.LastCompatible.ToString(); + theme.PreviewUrls = string.Join('|', GetPreviewUrls(themeContents)); + } + + var hasUpdated = cssFile.Sha != theme.ShaHash; + if (hasUpdated) + { + _logger.LogDebug("Theme {ThemeName} is out of date, updating", theme.Name); + var tempLocation = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName); + + _directoryService.DeleteFiles([tempLocation]); + + var location = await cssFile.DownloadUrl.DownloadFileAsync(_directoryService.TempDirectory); + if (_directoryService.FileSystem.File.Exists(location)) + { + _directoryService.CopyFileToDirectory(location, _directoryService.SiteThemeDirectory); + _logger.LogInformation("Updated Theme on disk for {ThemeName}", theme.Name); + } + } + + await _unitOfWork.CommitAsync(); + + + if (hasUpdated) + { + await _eventHub.SendMessageAsync(MessageFactory.SiteThemeUpdated, + MessageFactory.SiteThemeUpdatedEvent(theme.Name)); + } + + // Send an update to refresh metadata around the themes + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name, + ProgressEventType.Ended)); + + _logger.LogInformation("Theme Sync complete"); + } + + /// + /// Deletes a SiteTheme. The CSS file will be moved to temp/ to allow user to recover data + /// + /// + public async Task DeleteTheme(int siteThemeId) + { + // Validate no one else is using this theme + var inUse = await _unitOfWork.SiteThemeRepository.IsThemeInUse(siteThemeId); + if (inUse) + { + throw new KavitaException("errors.delete-theme-in-use"); + } + + var siteTheme = await _unitOfWork.SiteThemeRepository.GetTheme(siteThemeId); + if (siteTheme == null) return; + + await RemoveTheme(siteTheme); + } + + /// + /// This assumes a file is already in temp directory and will be used for + /// + /// + /// + public async Task CreateThemeFromFile(string tempFile, string username) + { + if (!_directoryService.FileSystem.File.Exists(tempFile)) + { + _logger.LogInformation("Unable to create theme from manual upload as file not in temp"); + throw new KavitaException("errors.theme-manual-upload"); + } + + + var filename = _directoryService.FileSystem.FileInfo.New(tempFile).Name; + var themeName = Path.GetFileNameWithoutExtension(filename); + + if (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName(themeName) != null) + { + throw new KavitaException("errors.theme-already-in-use"); + } + + _directoryService.CopyFileToDirectory(tempFile, _directoryService.SiteThemeDirectory); + var finalLocation = _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, filename); + + + // Create a new entry and note that this is downloaded + var theme = new SiteTheme() + { + Name = Path.GetFileNameWithoutExtension(filename), + NormalizedName = themeName.ToNormalized(), + FileName = _directoryService.FileSystem.Path.GetFileName(finalLocation), + Provider = ThemeProvider.Custom, + IsDefault = false, + Description = $"Manually uploaded via UI by {username}", + PreviewUrls = string.Empty, + Author = username, + }; + _unitOfWork.SiteThemeRepository.Add(theme); + + await _unitOfWork.CommitAsync(); + + // Inform about the new theme + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(theme.FileName), theme.Name, + ProgressEventType.Ended)); + return theme; + } @@ -130,6 +490,7 @@ public class ThemeService : IThemeService /// private async Task RemoveTheme(SiteTheme theme) { + _logger.LogInformation("Removing {ThemeName}. File can be found in temp/ until nightly cleanup", theme.Name); var prefs = await _unitOfWork.UserRepository.GetAllPreferencesByThemeAsync(theme.Id); var defaultTheme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); foreach (var pref in prefs) @@ -137,6 +498,20 @@ public class ThemeService : IThemeService pref.Theme = defaultTheme; _unitOfWork.UserRepository.Update(pref); } + + try + { + // Copy the theme file to temp for nightly removal (to give user time to reclaim if made a mistake) + var existingLocation = + _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, theme.FileName); + var newLocation = + _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, theme.FileName); + _directoryService.CopyFileToDirectory(existingLocation, newLocation); + _directoryService.DeleteFiles([existingLocation]); + } + catch (Exception) { /* Swallow */ } + + _unitOfWork.SiteThemeRepository.Remove(theme); await _unitOfWork.CommitAsync(); } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index ce45cdb28..5d5df6647 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,20 +1,29 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Diagnostics; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; using API.Data; +using API.Data.Misc; using API.Data.Repositories; using API.DTOs.Stats; +using API.DTOs.Stats.V3; +using API.Entities; using API.Entities.Enums; -using API.Entities.Enums.UserPreferences; +using API.Extensions; +using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; using Flurl.Http; +using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Kavita.Common.Helpers; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -24,7 +33,6 @@ namespace API.Services.Tasks; public interface IStatsService { Task Send(); - Task GetServerInfo(); Task GetServerInfoSlim(); Task SendCancellation(); } @@ -36,23 +44,33 @@ public class StatsService : IStatsService private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly DataContext _context; - private readonly IStatisticService _statisticService; - private const string ApiUrl = "https://stats.kavitareader.com"; + private readonly ILicenseService _licenseService; + private readonly UserManager _userManager; + private readonly IEmailService _emailService; + private readonly ICacheService _cacheService; + private readonly string _apiUrl = ""; + private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly - public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, IStatisticService statisticService) + public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, + ILicenseService licenseService, UserManager userManager, IEmailService emailService, + ICacheService cacheService, IHostEnvironment environment) { _logger = logger; _unitOfWork = unitOfWork; _context = context; - _statisticService = statisticService; + _licenseService = licenseService; + _userManager = userManager; + _emailService = emailService; + _cacheService = cacheService; - FlurlHttp.ConfigureClient(ApiUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(Configuration.StatsApiUrl); + + _apiUrl = environment.IsDevelopment() ? "http://localhost:5001" : Configuration.StatsApiUrl; } /// /// Due to all instances firing this at the same time, we can DDOS our server. This task when fired will schedule the task to be run - /// randomly over a 6 hour spread + /// randomly over a six-hour spread /// public async Task Send() { @@ -71,24 +89,22 @@ public class StatsService : IStatsService // ReSharper disable once MemberCanBePrivate.Global public async Task SendData() { - var data = await GetServerInfo(); + var sw = Stopwatch.StartNew(); + var data = await GetStatV3Payload(); + _logger.LogDebug("Collecting stats took {Time} ms", sw.ElapsedMilliseconds); + sw.Stop(); await SendDataToStatsServer(data); } - private async Task SendDataToStatsServer(ServerInfoDto data) + private async Task SendDataToStatsServer(ServerInfoV3Dto data) { var responseContent = string.Empty; try { - var response = await (ApiUrl + "/api/v2/stats") - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") - .WithTimeout(TimeSpan.FromSeconds(30)) + var response = await (_apiUrl + "/api/v3/stats") + .WithBasicHeaders(ApiKey) .PostJsonAsync(data); if (response.StatusCode != StatusCodes.Status200OK) @@ -112,67 +128,6 @@ public class StatsService : IStatsService } } - public async Task GetServerInfo() - { - var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - - var serverInfo = new ServerInfoDto - { - InstallId = serverSettings.InstallId, - Os = RuntimeInformation.OSDescription, - KavitaVersion = serverSettings.InstallVersion, - DotnetVersion = Environment.Version.ToString(), - IsDocker = OsInfo.IsDocker, - NumOfCores = Math.Max(Environment.ProcessorCount, 1), - UsersWithEmulateComicBook = await _context.AppUserPreferences.CountAsync(p => p.EmulateBook), - TotalReadingHours = await _statisticService.TimeSpentReadingForUsersAsync(ArraySegment.Empty, ArraySegment.Empty), - - PercentOfLibrariesWithFolderWatchingEnabled = await GetPercentageOfLibrariesWithFolderWatchingEnabled(), - PercentOfLibrariesIncludedInRecommended = await GetPercentageOfLibrariesIncludedInRecommended(), - PercentOfLibrariesIncludedInDashboard = await GetPercentageOfLibrariesIncludedInDashboard(), - PercentOfLibrariesIncludedInSearch = await GetPercentageOfLibrariesIncludedInSearch(), - - HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(), - NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(), - NumberOfCollections = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count(), - NumberOfReadingLists = await _unitOfWork.ReadingListRepository.Count(), - OPDSEnabled = serverSettings.EnableOpds, - NumberOfUsers = (await _unitOfWork.UserRepository.GetAllUsersAsync()).Count(), - TotalFiles = await _unitOfWork.LibraryRepository.GetTotalFiles(), - TotalGenres = await _unitOfWork.GenreRepository.GetCountAsync(), - TotalPeople = await _unitOfWork.PersonRepository.GetCountAsync(), - UsingSeriesRelationships = await GetIfUsingSeriesRelationship(), - EncodeMediaAs = serverSettings.EncodeMediaAs, - MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(), - MaxVolumesInASeries = await MaxVolumesInASeries(), - MaxChaptersInASeries = await MaxChaptersInASeries(), - MangaReaderBackgroundColors = await AllMangaReaderBackgroundColors(), - MangaReaderPageSplittingModes = await AllMangaReaderPageSplitting(), - MangaReaderLayoutModes = await AllMangaReaderLayoutModes(), - FileFormats = AllFormats(), - UsingRestrictedProfiles = await GetUsingRestrictedProfiles(), - LastReadTime = await _unitOfWork.AppUserProgressRepository.GetLatestProgress() - }; - - var usersWithPref = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences)).ToList(); - serverInfo.UsersOnCardLayout = - usersWithPref.Count(u => u.UserPreferences.GlobalPageLayoutMode == PageLayoutMode.Cards); - serverInfo.UsersOnListLayout = - usersWithPref.Count(u => u.UserPreferences.GlobalPageLayoutMode == PageLayoutMode.List); - - var firstAdminUser = (await _unitOfWork.UserRepository.GetAdminUsersAsync()).FirstOrDefault(); - - if (firstAdminUser != null) - { - var firstAdminUserPref = (await _unitOfWork.UserRepository.GetPreferencesAsync(firstAdminUser.UserName!)); - var activeTheme = firstAdminUserPref?.Theme ?? Seed.DefaultThemes.First(t => t.IsDefault); - - serverInfo.ActiveSiteTheme = activeTheme.Name; - if (firstAdminUserPref != null) serverInfo.MangaReaderMode = firstAdminUserPref.ReaderMode; - } - - return serverInfo; - } public async Task GetServerInfoSlim() { @@ -181,7 +136,9 @@ public class StatsService : IStatsService { InstallId = serverSettings.InstallId, KavitaVersion = serverSettings.InstallVersion, - IsDocker = OsInfo.IsDocker + IsDocker = OsInfo.IsDocker, + FirstInstallDate = serverSettings.FirstInstallDate, + FirstInstallVersion = serverSettings.FirstInstallVersion }; } @@ -194,12 +151,8 @@ public class StatsService : IStatsService try { - var response = await (ApiUrl + "/api/v2/stats/opt-out?installId=" + installId) - .WithHeader("Accept", "application/json") - .WithHeader("User-Agent", "Kavita") - .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") - .WithHeader("x-kavita-version", BuildInfo.Version) - .WithHeader("Content-Type", "application/json") + var response = await (_apiUrl + "/api/v2/stats/opt-out?installId=" + installId) + .WithBasicHeaders(ApiKey) .WithTimeout(TimeSpan.FromSeconds(30)) .PostAsync(); @@ -218,42 +171,32 @@ public class StatsService : IStatsService } } - private async Task GetPercentageOfLibrariesWithFolderWatchingEnabled() + private static async Task PingStatsApi() { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.FolderWatching) / (1.0f * libraries.Count); - } + try + { + var sw = Stopwatch.StartNew(); + var response = await (Configuration.StatsApiUrl + "/api/health/") + .WithBasicHeaders(ApiKey) + .GetAsync(); - private async Task GetPercentageOfLibrariesIncludedInRecommended() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInRecommended) / (1.0f * libraries.Count); - } + if (response.StatusCode == StatusCodes.Status200OK) + { + sw.Stop(); + return sw.ElapsedMilliseconds; + } + } + catch (Exception) + { + /* Swallow */ + } - private async Task GetPercentageOfLibrariesIncludedInDashboard() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInDashboard) / (1.0f * libraries.Count); - } - - private async Task GetPercentageOfLibrariesIncludedInSearch() - { - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - if (libraries.Count == 0) return 0.0f; - return libraries.Count(l => l.IncludeInSearch) / (1.0f * libraries.Count); - } - - private Task GetIfUsingSeriesRelationship() - { - return _context.SeriesRelation.AnyAsync(); + return 0; } private async Task MaxSeriesInAnyLibrary() { - // If first time flow, just return 0 + // If first time flow, return 0 if (!await _context.Series.AnyAsync()) return 0; return await _context.Series .Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series!).Count()) @@ -279,50 +222,191 @@ public class StatsService : IStatsService { // If first time flow, just return 0 if (!await _context.Chapter.AnyAsync()) return 0; + return await _context.Series .AsNoTracking() .AsSplitQuery() .MaxAsync(s => s.Volumes! - .Where(v => v.MinNumber == 0) + .Where(v => v.MinNumber == Parser.LooseLeafVolumeNumber) .SelectMany(v => v.Chapters!) .Count()); } - private async Task> AllMangaReaderBackgroundColors() + private async Task GetStatV3Payload() { - return await _context.AppUserPreferences.Select(p => p.BackgroundColor).Distinct().ToListAsync(); - } + var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + var mediaSettings = await _unitOfWork.SettingsRepository.GetMetadataSettings(); + var dto = new ServerInfoV3Dto() + { + InstallId = serverSettings.InstallId, + KavitaVersion = serverSettings.InstallVersion, + InitialKavitaVersion = serverSettings.FirstInstallVersion, + InitialInstallDate = (DateTime)serverSettings.FirstInstallDate!, + IsDocker = OsInfo.IsDocker, + Os = RuntimeInformation.OSDescription, + NumOfCores = Math.Max(Environment.ProcessorCount, 1), + DotnetVersion = Environment.Version.ToString(), + OpdsEnabled = serverSettings.EnableOpds, + EncodeMediaAs = serverSettings.EncodeMediaAs, + MatchedMetadataEnabled = mediaSettings.Enabled + }; - private async Task> AllMangaReaderPageSplitting() - { - return await _context.AppUserPreferences.Select(p => p.PageSplitOption).Distinct().ToListAsync(); - } + dto.OsLocale = CultureInfo.CurrentCulture.EnglishName; + dto.LastReadTime = await _unitOfWork.AppUserProgressRepository.GetLatestProgress(); + dto.MaxSeriesInALibrary = await MaxSeriesInAnyLibrary(); + dto.MaxVolumesInASeries = await MaxVolumesInASeries(); + dto.MaxChaptersInASeries = await MaxChaptersInASeries(); + dto.TotalFiles = await _context.MangaFile.CountAsync(); + dto.TotalGenres = await _context.Genre.CountAsync(); + dto.TotalPeople = await _context.Person.CountAsync(); + dto.TotalSeries = await _context.Series.CountAsync(); + dto.TotalLibraries = await _context.Library.CountAsync(); + dto.NumberOfCollections = await _context.AppUserCollection.CountAsync(); + dto.NumberOfReadingLists = await _context.ReadingList.CountAsync(); + + try + { + var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + dto.ActiveKavitaPlusSubscription = await _licenseService.HasActiveSubscription(license); + } + catch (Exception) + { + dto.ActiveKavitaPlusSubscription = false; + } - private async Task> AllMangaReaderLayoutModes() - { - return await _context.AppUserPreferences.Select(p => p.LayoutMode).Distinct().ToListAsync(); - } + // Find a random cbz/zip file and open it for reading + await OpenRandomFile(dto); + dto.TimeToPingKavitaStatsApi = await PingStatsApi(); - private IEnumerable AllFormats() - { + #region Relationships - var results = _context.MangaFile - .AsNoTracking() - .AsEnumerable() - .Select(m => new FileFormatDto() + dto.Relationships = await _context.SeriesRelation + .GroupBy(sr => sr.RelationKind) + .Select(g => new RelationshipStatV3 { - Format = m.Format, - Extension = m.Extension + Relationship = g.Key, + Count = g.Count() }) - .DistinctBy(f => f.Extension) - .ToList(); + .ToListAsync(); - return results; + #endregion + + #region Libraries + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.Folders | + LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns | LibraryIncludes.AppUser)).ToList(); + dto.Libraries ??= []; + foreach (var library in allLibraries) + { + var libDto = new LibraryStatV3(); + libDto.IncludeInDashboard = library.IncludeInDashboard; + libDto.IncludeInSearch = library.IncludeInSearch; + libDto.LastScanned = library.LastScanned; + libDto.NumberOfFolders = library.Folders.Count; + libDto.FileTypes = library.LibraryFileTypes.Select(s => s.FileTypeGroup).Distinct().ToList(); + libDto.UsingExcludePatterns = library.LibraryExcludePatterns.Any(p => !string.IsNullOrEmpty(p.Pattern)); + libDto.UsingFolderWatching = library.FolderWatching; + libDto.CreateCollectionsFromMetadata = library.ManageCollections; + libDto.CreateReadingListsFromMetadata = library.ManageReadingLists; + libDto.LibraryType = library.Type; + + dto.Libraries.Add(libDto); + } + #endregion + + #region Users + + // Create a dictionary mapping user IDs to the libraries they have access to + var userLibraryAccess = allLibraries + .SelectMany(l => l.AppUsers.Select(appUser => new { l, appUser.Id })) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.Select(x => x.l).ToList()); + dto.Users ??= []; + var allUsers = await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences + | AppUserIncludes.ReadingLists | AppUserIncludes.Bookmarks + | AppUserIncludes.Collections | AppUserIncludes.Devices + | AppUserIncludes.Progress | AppUserIncludes.Ratings + | AppUserIncludes.SmartFilters | AppUserIncludes.WantToRead, false); + foreach (var user in allUsers) + { + var userDto = new UserStatV3(); + userDto.HasMALToken = !string.IsNullOrEmpty(user.MalAccessToken); + userDto.HasAniListToken = !string.IsNullOrEmpty(user.AniListAccessToken); + userDto.AgeRestriction = new AgeRestriction() + { + AgeRating = user.AgeRestriction, + IncludeUnknowns = user.AgeRestrictionIncludeUnknowns + }; + + userDto.Locale = user.UserPreferences.Locale; + userDto.Roles = [.. _userManager.GetRolesAsync(user).Result]; + userDto.LastLogin = user.LastActiveUtc; + userDto.HasValidEmail = user.Email != null && _emailService.IsValidEmail(user.Email); + userDto.IsEmailConfirmed = user.EmailConfirmed; + userDto.ActiveTheme = user.UserPreferences.Theme.Name; + userDto.CollectionsCreatedCount = user.Collections.Count; + userDto.ReadingListsCreatedCount = user.ReadingLists.Count; + userDto.LastReadTime = user.Progresses + .Select(p => p.LastModifiedUtc) + .DefaultIfEmpty() + .Max(); + userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList(); + userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count; + userDto.SmartFilterCreatedCount = user.SmartFilters.Count; + userDto.WantToReadSeriesCount = user.WantToRead.Count; + + if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries)) + { + userDto.PercentageOfLibrariesHasAccess = (1f * accessibleLibraries.Count) / allLibraries.Count; + } + else + { + userDto.PercentageOfLibrariesHasAccess = 0; + } + + dto.Users.Add(userDto); + } + + #endregion + + return dto; } - private Task GetUsingRestrictedProfiles() + private async Task OpenRandomFile(ServerInfoV3Dto dto) { - return _context.Users.AnyAsync(u => u.AgeRestriction > AgeRating.NotApplicable); + var random = new Random(); + List extensions = [".cbz", ".zip"]; + + // Count the total number of files that match the criteria + var count = await _context.MangaFile.AsNoTracking() + .Where(r => r.Extension != null && extensions.Contains(r.Extension)) + .CountAsync(); + + if (count == 0) + { + dto.TimeToOpeCbzMs = 0; + dto.TimeToOpenCbzPages = 0; + + return; + } + + // Generate a random skip value + var skip = random.Next(count); + + // Fetch the random file + var randomFile = await _context.MangaFile.AsNoTracking() + .Where(r => r.Extension != null && extensions.Contains(r.Extension)) + .Skip(skip) + .Take(1) + .FirstAsync(); + + var sw = Stopwatch.StartNew(); + + await _cacheService.Ensure(randomFile.ChapterId); + var time = sw.ElapsedMilliseconds; + sw.Stop(); + + dto.TimeToOpeCbzMs = time; + dto.TimeToOpenCbzPages = randomFile.Pages; } } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 200851d10..123b610ff 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -1,8 +1,13 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.DTOs.Update; +using API.Extensions; using API.SignalR; using Flurl.Http; using Kavita.Common.EnvironmentInfo; @@ -30,7 +35,7 @@ internal class GithubReleaseMetadata /// public required string Body { get; init; } /// - /// Url of the release on Github + /// Url of the release on GitHub /// // ReSharper disable once InconsistentNaming public required string Html_Url { get; init; } @@ -45,11 +50,12 @@ public interface IVersionUpdaterService { Task CheckForUpdate(); Task PushUpdate(UpdateNotificationDto update); - Task> GetAllReleases(); - Task GetNumberOfReleasesBehind(); + Task> GetAllReleases(int count = 0); + Task GetNumberOfReleasesBehind(bool stableOnly = false); } -public class VersionUpdaterService : IVersionUpdaterService + +public partial class VersionUpdaterService : IVersionUpdaterService { private readonly ILogger _logger; private readonly IEventHub _eventHub; @@ -57,50 +63,387 @@ public class VersionUpdaterService : IVersionUpdaterService #pragma warning disable S1075 private const string GithubLatestReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases/latest"; private const string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; + private const string GithubPullsUrl = "https://api.github.com/repos/Kareadita/Kavita/pulls/"; + private const string GithubBranchCommitsUrl = "https://api.github.com/repos/Kareadita/Kavita/commits?sha=develop"; #pragma warning restore S1075 - public VersionUpdaterService(ILogger logger, IEventHub eventHub) + [GeneratedRegex(@"^\n*(.*?)\n+#{1,2}\s", RegexOptions.Singleline)] + private static partial Regex BlogPartRegex(); + private readonly string _cacheFilePath; + /// + /// The latest release cache + /// + private readonly string _cacheLatestReleaseFilePath; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public VersionUpdaterService(ILogger logger, IEventHub eventHub, IDirectoryService directoryService) { _logger = logger; _eventHub = eventHub; + _cacheFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_releases_cache.json"); + _cacheLatestReleaseFilePath = Path.Combine(directoryService.LongTermCacheDirectory, "github_latest_release_cache.json"); - FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); - FlurlHttp.ConfigureClient(GithubAllReleasesUrl, cli => - cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + FlurlConfiguration.ConfigureClientForUrl(GithubLatestReleasesUrl); + FlurlConfiguration.ConfigureClientForUrl(GithubAllReleasesUrl); } /// - /// Fetches the latest release from Github + /// Fetches the latest (stable) release from GitHub. Does not do any extra nightly release parsing. /// /// Latest update public async Task CheckForUpdate() { + // Attempt to fetch from cache + var cachedRelease = await TryGetCachedLatestRelease(); + if (cachedRelease != null) + { + return cachedRelease; + } + var update = await GetGithubRelease(); - return CreateDto(update); + var dto = CreateDto(update); + + if (dto != null) + { + await CacheLatestReleaseAsync(dto); + } + + return dto; } - public async Task> GetAllReleases() + /// + /// Will add any extra (nightly) updates from the latest stable. Does not back-fill anything prior to the latest stable. + /// + /// + private async Task EnrichWithNightlyInfo(List dtos) { + var dto = dtos[0]; // Latest version + try + { + var currentVersion = new Version(dto.CurrentVersion); + var nightlyReleases = await GetNightlyReleases(currentVersion, Version.Parse(dto.UpdateVersion)); + + if (nightlyReleases.Count == 0) return; + + // Create new DTOs for each nightly release and insert them at the beginning of the list + var nightlyDtos = new List(); + foreach (var nightly in nightlyReleases) + { + var prInfo = await FetchPullRequestInfo(nightly.PrNumber); + if (prInfo == null) continue; + + var sections = ParseReleaseBody(prInfo.Body); + var blogPart = ExtractBlogPart(prInfo.Body); + + var nightlyDto = new UpdateNotificationDto + { + // TODO: I should pass Title to the FE so that Nightly Release can be localized + UpdateTitle = $"Nightly Release {nightly.Version} - {prInfo.Title}", + UpdateVersion = nightly.Version, + CurrentVersion = dto.CurrentVersion, + UpdateUrl = prInfo.Html_Url, + PublishDate = prInfo.Merged_At, + IsDocker = true, // Nightlies are always Docker Only + IsReleaseEqual = IsVersionEqualToBuildVersion(Version.Parse(nightly.Version)), + IsReleaseNewer = true, // Since we already filtered these in GetNightlyReleases + IsPrerelease = true, // All Nightlies are considered prerelease + Added = sections.TryGetValue("Added", out var added) ? added : [], + Changed = sections.TryGetValue("Changed", out var changed) ? changed : [], + Fixed = sections.TryGetValue("Fixed", out var bugfixes) ? bugfixes : [], + Removed = sections.TryGetValue("Removed", out var removed) ? removed : [], + Theme = sections.TryGetValue("Theme", out var theme) ? theme : [], + Developer = sections.TryGetValue("Developer", out var developer) ? developer : [], + KnownIssues = sections.TryGetValue("KnownIssues", out var knownIssues) ? knownIssues : [], + Api = sections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = sections.TryGetValue("Feature Requests", out var frs) ? frs : [], + BlogPart = _markdown.Transform(blogPart.Trim()), + UpdateBody = _markdown.Transform(prInfo.Body.Trim()) + }; + + nightlyDtos.Add(nightlyDto); + } + + // Insert nightly releases at the beginning of the list + var sortedNightlyDtos = nightlyDtos.OrderByDescending(x => x.PublishDate).ToList(); + dtos.InsertRange(0, sortedNightlyDtos); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enrich nightly release information"); + } + } + + + private async Task FetchPullRequestInfo(int prNumber) + { + try + { + return await $"{GithubPullsUrl}{prNumber}" + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch PR information for #{PrNumber}", prNumber); + return null; + } + } + + private async Task> GetNightlyReleases(Version currentVersion, Version latestStableVersion) + { + try + { + var nightlyReleases = new List(); + + var commits = await GithubBranchCommitsUrl + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .GetJsonAsync>(); + + var commitList = commits.ToList(); + bool foundLastStable = false; + + for (var i = 0; i < commitList.Count - 1; i++) + { + var commit = commitList[i]; + var message = commit.Commit.Message.Split('\n')[0]; // Take first line only + + // Skip [skip ci] commits + if (message.Contains("[skip ci]")) continue; + + // Check if this is a stable release + if (message.StartsWith('v')) + { + var stableMatch = Regex.Match(message, @"v(\d+\.\d+\.\d+\.\d+)"); + if (stableMatch.Success) + { + var stableVersion = new Version(stableMatch.Groups[1].Value); + // If we find a stable version lower than current, we've gone too far back + if (stableVersion <= currentVersion) + { + foundLastStable = true; + break; + } + } + continue; + } + + // Look for version bumps that follow PRs + if (!foundLastStable && message == "Bump versions by dotnet-bump-version.") + { + // Get the PR commit that triggered this version bump + if (i + 1 < commitList.Count) + { + var prCommit = commitList[i + 1]; + var prMessage = prCommit.Commit.Message.Split('\n')[0]; + + // Extract PR number using improved regex + var prMatch = Regex.Match(prMessage, @"(?:^|\s)\(#(\d+)\)|\s#(\d+)"); + if (!prMatch.Success) continue; + + var prNumber = int.Parse(prMatch.Groups[1].Value != "" ? + prMatch.Groups[1].Value : prMatch.Groups[2].Value); + + // Get the version from AssemblyInfo.cs in this commit + var version = await GetVersionFromCommit(commit.Sha); + if (version == null) continue; + + // Parse version and compare with current version + if (Version.TryParse(version, out var parsedVersion) && + parsedVersion > latestStableVersion) + { + nightlyReleases.Add(new NightlyInfo + { + Version = version, + PrNumber = prNumber, + Date = DateTime.Parse(commit.Commit.Author.Date, CultureInfo.InvariantCulture) + }); + } + } + } + } + + return nightlyReleases.OrderByDescending(x => x.Date).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get nightly releases"); + return []; + } + } + + public async Task> GetAllReleases(int count = 0) + { + // Attempt to fetch from cache + var cachedReleases = await TryGetCachedReleases(); + // If there is a cached release and the current version is within it, use it, otherwise regenerate + if (cachedReleases != null && cachedReleases.Any(r => IsVersionEqual(r.UpdateVersion, BuildInfo.Version.ToString()))) + { + if (count > 0) + { + // NOTE: We may want to allow the admin to clear Github cache + return cachedReleases.Take(count).ToList(); + } + + return cachedReleases; + } + var updates = await GetGithubReleases(); - var updateDtos = updates.Select(CreateDto) + var query = updates.Select(CreateDto) .Where(d => d != null) .OrderByDescending(d => d!.PublishDate) - .Select(d => d!) - .ToList(); + .Select(d => d!); + + var updateDtos = query.ToList(); + + // Sometimes a release can be 0.8.5.0 on disk, but 0.8.5 from Github + var versionParts = updateDtos[0].UpdateVersion.Split('.'); + if (versionParts.Length < 4) + { + updateDtos[0].UpdateVersion += ".0"; // Append missing parts + } + + // If we're on a nightly build, enrich the information + if (updateDtos.Count != 0) // && BuildInfo.Version > new Version(updateDtos[0].UpdateVersion) + { + await EnrichWithNightlyInfo(updateDtos); + } // Find the latest dto var latestRelease = updateDtos[0]!; + var updateVersion = new Version(latestRelease.UpdateVersion); var isNightly = BuildInfo.Version > new Version(latestRelease.UpdateVersion); + + // isNightly can be true when we compare something like v0.8.1 vs v0.8.1.0 + if (IsVersionEqualToBuildVersion(updateVersion)) + { + isNightly = false; + } + + latestRelease.IsOnNightlyInRelease = isNightly; + // Cache the fetched data + if (updateDtos.Count > 0) + { + await CacheReleasesAsync(updateDtos); + } + + if (count > 0) + { + return updateDtos.Take(count).ToList(); + } + return updateDtos; } - public async Task GetNumberOfReleasesBehind() + /// + /// Compares 2 versions and ensures that the minor is always there + /// + /// + /// + /// + private static bool IsVersionEqual(string v1, string v2) + { + var versionParts = v1.Split('.'); + if (versionParts.Length < 4) + { + v1 += ".0"; // Append missing parts + } + + versionParts = v2.Split('.'); + if (versionParts.Length < 4) + { + v2 += ".0"; // Append missing parts + } + + return string.Equals(v2, v2, StringComparison.OrdinalIgnoreCase); + } + + private async Task?> TryGetCachedReleases() + { + if (!File.Exists(_cacheFilePath)) return null; + + var fileInfo = new FileInfo(_cacheFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheFilePath); + return JsonSerializer.Deserialize>(cachedData); + } + + return null; + } + + private async Task TryGetCachedLatestRelease() + { + if (!File.Exists(_cacheLatestReleaseFilePath)) return null; + + var fileInfo = new FileInfo(_cacheLatestReleaseFilePath); + if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) + { + var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); + return System.Text.Json.JsonSerializer.Deserialize(cachedData); + } + + return null; + } + + private async Task CacheReleasesAsync(IList updates) + { + try + { + var json = JsonSerializer.Serialize(updates, JsonOptions); + await File.WriteAllTextAsync(_cacheFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache releases"); + } + } + + private async Task CacheLatestReleaseAsync(UpdateNotificationDto update) + { + try + { + var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions); + await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cache latest release"); + } + } + + + + private static bool IsVersionEqualToBuildVersion(Version updateVersion) + { + return updateVersion == BuildInfo.Version || (updateVersion.Revision < 0 && BuildInfo.Version.Revision == 0 && + BuildInfo.Version.CompareWithoutRevision(updateVersion)); + } + + + /// + /// Returns the number of releases ahead of this install version. If this install version is on a nightly, + /// then include nightly releases, otherwise only count Stable releases. + /// + /// Only count Stable releases + /// + public async Task GetNumberOfReleasesBehind(bool stableOnly = false) { var updates = await GetAllReleases(); - return updates.TakeWhile(update => update.UpdateVersion != update.CurrentVersion).Count(); + + // If the user is on nightly, then we need to handle releases behind differently + if (!stableOnly && (updates[0].IsPrerelease || updates[0].IsOnNightlyInRelease)) + { + return updates.Count(u => u.IsReleaseNewer); + } + + return updates + .Where(update => !update.IsPrerelease) + .Count(u => u.IsReleaseNewer); } private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) @@ -109,17 +452,33 @@ public class VersionUpdaterService : IVersionUpdaterService var updateVersion = new Version(update.Tag_Name.Replace("v", string.Empty)); var currentVersion = BuildInfo.Version.ToString(4); + var bodyHtml = _markdown.Transform(update.Body.Trim()); + var parsedSections = ParseReleaseBody(update.Body); + var blogPart = _markdown.Transform(ExtractBlogPart(update.Body).Trim()); + return new UpdateNotificationDto() { CurrentVersion = currentVersion, UpdateVersion = updateVersion.ToString(), - UpdateBody = _markdown.Transform(update.Body.Trim()), + UpdateBody = bodyHtml, UpdateTitle = update.Name, UpdateUrl = update.Html_Url, IsDocker = OsInfo.IsDocker, PublishDate = update.Published_At, - IsReleaseEqual = BuildInfo.Version == updateVersion, + IsReleaseEqual = IsVersionEqualToBuildVersion(updateVersion), IsReleaseNewer = BuildInfo.Version < updateVersion, + IsPrerelease = false, + + Added = parsedSections.TryGetValue("Added", out var added) ? added : [], + Removed = parsedSections.TryGetValue("Removed", out var removed) ? removed : [], + Changed = parsedSections.TryGetValue("Changed", out var changed) ? changed : [], + Fixed = parsedSections.TryGetValue("Fixed", out var fixes) ? fixes : [], + Theme = parsedSections.TryGetValue("Theme", out var theme) ? theme : [], + Developer = parsedSections.TryGetValue("Developer", out var developer) ? developer : [], + KnownIssues = parsedSections.TryGetValue("Known Issues", out var knownIssues) ? knownIssues : [], + Api = parsedSections.TryGetValue("Api", out var api) ? api : [], + FeatureRequests = parsedSections.TryGetValue("Feature Requests", out var frs) ? frs : [], + BlogPart = blogPart }; } @@ -138,6 +497,26 @@ public class VersionUpdaterService : IVersionUpdaterService } } + private async Task GetVersionFromCommit(string commitSha) + { + try + { + // Use the raw GitHub URL format for the csproj file + var content = await $"https://raw.githubusercontent.com/Kareadita/Kavita/{commitSha}/Kavita.Common/Kavita.Common.csproj" + .WithHeader("User-Agent", "Kavita") + .GetStringAsync(); + + var versionMatch = Regex.Match(content, @"([0-9\.]+)"); + return versionMatch.Success ? versionMatch.Groups[1].Value : null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get version from commit {Sha}: {Message}", commitSha, ex.Message); + return null; + } + } + + private static async Task GetGithubRelease() { @@ -149,13 +528,109 @@ public class VersionUpdaterService : IVersionUpdaterService return update; } - private static async Task> GetGithubReleases() + private static async Task> GetGithubReleases() { var update = await GithubAllReleasesUrl .WithHeader("Accept", "application/json") .WithHeader("User-Agent", "Kavita") - .GetJsonAsync>(); + .GetJsonAsync>(); return update; } + + private static string ExtractBlogPart(string body) + { + if (body.StartsWith('#')) return string.Empty; + var match = BlogPartRegex().Match(body); + return match.Success ? match.Groups[1].Value.Trim() : body.Trim(); + } + + private static Dictionary> ParseReleaseBody(string body) + { + var sections = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var lines = body.Split('\n'); + string? currentSection = null; + + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + + // Check for section headers (case-insensitive) + if (trimmedLine.StartsWith('#')) + { + currentSection = trimmedLine.TrimStart('#').Trim(); + sections[currentSection] = []; + continue; + } + + // Parse items under a section + if (currentSection != null && + trimmedLine.StartsWith("- ") && + !string.IsNullOrWhiteSpace(trimmedLine)) + { + // Remove "Fixed:", "Added:" etc. if present + var cleanedItem = CleanSectionItem(trimmedLine); + + // Some sections like API/Developer/Removed don't have the title repeated, so we need to check for an additional cleaning + if (cleanedItem.StartsWith("- ")) + { + cleanedItem = trimmedLine.Substring(2); + } + + // Only add non-empty items + if (!string.IsNullOrWhiteSpace(cleanedItem)) + { + sections[currentSection].Add(cleanedItem); + } + } + } + + return sections; + } + + private static string CleanSectionItem(string item) + { + // Remove everything up to and including the first ":" + var colonIndex = item.IndexOf(':'); + if (colonIndex != -1) + { + item = item.Substring(colonIndex + 1).Trim(); + } + + return item; + } + + private sealed class PullRequestInfo + { + public required string Title { get; init; } + public required string Body { get; init; } + public required string Html_Url { get; init; } + public required string Merged_At { get; init; } + public required int Number { get; init; } + } + + private sealed class CommitInfo + { + public required string Sha { get; init; } + public required CommitDetail Commit { get; init; } + public required string Html_Url { get; init; } + } + + private sealed class CommitDetail + { + public required string Message { get; init; } + public required CommitAuthor Author { get; init; } + } + + private sealed class CommitAuthor + { + public required string Date { get; init; } + } + + private sealed class NightlyInfo + { + public required string Version { get; init; } + public required int PrNumber { get; init; } + public required DateTime Date { get; init; } + } } diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 069b28403..720d97663 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -4,10 +4,12 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Account; using API.Entities; +using API.Helpers; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -24,8 +26,7 @@ public interface ITokenService Task CreateToken(AppUser user); Task ValidateRefreshToken(TokenRequestDto request); Task CreateRefreshToken(AppUser user); - Task GetJwtFromUser(AppUser user); - bool HasTokenExpired(string token); + Task GetJwtFromUser(AppUser user); } @@ -36,6 +37,7 @@ public class TokenService : ITokenService private readonly IUnitOfWork _unitOfWork; private readonly SymmetricSecurityKey _key; private const string RefreshTokenName = "RefreshToken"; + private static readonly SemaphoreSlim _refreshTokenLock = new SemaphoreSlim(1, 1); public TokenService(IConfiguration config, UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { @@ -81,6 +83,8 @@ public class TokenService : ITokenService public async Task ValidateRefreshToken(TokenRequestDto request) { + await _refreshTokenLock.WaitAsync(); + try { var tokenHandler = new JwtSecurityTokenHandler(); @@ -91,6 +95,7 @@ public class TokenService : ITokenService _logger.LogDebug("[RefreshToken] failed to validate due to not finding user in RefreshToken"); return null; } + var user = await _userManager.FindByNameAsync(username); if (user == null) { @@ -98,13 +103,19 @@ public class TokenService : ITokenService return null; } - var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, RefreshTokenName, request.RefreshToken); + var validated = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, + RefreshTokenName, request.RefreshToken); if (!validated && tokenContent.ValidTo <= DateTime.UtcNow.Add(TimeSpan.FromHours(1))) { _logger.LogDebug("[RefreshToken] failed to validate due to invalid refresh token"); return null; } + // Remove the old refresh token first + await _userManager.RemoveAuthenticationTokenAsync(user, + TokenOptions.DefaultProvider, + RefreshTokenName); + try { user.UpdateLastActive(); @@ -121,7 +132,8 @@ public class TokenService : ITokenService Token = await CreateToken(user), RefreshToken = await CreateRefreshToken(user) }; - } catch (SecurityTokenExpiredException ex) + } + catch (SecurityTokenExpiredException ex) { // Handle expired token _logger.LogError(ex, "Failed to validate refresh token"); @@ -133,20 +145,27 @@ public class TokenService : ITokenService _logger.LogError(ex, "Failed to validate refresh token"); return null; } + finally + { + _refreshTokenLock.Release(); + } } - public async Task GetJwtFromUser(AppUser user) + public async Task GetJwtFromUser(AppUser user) { var userClaims = await _userManager.GetClaimsAsync(user); var jwtClaim = userClaims.FirstOrDefault(claim => claim.Type == "jwt"); return jwtClaim?.Value; } - public bool HasTokenExpired(string? token) + public static bool HasTokenExpired(string? token) { - if (string.IsNullOrEmpty(token)) return true; - var tokenHandler = new JwtSecurityTokenHandler(); - var tokenContent = tokenHandler.ReadJwtToken(token); - return tokenContent.ValidTo <= DateTime.UtcNow; + return !JwtHelper.IsTokenValid(token); + } + + + public static DateTime GetTokenExpiry(string? token) + { + return JwtHelper.GetTokenExpiry(token); } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 44767bd8a..de9818b79 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -13,6 +13,7 @@ public static class MessageFactoryEntityTypes public const string Chapter = "chapter"; public const string CollectionTag = "collection"; public const string ReadingList = "readingList"; + public const string Person = "person"; } public static class MessageFactory { @@ -41,9 +42,9 @@ public static class MessageFactory /// public const string OnlineUsers = "OnlineUsers"; /// - /// When a series is added to a collection + /// When a Collection has been updated /// - public const string SeriesAddedToCollection = "SeriesAddedToCollection"; + public const string CollectionUpdated = "CollectionUpdated"; /// /// Event sent out during backing up the database /// @@ -130,6 +131,22 @@ public static class MessageFactory /// Order, Visibility, etc has changed on the Sidenav. UI will refresh the layout /// public const string SideNavUpdate = "SideNavUpdate"; + /// + /// A Theme was updated and UI should refresh to get the latest version + /// + public const string SiteThemeUpdated = "SiteThemeUpdated"; + /// + /// A Progress event when a smart collection is synchronizing + /// + public const string SmartCollectionSync = "SmartCollectionSync"; + /// + /// Chapter is removed from server + /// + public const string ChapterRemoved = "ChapterRemoved"; + /// + /// Volume is removed from server + /// + public const string VolumeRemoved = "VolumeRemoved"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -205,6 +222,32 @@ public static class MessageFactory }; } + public static SignalRMessage ChapterRemovedEvent(int chapterId, int seriesId) + { + return new SignalRMessage() + { + Name = ChapterRemoved, + Body = new + { + SeriesId = seriesId, + ChapterId = chapterId + } + }; + } + + public static SignalRMessage VolumeRemovedEvent(int volumeId, int seriesId) + { + return new SignalRMessage() + { + Name = VolumeRemoved, + Body = new + { + SeriesId = seriesId, + VolumeId = volumeId + } + }; + } + public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") { @@ -310,17 +353,17 @@ public static class MessageFactory }; } - public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId) + + public static SignalRMessage CollectionUpdatedEvent(int collectionId) { return new SignalRMessage { - Name = SeriesAddedToCollection, + Name = CollectionUpdated, Progress = ProgressType.None, EventType = ProgressEventType.Single, Body = new { - TagId = tagId, - SeriesId = seriesId + TagId = collectionId, } }; } @@ -337,6 +380,7 @@ public static class MessageFactory EventType = ProgressEventType.Single, Body = new { + Name = Error, Title = title, SubTitle = subtitle, } @@ -354,6 +398,7 @@ public static class MessageFactory EventType = ProgressEventType.Single, Body = new { + Name = Info, Title = title, SubTitle = subtitle, } @@ -421,6 +466,31 @@ public static class MessageFactory }; } + /// + /// Represents a file being scanned by Kavita for processing and grouping + /// + /// Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate + /// + /// + /// + /// + public static SignalRMessage SmartCollectionProgressEvent(string collectionName, string seriesName, int currentItems, int totalItems, string eventType) + { + return new SignalRMessage() + { + Name = SmartCollectionSync, + Title = $"Synchronizing {collectionName}", + SubTitle = seriesName, + EventType = eventType, + Progress = ProgressType.Determinate, + Body = new + { + Progress = float.Min((currentItems / (totalItems * 1.0f)), 100f), + EventTime = DateTime.Now + } + }; + } + /// /// This informs the UI with details about what is being processed by the Scanner /// @@ -428,7 +498,7 @@ public static class MessageFactory /// /// /// - public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "") + public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "", int? totalToProcess = null) { return new SignalRMessage() { @@ -437,7 +507,12 @@ public static class MessageFactory SubTitle = seriesName, EventType = eventType, Progress = ProgressType.Indeterminate, - Body = null + Body = new + { + SeriesName = seriesName, + LibraryName = libraryName, + LeftToProcess = totalToProcess + } }; } @@ -480,7 +555,7 @@ public static class MessageFactory return new SignalRMessage() { Name = SiteThemeProgress, - Title = "Scanning Site Theme", + Title = "Processing Site Theme", // TODO: Localize SignalRMessage titles SubTitle = subtitle, EventType = eventType, Progress = ProgressType.Indeterminate, @@ -491,6 +566,25 @@ public static class MessageFactory }; } + /// + /// Sends an event to the UI informing of a SiteTheme update and UI needs to refresh the content + /// + /// + /// + public static SignalRMessage SiteThemeUpdatedEvent(string themeName) + { + return new SignalRMessage() + { + Name = SiteThemeUpdated, + Title = "SiteTheme Update", + Progress = ProgressType.None, + Body = new + { + ThemeName = themeName, + } + }; + } + public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType) { return new SignalRMessage() diff --git a/API/Startup.cs b/API/Startup.cs index 3b872f396..188c2b2dd 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -41,6 +41,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; using Serilog; +using Swashbuckle.AspNetCore.SwaggerGen; using TaskScheduler = API.Services.TaskScheduler; namespace API; @@ -137,14 +138,14 @@ public class Startup { c.SwaggerDoc("v1", new OpenApiInfo { - Version = BuildInfo.Version.ToString(), - Title = "Kavita", - Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", + Version = "3.1.0", + Title = $"Kavita (v{BuildInfo.Version})", + Description = $"Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v{BuildInfo.Version}", License = new OpenApiLicense { Name = "GPL-3.0", Url = new Uri("https://github.com/Kareadita/Kavita/blob/develop/LICENSE") - } + }, }); var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; @@ -176,7 +177,7 @@ public class Startup Url = "{protocol}://{hostpath}", Variables = new Dictionary { - { "protocol", new OpenApiServerVariable { Default = "http", Enum = new List { "http", "https" } } }, + { "protocol", new OpenApiServerVariable { Default = "http", Enum = ["http", "https"]} }, { "hostpath", new OpenApiServerVariable { Default = "localhost:5000" } } } }); @@ -207,7 +208,7 @@ public class Startup .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UseInMemoryStorage()); - //.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) + //.UseSQLiteStorage("config/Hangfire.db")); // UseSQLiteStorage - SQLite has some issues around resuming jobs when aborted (and locking can cause high utilization) (NOTE: There is code to clear jobs on startup a redditor gave me) // Add the processing server as IHostedService services.AddHangfireServer(options => @@ -247,11 +248,47 @@ public class Startup // v0.7.14 await MigrateEmailTemplates.Migrate(directoryService, logger); - await MigrateVolumeNumber.Migrate(unitOfWork, dataContext, logger); - await MigrateWantToReadImport.Migrate(unitOfWork, directoryService, logger); + await MigrateVolumeNumber.Migrate(dataContext, logger); + await MigrateWantToReadImport.Migrate(unitOfWork, dataContext, directoryService, logger); await MigrateManualHistory.Migrate(dataContext, logger); await MigrateClearNightlyExternalSeriesRecords.Migrate(dataContext, logger); + // v0.8.0 + await MigrateVolumeLookupName.Migrate(dataContext, unitOfWork, logger); + await MigrateChapterNumber.Migrate(dataContext, 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); + + // v0.8.1 + await MigrateLowestSeriesFolderPath.Migrate(dataContext, unitOfWork, logger); + + // v0.8.2 + await ManualMigrateThemeDescription.Migrate(dataContext, logger); + await MigrateInitialInstallData.Migrate(dataContext, logger, directoryService); + await MigrateSeriesLowestFolderPath.Migrate(dataContext, logger, directoryService); + + // v0.8.4 + await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger); + await ManualMigrateRemovePeople.Migrate(dataContext, logger); + await MigrateDuplicateDarkTheme.Migrate(dataContext, logger); + await ManualMigrateUnscrobbleBookLibraries.Migrate(dataContext, logger); + + // v0.8.5 + await ManualMigrateBlacklistTableToSeries.Migrate(dataContext, logger); + await ManualMigrateInvalidBlacklistSeries.Migrate(dataContext, logger); + await ManualMigrateScrobbleErrors.Migrate(dataContext, logger); + await ManualMigrateNeedsManualMatch.Migrate(dataContext, logger); + await MigrateProgressExportForV085.Migrate(dataContext, directoryService, logger); + + // v0.8.6 + await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); + await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); @@ -341,7 +378,14 @@ public class Startup app.UseStaticFiles(new StaticFileOptions { - ContentTypeProvider = new FileExtensionContentTypeProvider(), + // bcmap files needed for PDF reader localizations (https://github.com/Kareadita/Kavita/issues/2970) + ContentTypeProvider = new FileExtensionContentTypeProvider + { + Mappings = + { + [".bcmap"] = "application/octet-stream" + } + }, HttpsCompression = HttpsCompressionMode.Compress, OnPrepareResponse = ctx => { @@ -384,7 +428,10 @@ public class Startup endpoints.MapControllers(); endpoints.MapHub("hubs/messages"); endpoints.MapHub("hubs/logs"); - endpoints.MapHangfireDashboard(); + if (env.IsDevelopment()) + { + endpoints.MapHangfireDashboard(); + } endpoints.MapFallbackToController("Index", "Fallback"); }); @@ -398,8 +445,8 @@ public class Startup catch (Exception) { /* Swallow Exception */ + Console.WriteLine($"Kavita - v{BuildInfo.Version}"); } - Console.WriteLine($"Kavita - v{BuildInfo.Version}"); }); logger.LogInformation("Starting with base url as {BaseUrl}", basePath); @@ -419,9 +466,7 @@ public class Startup } catch (Exception ex) { - if ((ex.Message.Contains("Permission denied") - || ex.Message.Contains("UnauthorizedAccessException")) - && baseUrl.Equals(Configuration.DefaultBaseUrl) && OsInfo.IsDocker) + if (ex is UnauthorizedAccessException && baseUrl.Equals(Configuration.DefaultBaseUrl) && OsInfo.IsDocker) { // Swallow the exception as the install is non-root and Docker return; diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index a72749400..ad2d89fa5 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": "", "BaseUrl": "/", - "Cache": 90, + "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/API/config/appsettings.json b/API/config/appsettings.json index 3eeee1c18..c77ff6a30 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -3,5 +3,5 @@ "Port": 5000, "IpAddresses": "", "BaseUrl": "/", - "Cache": 50 + "Cache": 75 } 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 @@ + + + + + + + + + + + + + + + + + + +
+ +
{{Preheader}}
+ + + +
+ + + diff --git a/API/config/templates/TokenExpiringSoon.html b/API/config/templates/TokenExpiringSoon.html new file mode 100644 index 000000000..eac990260 --- /dev/null +++ b/API/config/templates/TokenExpiringSoon.html @@ -0,0 +1,344 @@ + + + + + + + + + + + + + Kavita - [Plain HTML] + + + + + + + + + + + + + + + + + + + + + + +
+ +
{{Preheader}}
+ + + +
+ + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 315c3b170..292217862 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,32 +3,36 @@ We're always looking for people to help make Kavita even better, there are a number of ways to contribute. ## Documentation ## -Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavitareader.com/) the better. +Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavitareader.com/contributing) the better. ## Development ## ### Tools required ### - Visual Studio 2019 or higher (https://www.visualstudio.com/vs/). The community version is free and works fine. [Download it here](https://www.visualstudio.com/downloads/). -- Rider (optional to Visual Studio) (https://www.jetbrains.com/rider/) +- Rider (optional to Visual Studio, preferred editor) (https://www.jetbrains.com/rider/) - HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) - [Git](https://git-scm.com/downloads) - [NodeJS](https://nodejs.org/en/download/) (Node 18.13.X or higher) -- .NET 7.0+ -- dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli +- .NET 9.0+ +- dotnet tool install -g Swashbuckle.AspNetCore.Cli ### Getting started ### 1. Fork Kavita 2. Clone the repository into your development machine. [*info*](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository-from-github) 3. Install the required Node Packages - - cd Kavita/UI/Web + - `cd Kavita/UI/Web` - `npm install` - `npm install -g @angular/cli` -4. Start angular server `ng serve` -5. Build the project in Visual Studio/Rider, Setting startup project to `API` -6. Debug the project in Visual Studio/Rider -7. Open http://localhost:4200 -8. (Deployment only) Run build.sh and pass the Runtime Identifier for your OS or just build.sh for all supported RIDs. +5. Start the frontend + - `npm run start` +6. Build the project in Visual Studio/Rider, Setting startup project to `API` +7. Debug the project in Visual Studio/Rider +8. Open http://localhost:4200 +9. (Deployment only) Run build.sh and pass the Runtime Identifier for your OS or just build.sh for all supported RIDs. + +### Debugging on Device ### +- Update `IP` constant in `Web/UI/src/environments/environment.ts` to your dev machine's ip instead of `localhost`. ### Contributing Code ### @@ -61,4 +65,7 @@ If you just want to play with Swagger, you can just - dotnet run -c Debug - Go to http://localhost:5000/swagger/index.html +If you have a build issue around swagger run: +` swagger tofile --output ../openapi.json API/bin/Debug/net8.0/API.dll v1` to see the error and correct it + If you have any questions about any of this, please let us know. diff --git a/Dockerfile b/Dockerfile index 6d52acaba..bfc253c0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN mkdir /files COPY _output/*.tar.gz /files/ COPY UI/Web/dist/browser /files/wwwroot COPY copy_runtime.sh /copy_runtime.sh + +RUN chmod +x /copy_runtime.sh RUN /copy_runtime.sh RUN chmod +x /Kavita/Kavita @@ -34,6 +36,7 @@ WORKDIR /kavita HEALTHCHECK --interval=30s --timeout=15s --start-period=30s --retries=3 CMD curl -fsS http://localhost:5000/api/health || exit 1 +# Enable detection of running in a container ENV DOTNET_RUNNING_IN_CONTAINER=true ENTRYPOINT [ "/bin/bash" ] diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index ca0fc40ec..f2d64cde6 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -16,7 +16,9 @@ public static class Configuration public const long DefaultCacheMemory = 75; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); - public static string KavitaPlusApiUrl = "https://plus.kavitareader.com"; + public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development + ? "http://localhost:5020" : "https://plus.kavitareader.com"; + public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port { @@ -314,6 +316,7 @@ public static class Configuration { public string TokenKey { get; set; } // ReSharper disable once MemberHidesStaticFromOuterClass +#pragma warning disable S3218 public int Port { get; set; } = DefaultHttpPort; // ReSharper disable once MemberHidesStaticFromOuterClass public string IpAddresses { get; set; } = string.Empty; @@ -322,6 +325,7 @@ public static class Configuration // ReSharper disable once MemberHidesStaticFromOuterClass public long Cache { get; set; } = DefaultCacheMemory; // ReSharper disable once MemberHidesStaticFromOuterClass - public bool AllowIFraming { get; set; } = false; + public bool AllowIFraming { get; init; } = false; +#pragma warning restore S3218 } } diff --git a/Kavita.Common/Helpers/CronHelper.cs b/Kavita.Common/Helpers/CronHelper.cs index 77a4e934e..0b40113ce 100644 --- a/Kavita.Common/Helpers/CronHelper.cs +++ b/Kavita.Common/Helpers/CronHelper.cs @@ -13,7 +13,7 @@ public static class CronHelper CronExpression.Parse(cronExpression); return true; } - catch (Exception ex) + catch (Exception) { /* Swallow */ return false; diff --git a/Kavita.Common/Helpers/FlurlConfiguration.cs b/Kavita.Common/Helpers/FlurlConfiguration.cs new file mode 100644 index 000000000..b80dff8d9 --- /dev/null +++ b/Kavita.Common/Helpers/FlurlConfiguration.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Flurl.Http; + +namespace Kavita.Common.Helpers; + +/// +/// Helper class for configuring Flurl client for a specific URL. +/// +public static class FlurlConfiguration +{ + private static readonly List ConfiguredClients = new List(); + private static readonly Lock Lock = new Lock(); + + /// + /// Configures the Flurl client for the specified URL. + /// + /// The URL to configure the client for. + public static void ConfigureClientForUrl(string url) + { + //Important client are mapped without path, per example two urls pointing to the same host:port but different path, will use the same client. + lock (Lock) + { + var ur = new Uri(url); + //key is host:port + var host = ur.Host + ":" + ur.Port; + if (ConfiguredClients.Contains(host)) return; + + FlurlHttp.ConfigureClientForUrl(url).ConfigureInnerHandler(cli => +#pragma warning disable S4830 + cli.ServerCertificateCustomValidationCallback = (_, _, _, _) => true); +#pragma warning restore S4830 + + ConfiguredClients.Add(host); + } + } +} diff --git a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs b/Kavita.Common/Helpers/UntrustedCertClientFactory.cs deleted file mode 100644 index 6ddb2a9f3..000000000 --- a/Kavita.Common/Helpers/UntrustedCertClientFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Net.Http; -using Flurl.Http.Configuration; - -namespace Kavita.Common.Helpers; - -public class UntrustedCertClientFactory : DefaultHttpClientFactory -{ - public override HttpMessageHandler CreateMessageHandler() { - return new HttpClientHandler { - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - } -} diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 3cba430c8..265de4a23 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -1,24 +1,23 @@ - - net8.0 + net9.0 kavitareader.com Kavita - 0.7.14.2 + 0.8.6.5 en true - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/Kavita.Common/RateLimitException.cs b/Kavita.Common/RateLimitException.cs new file mode 100644 index 000000000..ecc0730f7 --- /dev/null +++ b/Kavita.Common/RateLimitException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Kavita.Common; + +/// +/// When a rate limit is hit +/// +public class RateLimitException : Exception +{ + public RateLimitException() + { } + + public RateLimitException(string message) : base(message) + { } + + public RateLimitException(string message, Exception inner) + : base(message, inner) { } +} diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings index 92adaa72f..b46c328cd 100644 --- a/Kavita.sln.DotSettings +++ b/Kavita.sln.DotSettings @@ -2,9 +2,13 @@ ExplicitlyExcluded True True + True + True + True True True True + True True True True diff --git a/README.md b/README.md index 46f6dfbb0..bff8f0f5c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # []() Kavita
-![new_github_preview_stills](https://user-images.githubusercontent.com/735851/169657008-37812c18-5490-4e2a-9dcb-4806f8c87c69.gif) +![new_github_preview_stills](https://github.com/user-attachments/assets/f016b34f-3c4c-4f07-8e72-12cd6f4e71ea) -Kavita is a fast, feature rich, cross platform reading server. Built with a focus for being a full solution for all your reading needs. Setup your own server and share +Kavita is a fast, feature rich, cross-platform reading server. Built with a focus for being a full solution for all your reading needs. Set up your own server and share your reading collection with your friends and family! [![Release](https://img.shields.io/github/release/Kareadita/Kavita.svg?style=flat&maxAge=3600)](https://github.com/Kareadita/Kavita/releases) @@ -24,14 +24,15 @@ your reading collection with your friends and family! ## What Kavita Provides - Serve up Manga/Webtoons/Comics (cbr, cbz, zip/rar/rar5, 7zip, raw images) and Books (epub, pdf) - First class responsive readers that work great on any device (phone, tablet, desktop) -- Dark mode and customizable theming support -- External metadata integration and scrobbling for read status, ratings, and reviews (available via Kavita+) +- Customizable theming support: [Theme Repo](https://github.com/Kareadita/Themes) and [Documentation](https://wiki.kavitareader.com/guides/themes) +- External metadata integration and scrobbling for read status, ratings, and reviews (available via [Kavita+](https://wiki.kavitareader.com/kavita+)) - Rich Metadata support with filtering and searching - Ways to group reading material: Collections, Reading Lists (CBL Import), Want to Read - Ability to manage users with rich Role-based management for age restrictions, abilities within the app, etc - Rich web readers supporting webtoon, continuous reading mode (continue without leaving the reader), virtual pages (epub), etc +- Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles - Full Localization Support -- Ability to customize your dashboard and side nav with smart filters, custom order and visibility toggles. +- Ability to download metadata (available via [Kavita+](https://wiki.kavitareader.com/kavita+)) ## Support @@ -40,7 +41,7 @@ your reading collection with your friends and family! ## Demo If you want to try out Kavita, a demo is available: -[https://demo.kavitareader.com/](https://demo.kavitareader.com/) +[https://demo.kavitareader.com/](https://demo.kavitareader.com/login?apiKey=9003cf99-9213-4206-a787-af2fe4cc5f1f) ``` Username: demouser Password: Demouser64 @@ -49,10 +50,10 @@ Password: Demouser64 ## Setup The easiest way to get started is to visit our Wiki which has up-to-date information on a variety of install methods and platforms. -[https://wiki.kavitareader.com/en/install](https://wiki.kavitareader.com/en/install) +[https://wiki.kavitareader.com/getting-started](https://wiki.kavitareader.com/getting-started) ## Feature Requests -Got a great idea? Throw it up on [Discussions](https://github.com/Kareadita/Kavita/discussions/2529) or vote on another idea. Please check the [Project Board](https://github.com/Kareadita/Kavita/projects?type=classic) first for a list of planned features before you submit an idea. +Got a great idea? Throw it up on [Discussions](https://github.com/Kareadita/Kavita/discussions/2529) or vote on another idea. Many great features in Kavita are driven by our community. ## Notice Kavita is being actively developed and should be considered beta software until the 1.0 release. @@ -62,17 +63,17 @@ vision. You may lose data and have to restart. The Kavita team strives to avoid ## Donate If you like Kavita, have gotten good use out of it, or feel like you want to say thanks with a few bucks, feel free to donate. Money will go towards expenses related to Kavita. Back us through [OpenCollective](https://opencollective.com/Kavita#backer). You can also use [Paypal](https://www.paypal.com/paypalme/majora2007?locale.x=en_US), however your name will not show below. Kavita+ is also an -option which provides funding and you get a benefit. +option which provides funding, and you get a benefit. ## Kavita+ -[Kavita+](https://wiki.kavitareader.com/en/kavita-plus) is a paid subscription that offers premium features that otherwise wouldn't be feasible to include in Kavita. It is ran and operated by majora2007, the creator and developer of Kavita. +[Kavita+](https://wiki.kavitareader.com/kavita+) is a paid subscription that offers premium features that otherwise wouldn't be feasible to include in Kavita. It is ran and operated by majora2007, the creator and developer of Kavita. If you are interested, you can use the promo code `FIRSTTIME` for your initial signup for a 50% discount on the first month (2$). This can be thought of as donating to Kavita's development and getting some sweet features out of it. **If you already contribute via OpenCollective, please reach out to majora2007 for a provisioned license.** ## Localization -Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro-bono. If you want to see Kavita in your language, please help us localize. +Thank you to [Weblate](https://hosted.weblate.org/engage/kavita/) who hosts our localization infrastructure pro bono. If you want to see Kavita in your language, please help us localize. Translation status diff --git a/TestData b/TestData deleted file mode 160000 index 4f5750025..000000000 --- a/TestData +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4f5750025a1c0b48cd72eaa6f1b61642c41f147f diff --git a/UI/Web/.editorconfig b/UI/Web/.editorconfig index 2c6908b84..28045b9af 100644 --- a/UI/Web/.editorconfig +++ b/UI/Web/.editorconfig @@ -8,6 +8,12 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +[*.json] +indent_size = 2 + +[en.json] +indent_size = 4 + [*.html] indent_size = 2 diff --git a/UI/Web/.gitignore b/UI/Web/.gitignore index 73b400165..8132126c9 100644 --- a/UI/Web/.gitignore +++ b/UI/Web/.gitignore @@ -2,3 +2,4 @@ node_modules/ test-results/ playwright-report/ i18n-cache-busting.json +e2e-tests/environments/environment.local.ts diff --git a/UI/Web/README.md b/UI/Web/README.md index 74919b78b..4efc47cbc 100644 --- a/UI/Web/README.md +++ b/UI/Web/README.md @@ -4,7 +4,7 @@ This project was generated with [Angular CLI](https://github.com/angular/angular ## Development server -Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. +Run `npm run start` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. Your backend must be served on port 5000. ## Code scaffolding @@ -25,11 +25,15 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github. Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e tests. -## Connecting to your dev server via your phone +## Connecting to your dev server via your phone or any other compatible client on local network -ng serve --host 0.0.0.0 -and update environment.ts to your local ip. +Update `IP` constant in `src/environments/environment.ts` to your dev machine's ip instead of `localhost`. + +Run `npm run start` ## Notes: - injected services should be at the top of the file - all components must be standalone + +# Update latest angular +`ng update @angular/core @angular/cli @typescript-eslint/parser @angular/localize @angular/compiler-cli @angular-devkit/build-angular @angular/cdk` diff --git a/UI/Web/angular.json b/UI/Web/angular.json index 60fc909a9..1ce56fa2e 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -24,13 +24,14 @@ "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:application", + "builder": "@angular/build:application", "options": { "outputPath": "dist", "index": "src/index.html", "browser": "src/main.ts", "polyfills": [ - "zone.js" + "@angular/localize/init", + "zone.js" ], "inlineStyleLanguage": "scss", "tsConfig": "tsconfig.app.json", @@ -55,7 +56,12 @@ }, "extractLicenses": false, "optimization": false, - "namedChunks": true + "namedChunks": true, + "stylePreprocessorOptions": { + "sass": { + "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"] + } + } }, "configurations": { "production": { @@ -87,7 +93,7 @@ "defaultConfiguration": "" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", + "builder": "@angular/build:dev-server", "options": { "sslKey": "./ssl/server.key", "sslCert": "./ssl/server.crt", @@ -101,7 +107,7 @@ } }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", + "builder": "@angular/build:extract-i18n", "options": { "buildTarget": "kavita-webui:build" } diff --git a/UI/Web/hash-localization.js b/UI/Web/hash-localization.js index 547b5af0d..542ae5127 100644 --- a/UI/Web/hash-localization.js +++ b/UI/Web/hash-localization.js @@ -14,6 +14,15 @@ function generateChecksum(str, algorithm, encoding) { const result = {}; +// Generate directory if it doesn't exist +const distFolderPath = './dist/'; +const browserFolderPath = './dist/browser/'; +if (!fs.existsSync(browserFolderPath)) { + console.log('Creating ./dist/browser folder'); + fs.mkdirSync(distFolderPath, 0o744); + fs.mkdirSync(browserFolderPath, 0o744); +} + // Remove file if it exists const cacheBustingFilePath = './i18n-cache-busting.json'; if (fs.existsSync(cacheBustingFilePath)) { diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index fa6b9d010..cfce8cded 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -8,71 +8,71 @@ "name": "kavita-webui", "version": "0.7.12.1", "dependencies": { - "@angular/animations": "^17.1.0", - "@angular/cdk": "^17.1.0", - "@angular/common": "^17.1.0", - "@angular/compiler": "^17.1.0", - "@angular/core": "^17.1.0", - "@angular/forms": "^17.1.0", - "@angular/localize": "^17.1.0", - "@angular/platform-browser": "^17.1.0", - "@angular/platform-browser-dynamic": "^17.1.0", - "@angular/router": "^17.1.0", - "@fortawesome/fontawesome-free": "^6.5.1", - "@iharbeck/ngx-virtual-scroller": "^17.0.0", - "@iplab/ngx-file-upload": "^17.0.0", - "@microsoft/signalr": "^7.0.12", - "@ng-bootstrap/ng-bootstrap": "^16.0.0", - "@ngneat/transloco": "^6.0.4", - "@ngneat/transloco-locale": "^5.1.1", - "@ngneat/transloco-persist-lang": "^5.0.0", - "@ngneat/transloco-persist-translations": "^5.0.0", - "@ngneat/transloco-preload-langs": "^5.0.1", + "@angular-slider/ngx-slider": "^19.0.0", + "@angular/animations": "^19.2.5", + "@angular/cdk": "^19.2.8", + "@angular/common": "^19.2.5", + "@angular/compiler": "^19.2.5", + "@angular/core": "^19.2.5", + "@angular/forms": "^19.2.5", + "@angular/localize": "^19.2.5", + "@angular/platform-browser": "^19.2.5", + "@angular/platform-browser-dynamic": "^19.2.5", + "@angular/router": "^19.2.5", + "@fortawesome/fontawesome-free": "^6.7.2", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@iplab/ngx-file-upload": "^19.0.3", + "@jsverse/transloco": "^7.6.1", + "@jsverse/transloco-locale": "^7.0.1", + "@jsverse/transloco-persist-lang": "^7.0.2", + "@jsverse/transloco-persist-translations": "^7.0.1", + "@jsverse/transloco-preload-langs": "^7.0.1", + "@microsoft/signalr": "^8.0.7", + "@ng-bootstrap/ng-bootstrap": "^18.0.0", "@popperjs/core": "^2.11.7", - "@swimlane/ngx-charts": "^20.5.0", - "@tweenjs/tween.js": "^21.0.0", + "@siemens/ngx-datatable": "^22.4.1", + "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", - "luxon": "^3.4.4", + "luxon": "^3.6.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^14.0.0", - "ngx-color-picker": "^16.0.0", - "ngx-extended-pdf-viewer": "^18.1.9", + "ng-select2-component": "^17.2.4", + "ngx-color-picker": "^19.0.0", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", - "ngx-slider-v2": "^17.0.0", "ngx-stars": "^1.6.5", - "ngx-toaster": "^1.0.1", - "ngx-toastr": "^18.0.0", + "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", - "rxjs": "^7.8.0", + "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^8.4.6", - "tslib": "^2.6.2", - "zone.js": "^0.14.2" + "tslib": "^2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^17.1.0", - "@angular-eslint/builder": "^17.2.1", - "@angular-eslint/eslint-plugin": "^17.2.1", - "@angular-eslint/eslint-plugin-template": "^17.2.1", - "@angular-eslint/schematics": "^17.2.1", - "@angular-eslint/template-parser": "^17.2.1", - "@angular/cli": "^17.1.0", - "@angular/compiler-cli": "^17.1.0", + "@angular-eslint/builder": "^19.3.0", + "@angular-eslint/eslint-plugin": "^19.3.0", + "@angular-eslint/eslint-plugin-template": "^19.3.0", + "@angular-eslint/schematics": "^19.3.0", + "@angular-eslint/template-parser": "^19.3.0", + "@angular/build": "^19.2.6", + "@angular/cli": "^19.2.6", + "@angular/compiler-cli": "^19.2.5", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", - "@types/luxon": "^3.4.0", - "@types/node": "^20.10.0", - "@typescript-eslint/eslint-plugin": "^6.13.0", - "@typescript-eslint/parser": "^6.19.0", - "eslint": "^8.54.0", + "@types/luxon": "^3.6.2", + "@types/node": "^22.13.13", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", - "typescript": "^5.2.2", - "webpack-bundle-analyzer": "^4.10.1" + "typescript": "^5.5.4", + "webpack-bundle-analyzer": "^4.10.2" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -85,125 +85,293 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@angular-devkit/architect": { - "version": "0.1701.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1701.0.tgz", - "integrity": "sha512-VP6mjptKFn0HO2dn4bH0mFMe4CrexlWlgnTHyAUbL7ZFaV9w4VQuE/vXr60wMlQ+83NIGUeJImjt1QVNlIjJnQ==", + "version": "0.1902.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.6.tgz", + "integrity": "sha512-Dx6yPxpaE5AhP6UtrVRDCc9Ihq9B65LAbmIh3dNOyeehratuaQS0TYNKjbpaevevJojW840DTg80N+CrlfYp9g==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.1.0", + "@angular-devkit/core": "19.2.6", "rxjs": "7.8.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, - "node_modules/@angular-devkit/build-angular": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.1.0.tgz", - "integrity": "sha512-N9B2SlKewD48qKFgRPKDH1X2EvOGll1ocMlFxi95mT9aXuFd2d75JUYHzS1v3FQRU3peoAoFKxCV7OuIL/cmTA==", + "node_modules/@angular-devkit/architect/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "dev": true, "dependencies": { - "@ampproject/remapping": "2.2.1", - "@angular-devkit/architect": "0.1701.0", - "@angular-devkit/build-webpack": "0.1701.0", - "@angular-devkit/core": "17.1.0", - "@babel/core": "7.23.7", - "@babel/generator": "7.23.6", - "@babel/helper-annotate-as-pure": "7.22.5", - "@babel/helper-split-export-declaration": "7.22.6", - "@babel/plugin-transform-async-generator-functions": "7.23.7", - "@babel/plugin-transform-async-to-generator": "7.23.3", - "@babel/plugin-transform-runtime": "7.23.7", - "@babel/preset-env": "7.23.7", - "@babel/runtime": "7.23.7", - "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.1.0", - "@vitejs/plugin-basic-ssl": "1.0.2", - "ansi-colors": "4.1.3", - "autoprefixer": "10.4.16", - "babel-loader": "9.1.3", - "babel-plugin-istanbul": "6.1.1", - "browserslist": "^4.21.5", - "copy-webpack-plugin": "11.0.0", - "critters": "0.0.20", - "css-loader": "6.8.1", - "esbuild-wasm": "0.19.11", - "fast-glob": "3.3.2", - "http-proxy-middleware": "2.0.6", - "https-proxy-agent": "7.0.2", - "inquirer": "9.2.12", - "jsonc-parser": "3.2.0", - "karma-source-map-support": "1.4.0", - "less": "4.2.0", - "less-loader": "11.1.0", - "license-webpack-plugin": "4.0.2", - "loader-utils": "3.2.1", - "magic-string": "0.30.5", - "mini-css-extract-plugin": "2.7.6", - "mrmime": "2.0.0", - "open": "8.4.2", - "ora": "5.4.1", - "parse5-html-rewriting-stream": "7.0.0", - "picomatch": "3.0.1", - "piscina": "4.2.1", - "postcss": "8.4.33", - "postcss-loader": "7.3.4", - "resolve-url-loader": "5.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.6.tgz", + "integrity": "sha512-WFgiYhrDMq83UNaGRAneIM7CYYdBozD+yYA9BjoU8AgBLKtrvn6S8ZcjKAk5heoHtY/u8pEb0mwDTz9gxFmJZQ==", + "dev": true, + "dependencies": { + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.2", "rxjs": "7.8.1", - "sass": "1.69.7", - "sass-loader": "13.3.3", - "semver": "7.5.4", - "source-map-loader": "5.0.0", - "source-map-support": "0.5.21", - "terser": "5.26.0", - "text-table": "0.2.0", - "tree-kill": "1.2.2", - "tslib": "2.6.2", - "undici": "6.2.1", - "vite": "5.0.11", - "watchpack": "2.4.0", - "webpack": "5.89.0", - "webpack-dev-middleware": "6.1.1", - "webpack-dev-server": "4.15.1", - "webpack-merge": "5.10.0", - "webpack-subresource-integrity": "5.1.0" + "source-map": "0.7.4" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.6.tgz", + "integrity": "sha512-YTAxNnT++5eflx19OUHmOWu597/TbTel+QARiZCv1xQw99+X8DCKKOUXtqBRd53CAHlREDI33Rn/JLY3NYgMLQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": "19.2.6", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-eslint/builder": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-19.3.0.tgz", + "integrity": "sha512-j9xNrzZJq29ONSG6EaeQHve0Squkm6u6Dm8fZgWP7crTFOrtLXn7Wxgxuyl9eddpbWY1Ov1gjFuwBVnxIdyAqg==", + "dev": true, + "dependencies": { + "@angular-devkit/architect": ">= 0.1900.0 < 0.2000.0", + "@angular-devkit/core": ">= 19.0.0 < 20.0.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/bundled-angular-compiler": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-19.3.0.tgz", + "integrity": "sha512-63Zci4pvnUR1iSkikFlNbShF1tO5HOarYd8fvNfmOZwFfZ/1T3j3bCy9YbE+aM5SYrWqPaPP/OcwZ3wJ8WNvqA==", + "dev": true + }, + "node_modules/@angular-eslint/eslint-plugin": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-19.3.0.tgz", + "integrity": "sha512-nBLslLI20KnVbqlfNW7GcnI9R6cYCvRGjOE2QYhzxM316ciAQ62tvQuXP9ZVnRBLSKDAVnMeC0eTq9O4ysrxrQ==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/utils": "19.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/eslint-plugin-template": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-19.3.0.tgz", + "integrity": "sha512-WyouppTpOYut+wvv13wlqqZ8EHoDrCZxNfGKuEUYK1BPmQlTB8EIZfQH4iR1rFVS28Rw+XRIiXo1x3oC0SOfnA==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "@angular-eslint/utils": "19.3.0", + "aria-query": "5.3.2", + "axobject-query": "4.1.0" + }, + "peerDependencies": { + "@typescript-eslint/types": "^7.11.0 || ^8.0.0", + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/schematics": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-19.3.0.tgz", + "integrity": "sha512-Wl5sFQ4t84LUb8mJ2iVfhYFhtF55IugXu7rRhPHtgIu9Ty5s1v3HGUx4LKv51m2kWhPPeFOTmjeBv1APzFlmnQ==", + "dev": true, + "dependencies": { + "@angular-devkit/core": ">= 19.0.0 < 20.0.0", + "@angular-devkit/schematics": ">= 19.0.0 < 20.0.0", + "@angular-eslint/eslint-plugin": "19.3.0", + "@angular-eslint/eslint-plugin-template": "19.3.0", + "ignore": "7.0.3", + "semver": "7.7.1", + "strip-json-comments": "3.1.1" + } + }, + "node_modules/@angular-eslint/schematics/node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@angular-eslint/template-parser": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-19.3.0.tgz", + "integrity": "sha512-VxMNgsHXMWbbmZeBuBX5i8pzsSSEaoACVpaE+j8Muk60Am4Mxc0PytJm4n3znBSvI3B7Kq2+vStSRYPkOER4lA==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0", + "eslint-scope": "^8.0.2" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-eslint/utils": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-19.3.0.tgz", + "integrity": "sha512-ovvbQh96FIJfepHqLCMdKFkPXr3EbcvYc9kMj9hZyIxs/9/VxwPH7x25mMs4VsL6rXVgH2FgG5kR38UZlcTNNw==", + "dev": true, + "dependencies": { + "@angular-eslint/bundled-angular-compiler": "19.3.0" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^7.11.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": "*" + } + }, + "node_modules/@angular-slider/ngx-slider": { + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/@angular-slider/ngx-slider/-/ngx-slider-19.0.0.tgz", + "integrity": "sha512-VVJ+Fij5SKnbltxh6TdoBAUAKWfCnSLRPZ7e+r2uO88t8qte5/KHqVOdK4DWCjBr3rEr4YrPR4ylqBCuAWPsKQ==", + "dependencies": { + "detect-passive-events": "^2.0.3", + "rxjs": "^7.8.1", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0" + } + }, + "node_modules/@angular/animations": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.5.tgz", + "integrity": "sha512-m4RtY3z1JuHFCh6OrOHxo25oKEigBDdR/XmdCfXIwfTiObZzNA7VQhysgdrb9IISO99kXbjZUYKDtLzgWT8Klg==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/common": "19.2.5", + "@angular/core": "19.2.5" + } + }, + "node_modules/@angular/build": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.6.tgz", + "integrity": "sha512-+VBLb4ZPLswwJmgfsTFzGex+Sq/WveNc+uaIWyHYjwnuI17NXe1qAAg1rlp72CqGn0cirisfOyAUwPc/xZAgTg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "0.1902.6", + "@babel/core": "7.26.10", + "@babel/helper-annotate-as-pure": "7.25.9", + "@babel/helper-split-export-declaration": "7.24.7", + "@babel/plugin-syntax-import-attributes": "7.26.0", + "@inquirer/confirm": "5.1.6", + "@vitejs/plugin-basic-ssl": "1.2.0", + "beasties": "0.2.0", + "browserslist": "^4.23.0", + "esbuild": "0.25.1", + "fast-glob": "3.3.3", + "https-proxy-agent": "7.0.6", + "istanbul-lib-instrument": "6.0.3", + "listr2": "8.2.5", + "magic-string": "0.30.17", + "mrmime": "2.0.1", + "parse5-html-rewriting-stream": "7.0.0", + "picomatch": "4.0.2", + "piscina": "4.8.0", + "rollup": "4.34.8", + "sass": "1.85.0", + "semver": "7.7.1", + "source-map-support": "0.5.21", + "vite": "6.2.4", + "watchpack": "2.4.2" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" }, "optionalDependencies": { - "esbuild": "0.19.11" + "lmdb": "3.2.6" }, "peerDependencies": { - "@angular/compiler-cli": "^17.0.0", - "@angular/localize": "^17.0.0", - "@angular/platform-server": "^17.0.0", - "@angular/service-worker": "^17.0.0", - "@web/test-runner": "^0.18.0", - "browser-sync": "^3.0.2", - "jest": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", - "karma": "^6.3.0", - "ng-packagr": "^17.0.0", - "protractor": "^7.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0", - "typescript": ">=5.2 <5.4" + "@angular/compiler": "^19.0.0 || ^19.2.0-next.0", + "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", + "@angular/localize": "^19.0.0 || ^19.2.0-next.0", + "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", + "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", + "@angular/ssr": "^19.2.6", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "^19.0.0 || ^19.2.0-next.0", + "postcss": "^8.4.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "typescript": ">=5.5 <5.9" }, "peerDependenciesMeta": { "@angular/localize": { @@ -215,25 +383,19 @@ "@angular/service-worker": { "optional": true }, - "@web/test-runner": { - "optional": true - }, - "browser-sync": { - "optional": true - }, - "jest": { - "optional": true - }, - "jest-environment-jsdom": { + "@angular/ssr": { "optional": true }, "karma": { "optional": true }, + "less": { + "optional": true + }, "ng-packagr": { "optional": true }, - "protractor": { + "postcss": { "optional": true }, "tailwindcss": { @@ -241,22 +403,22 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/@babel/core": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", - "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", + "node_modules/@angular/build/node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -271,7 +433,7 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@angular-devkit/build-angular/node_modules/@babel/core/node_modules/semver": { + "node_modules/@angular/build/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", @@ -280,291 +442,63 @@ "semver": "bin/semver.js" } }, - "node_modules/@angular-devkit/build-angular/node_modules/convert-source-map": { + "node_modules/@angular/build/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/@angular-devkit/build-angular/node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "node_modules/@angular/build/node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@angular-devkit/build-angular/node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", - "dev": true, "engines": { "node": ">=10" } }, - "node_modules/@angular-devkit/build-angular/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@angular-devkit/build-webpack": { - "version": "0.1701.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1701.0.tgz", - "integrity": "sha512-AUQbdnAXMdXKPj51RWr+0SusTh5M1EWEpXtEZgDSO5Vab6ak+xsX+k1IhjlEoliF0prHjD5WzBegr6WKCjZ30w==", - "dev": true, - "dependencies": { - "@angular-devkit/architect": "0.1701.0", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "webpack": "^5.30.0", - "webpack-dev-server": "^4.0.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.1.0.tgz", - "integrity": "sha512-w7HeJjyM6YtjXrwFdmFIsp9lzDPAFJov8hVCD18DZaCwryRixz+o8egfw2SkpI4L8kuGAiGxpaCTRsTQtmR4/w==", - "dev": true, - "dependencies": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.0", - "picomatch": "3.0.1", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^3.5.2" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/core/node_modules/picomatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", - "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.1.0.tgz", - "integrity": "sha512-7q4Bk3+ePBdzrmMWxWBnNdN4kmBe2jJwa3vAofaMqZiIBEor85YcOsrUJvcWM/3+/TusgZr4p/4+oJgiYDrj5A==", - "dev": true, - "dependencies": { - "@angular-devkit/core": "17.1.0", - "jsonc-parser": "3.2.0", - "magic-string": "0.30.5", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-eslint/builder": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-17.2.1.tgz", - "integrity": "sha512-O30eaR0wCPiP+zKWvXj2JM8hVq30Wok2rp7zJMFm3PurjF9nWIIyexXkE5fa538DYZYxu8N3gQRqhpv5jvTXCg==", - "dev": true, - "dependencies": { - "@nx/devkit": "17.2.8", - "nx": "17.2.8" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/bundled-angular-compiler": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-17.2.1.tgz", - "integrity": "sha512-puC0itsZv2QlrDOCcWtq1KZH+DvfrpV+mV78HHhi6+h25R5iIhr8ARKcl3EQxFjvrFq34jhG8pSupxKvFbHVfA==", - "dev": true - }, - "node_modules/@angular-eslint/eslint-plugin": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-17.2.1.tgz", - "integrity": "sha512-9yA81BHpsaCUKRBtHGN3ieAy8HpIoffzPQMu34lYqZFT4yGHGhYmhQjNSQGBRbV2LD9dVv2U35rMHNmUcozXpw==", - "dev": true, - "dependencies": { - "@angular-eslint/utils": "17.2.1", - "@typescript-eslint/utils": "6.19.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/eslint-plugin-template": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-17.2.1.tgz", - "integrity": "sha512-hl1hcHtcm90wyVL1OQGTz16oA0KHon+FFb3Qg0fLXObaXxA495Ecefd9ub5Xxg4JEOPRDi29bF1Y3YKpwflgeg==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.2.1", - "@angular-eslint/utils": "17.2.1", - "@typescript-eslint/type-utils": "6.19.0", - "@typescript-eslint/utils": "6.19.0", - "aria-query": "5.3.0", - "axobject-query": "4.0.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/schematics": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-17.2.1.tgz", - "integrity": "sha512-7ldtIePI4ZTp/TBpeOZkzfv30HSAn//4TgtFuqvojudI8n8batV5FqQ0VNm1e0zitl75t8Zwtr0KYT4I6vh59g==", - "dev": true, - "dependencies": { - "@angular-eslint/eslint-plugin": "17.2.1", - "@angular-eslint/eslint-plugin-template": "17.2.1", - "@nx/devkit": "17.2.8", - "ignore": "5.3.0", - "nx": "17.2.8", - "strip-json-comments": "3.1.1", - "tmp": "0.2.1" - }, - "peerDependencies": { - "@angular/cli": ">= 17.0.0 < 18.0.0" - } - }, - "node_modules/@angular-eslint/template-parser": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-17.2.1.tgz", - "integrity": "sha512-WPQYFvRju0tCDXQ/pwrzC911pE07JvpeDgcN2elhzV6lxDHJEZpA5O9pnW9qgNA6J6XM9Q7dBkJ22ztAzC4WFw==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.2.1", - "eslint-scope": "^8.0.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.0.tgz", - "integrity": "sha512-zj3Byw6jX4TcFCJmxOzLt6iol5FAr9xQyZZSQjEzW2UiCJXLwXdRIKCYVFftnpZckaC9Ps9xlC7jB8tSeWWOaw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@angular-eslint/utils": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-17.2.1.tgz", - "integrity": "sha512-qQYTBXy90dWM7fhhpa5i9lTtqqhJisvRa+naCrQx9kBgR458JScLdkVIdcZ9D/rPiDCmKiVUfgcDISnjUeqTqg==", - "dev": true, - "dependencies": { - "@angular-eslint/bundled-angular-compiler": "17.2.1", - "@typescript-eslint/utils": "6.19.0" - }, - "peerDependencies": { - "eslint": "^7.20.0 || ^8.0.0", - "typescript": "*" - } - }, - "node_modules/@angular/animations": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.1.0.tgz", - "integrity": "sha512-EzyJsla/CnRX4ARmHe9J1m3Pl+J4m5hznzeQFyZpJehikaHKAGGJTGM/+DFAX9TuR1ZpCmS0z0oWsYzag2Q7RA==", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0" - }, - "peerDependencies": { - "@angular/core": "17.1.0" - } - }, "node_modules/@angular/cdk": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.1.0.tgz", - "integrity": "sha512-a2+uqr1s2pCStFs78BM1ViVqi0GnxFHGKHo58hiR9pDV/pyg9cvy+d+rsci1HkuF9AC/UqV5Y6rGLfwayO183g==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.8.tgz", + "integrity": "sha512-ZZqWVYFF80TdjWkk2sc9Pn2luhiYeC78VH3Yjeln4wXMsTGDsvKPBcuOxSxxpJ31saaVBehDjBUuXMqGRj8KuA==", "dependencies": { + "parse5": "^7.1.2", "tslib": "^2.3.0" }, - "optionalDependencies": { - "parse5": "^7.1.2" - }, "peerDependencies": { - "@angular/common": "^17.0.0 || ^18.0.0", - "@angular/core": "^17.0.0 || ^18.0.0", + "@angular/common": "^19.0.0 || ^20.0.0", + "@angular/core": "^19.0.0 || ^20.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/cli": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.1.0.tgz", - "integrity": "sha512-mZh8ibV94CqHls+GTHok9rF78UvrtKZx+o1QOcG50ZM1L5O5s2NYrBhf+QXVeTTmzhSH1wXQb7ueyuLNLVB/eA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.6.tgz", + "integrity": "sha512-eZhFOSsDUHKaciwcWdU5C54ViAvPPdZJf42So93G2vZWDtEq6Uk47huocn1FY9cMhDvURfYLNrrLMpUDtUSsSA==", "dev": true, "dependencies": { - "@angular-devkit/architect": "0.1701.0", - "@angular-devkit/core": "17.1.0", - "@angular-devkit/schematics": "17.1.0", - "@schematics/angular": "17.1.0", + "@angular-devkit/architect": "0.1902.6", + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "@inquirer/prompts": "7.3.2", + "@listr2/prompt-adapter-inquirer": "2.0.18", + "@schematics/angular": "19.2.6", "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.3", - "ini": "4.1.1", - "inquirer": "9.2.12", - "jsonc-parser": "3.2.0", - "npm-package-arg": "11.0.1", - "npm-pick-manifest": "9.0.0", - "open": "8.4.2", - "ora": "5.4.1", - "pacote": "17.0.5", - "resolve": "1.22.8", - "semver": "7.5.4", + "ini": "5.0.0", + "jsonc-parser": "3.3.1", + "listr2": "8.2.5", + "npm-package-arg": "12.0.2", + "npm-pick-manifest": "10.0.0", + "pacote": "20.0.0", + "resolve": "1.22.10", + "semver": "7.7.1", "symbol-observable": "4.0.0", "yargs": "17.7.2" }, @@ -572,56 +506,48 @@ "ng": "bin/ng.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, "node_modules/@angular/common": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.1.0.tgz", - "integrity": "sha512-0Zg62iSynyRr2QslC8dVwSo46mkKrVENnwcBvsgTJ8rfGiuRdKMX8nWm5EUEm3ohKmYLfHvyEjsKDRn//UefVw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.5.tgz", + "integrity": "sha512-vFCBdas4C5PxP6ts/4TlRddWD3DUmI3aaO0QZdZvqyLHy428t84ruYdsJXKaeD8ie2U4/9F3a1tsklclRG/BBA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/core": "17.1.0", + "@angular/core": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.1.0.tgz", - "integrity": "sha512-gF4i/WtPSiSvT4YNasTNnckOxdxuSNwi0EsncrtewwveBcCatjqaXNssUCiF5TgxlC2sKTmsPcMqDJrfX2LMpw==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.5.tgz", + "integrity": "sha512-34J+HubQjwkbZ0AUtU5sa4Zouws9XtP/fKaysMQecoYJTZ3jewzLSRu3aAEZX1Y4gIrcVVKKIxM6oWoXKwYMOA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" - }, - "peerDependencies": { - "@angular/core": "17.1.0" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - } + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" } }, "node_modules/@angular/compiler-cli": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.1.0.tgz", - "integrity": "sha512-WDpO4WvC5ItjaRexnpFpKPpT+cu+5GYkWF8h74iHhfxOgU+gaQiMWERHylWCqF25AzmhKu0iI3ZZtaIJ6qqwog==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz", + "integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==", "dev": true, "dependencies": { - "@babel/core": "7.23.2", + "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^3.0.0", + "chokidar": "^4.0.0", "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.1.2", + "reflect-metadata": "^0.2.0", "semver": "^7.0.0", "tslib": "^2.3.0", "yargs": "^17.2.1" @@ -632,53 +558,81 @@ "ngcc": "bundles/ngcc/index.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "17.1.0", - "typescript": ">=5.2 <5.4" + "@angular/compiler": "19.2.5", + "typescript": ">=5.5 <5.9" + } + }, + "node_modules/@angular/compiler-cli/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@angular/compiler-cli/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/@angular/core": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.1.0.tgz", - "integrity": "sha512-9OvRRZq+46S+ICZLRYIGVU2pknuPz23B+5V3jz7cDA5V43GVcMnfmAbMClPQxm7kRGnqtQ+yzBjn+HubCerE6g==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.5.tgz", + "integrity": "sha512-NNEz1sEZz1mBpgf6Tz3aJ9b8KjqpTiMYhHfCYA9h9Ipe4D8gUmOsvPHPK2M755OX7p7PmUmzp1XCUHYrZMVHRw==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.14.0" + "zone.js": "~0.15.0" } }, "node_modules/@angular/forms": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.1.0.tgz", - "integrity": "sha512-JD9IAxa5gQnjzxYJXm3H+lBuyv/dCnPHl6fpvb/JGrxY6xi4gfndyI8AkAb/wOAQgZDsIPaq5s4eWDjhr7CpyA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.5.tgz", + "integrity": "sha512-2Zvy3qK1kOxiAX9fdSaeG48q7oyO/4RlMYlg1w+ra9qX1SrgwF3OQ2P2Vs+ojg1AxN3z9xFp4aYaaID/G2LZAw==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "17.1.0", - "@angular/core": "17.1.0", - "@angular/platform-browser": "17.1.0", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/localize": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-17.1.0.tgz", - "integrity": "sha512-GW+1F72lRnCwppu2GzGP04d3UhtdhqMHlCbBdZzQUbv8XQfU+22MOGZx/Ry8sXnanZDgH+u+2A4bvrKZPsVgZg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-19.2.5.tgz", + "integrity": "sha512-oAc19bubk6Z/2Vv6OkV0MsjdgC8cUaUwBmwdc6blFVe1NCX1KjdaqDyC2EQAO3nWfcdV4uvOOuu8myxB64bamw==", "dependencies": { - "@babel/core": "7.23.2", - "@types/babel__core": "7.20.2", - "fast-glob": "3.3.1", + "@babel/core": "7.26.9", + "@types/babel__core": "7.20.5", + "fast-glob": "3.3.3", "yargs": "^17.2.1" }, "bin": { @@ -687,27 +641,27 @@ "localize-translate": "tools/bundles/src/translate/cli.js" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/compiler": "17.1.0", - "@angular/compiler-cli": "17.1.0" + "@angular/compiler": "19.2.5", + "@angular/compiler-cli": "19.2.5" } }, "node_modules/@angular/platform-browser": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.1.0.tgz", - "integrity": "sha512-Klq92ZUX0+ZsxLvbYtIEP3GtVEfMLYPxmBP0pWNZyYIeJCg/YxPS76QSvEhBaMqFelk4RzkDQEIfixC16UIgOA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.5.tgz", + "integrity": "sha512-Lshy++X16cvl6OPvfzMySpsqEaCPKEJmDjz7q7oSt96oxlh6LvOeOUVLjsNyrNaIt9NadpWoqjlu/I9RTPJkpw==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/animations": "17.1.0", - "@angular/common": "17.1.0", - "@angular/core": "17.1.0" + "@angular/animations": "19.2.5", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5" }, "peerDependenciesMeta": { "@angular/animations": { @@ -716,80 +670,75 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.1.0.tgz", - "integrity": "sha512-rqPRZZx6VcSx81HIQr1XMBgb7fYSj6pOZNTJGZkn2KNxrz6hyU3A3qaom1VSVRK5vvNb1cFn35mg/zyOIliTIg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-19.2.5.tgz", + "integrity": "sha512-15in8u4552EcdWNTXY2h0MKuJbk3AuXwWr0zVTum4CfB/Ss2tNTrDEdWhgAbhnUI0e9jZQee/fhBbA1rleMYrA==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "17.1.0", - "@angular/compiler": "17.1.0", - "@angular/core": "17.1.0", - "@angular/platform-browser": "17.1.0" + "@angular/common": "19.2.5", + "@angular/compiler": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5" } }, "node_modules/@angular/router": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-17.1.0.tgz", - "integrity": "sha512-VDeVLiiS4iEwqwgsLyL9hqA1djFW3yveMnhZIwviJlnp9vG2r/ggMKhNmdP1Hb2iaNgflyhyhwafJ0gi9SLi5A==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.5.tgz", + "integrity": "sha512-9pSfmdNXLjaOKj0kd4UxBC7sFdCFOnRGbftp397G3KWqsLsGSKmNFzqhXNeA5QHkaVxnpmpm8HzXU+zYV5JwSg==", "dependencies": { "tslib": "^2.3.0" }, "engines": { - "node": "^18.13.0 || >=20.9.0" + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" }, "peerDependencies": { - "@angular/common": "17.1.0", - "@angular/core": "17.1.0", - "@angular/platform-browser": "17.1.0", + "@angular/common": "19.2.5", + "@angular/core": "19.2.5", + "@angular/platform-browser": "19.2.5", "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@assemblyscript/loader": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.10.1.tgz", - "integrity": "sha512-H71nDOOL8Y7kWRLqf6Sums+01Q5msqBW2KhDUTemh1tvY04eSkSXrK0uj/4mmY0Xr16/3zyZmsrxN7CKuRbNRg==", - "dev": true - }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.9.tgz", + "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.9", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.9", + "@babel/parser": "^7.26.9", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.9", + "@babel/types": "^7.26.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -818,51 +767,40 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz", + "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/parser": "^7.26.10", + "@babel/types": "^7.26.10", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", - "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.15" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", + "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -878,144 +816,26 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.7.tgz", - "integrity": "sha512-xCoqR/8+BoNnXOY7RVSgv6X+o7pmT5q1d+gGcRlXYkI+9B31glE4jeejhKVpA04O1AtzOt7OSQ6VYKP5FcRl9g==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-member-expression-to-functions": "^7.23.0", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", - "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "regexpu-core": "^5.3.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.5.0.tgz", - "integrity": "sha512-NovQquuQLAQ5HuyjCz7WQP9MjRj7dx++yspwiyUiGl9ZyadHRSql1HZh5ogRd8W8w6YM6EQ/NTB8rgjLt5W65Q==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", - "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", - "dev": true, - "dependencies": { - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1024,163 +844,70 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", "dev": true, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", - "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-wrap-function": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", - "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-member-expression-to-functions": "^7.22.15", - "@babel/helper-optimise-call-expression": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "dev": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", - "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", - "dev": true, - "dependencies": { - "@babel/helper-function-name": "^7.22.5", - "@babel/template": "^7.22.15", - "@babel/types": "^7.22.19" - }, + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", - "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", + "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", + "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "dependencies": { + "@babel/types": "^7.26.10" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -1188,151 +915,13 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", - "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", - "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.23.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.7.tgz", - "integrity": "sha512-LlRT7HgaifEpQA1ZgLVOIJZZFVPWN5iReq/7/JixwBtwcoeVGDBD53ZV28rrsLYOZs1Y/EHhA8N/Z6aazHR8cw==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-export-namespace-from": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", - "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", - "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", - "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1341,1110 +930,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", - "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.7.tgz", - "integrity": "sha512-PdxEpL71bJp1byMG0va5gwQcXHxuEYC/BgI/e88mGTtohbZN28O5Yit0Plkkm/dBzCF/BxmbNcses1RH1T+urA==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20", - "@babel/plugin-syntax-async-generators": "^7.8.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", - "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-remap-async-to-generator": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", - "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", - "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", - "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", - "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-class-static-block": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz", - "integrity": "sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20", - "@babel/helper-split-export-declaration": "^7.22.6", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", - "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/template": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", - "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", - "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", - "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", - "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", - "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", - "dev": true, - "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", - "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz", - "integrity": "sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", - "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", - "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-json-strings": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", - "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", - "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", - "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", - "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", - "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", - "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", - "dev": true, - "dependencies": { - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", - "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", - "dev": true, - "dependencies": { - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", - "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", - "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", - "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", - "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", - "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.23.3", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.23.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", - "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", - "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", - "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", - "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", - "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", - "dev": true, - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", - "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", - "dev": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-create-class-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", - "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", - "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "regenerator-transform": "^0.15.2" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", - "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.7.tgz", - "integrity": "sha512-fa0hnfmiXc9fq/weK34MUV0drz2pOL/vfKWvN7Qw127hiUPabFCUMgAbYWcchRzMJit4o5ARsK/s+5h0249pLw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", - "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", - "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", - "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", - "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", - "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", - "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", - "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", - "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", - "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", - "dev": true, - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.7.tgz", - "integrity": "sha512-SY27X/GtTz/L4UryMNJ6p4fH4nsgWbz84y9FE0bQeWJP6O5BhgVCt53CotQKHCOeXJel8VyhlhujhlltKms/CA==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-validator-option": "^7.23.5", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.7", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.23.3", - "@babel/plugin-syntax-import-attributes": "^7.23.3", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.23.3", - "@babel/plugin-transform-async-generator-functions": "^7.23.7", - "@babel/plugin-transform-async-to-generator": "^7.23.3", - "@babel/plugin-transform-block-scoped-functions": "^7.23.3", - "@babel/plugin-transform-block-scoping": "^7.23.4", - "@babel/plugin-transform-class-properties": "^7.23.3", - "@babel/plugin-transform-class-static-block": "^7.23.4", - "@babel/plugin-transform-classes": "^7.23.5", - "@babel/plugin-transform-computed-properties": "^7.23.3", - "@babel/plugin-transform-destructuring": "^7.23.3", - "@babel/plugin-transform-dotall-regex": "^7.23.3", - "@babel/plugin-transform-duplicate-keys": "^7.23.3", - "@babel/plugin-transform-dynamic-import": "^7.23.4", - "@babel/plugin-transform-exponentiation-operator": "^7.23.3", - "@babel/plugin-transform-export-namespace-from": "^7.23.4", - "@babel/plugin-transform-for-of": "^7.23.6", - "@babel/plugin-transform-function-name": "^7.23.3", - "@babel/plugin-transform-json-strings": "^7.23.4", - "@babel/plugin-transform-literals": "^7.23.3", - "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", - "@babel/plugin-transform-member-expression-literals": "^7.23.3", - "@babel/plugin-transform-modules-amd": "^7.23.3", - "@babel/plugin-transform-modules-commonjs": "^7.23.3", - "@babel/plugin-transform-modules-systemjs": "^7.23.3", - "@babel/plugin-transform-modules-umd": "^7.23.3", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", - "@babel/plugin-transform-new-target": "^7.23.3", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", - "@babel/plugin-transform-numeric-separator": "^7.23.4", - "@babel/plugin-transform-object-rest-spread": "^7.23.4", - "@babel/plugin-transform-object-super": "^7.23.3", - "@babel/plugin-transform-optional-catch-binding": "^7.23.4", - "@babel/plugin-transform-optional-chaining": "^7.23.4", - "@babel/plugin-transform-parameters": "^7.23.3", - "@babel/plugin-transform-private-methods": "^7.23.3", - "@babel/plugin-transform-private-property-in-object": "^7.23.4", - "@babel/plugin-transform-property-literals": "^7.23.3", - "@babel/plugin-transform-regenerator": "^7.23.3", - "@babel/plugin-transform-reserved-words": "^7.23.3", - "@babel/plugin-transform-shorthand-properties": "^7.23.3", - "@babel/plugin-transform-spread": "^7.23.3", - "@babel/plugin-transform-sticky-regex": "^7.23.3", - "@babel/plugin-transform-template-literals": "^7.23.3", - "@babel/plugin-transform-typeof-symbol": "^7.23.3", - "@babel/plugin-transform-unicode-escapes": "^7.23.3", - "@babel/plugin-transform-unicode-property-regex": "^7.23.3", - "@babel/plugin-transform-unicode-regex": "^7.23.3", - "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.7", - "babel-plugin-polyfill-corejs3": "^0.8.7", - "babel-plugin-polyfill-regenerator": "^0.5.4", - "core-js-compat": "^3.31.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", - "dev": true - }, - "node_modules/@babel/runtime": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.7.tgz", - "integrity": "sha512-w06OXVOFso7LcbzMiDGt+3X7Rh7Ho8MmgPoWU3rarH+8upf+wSU/grlGbWzQyr3DkdN6ZeuMFjpdwW0Q+HxobA==", - "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", + "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.9", + "@babel/types": "^7.26.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", - "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz", + "integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/types": "^7.26.10", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2453,13 +961,12 @@ } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", + "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -2497,9 +1004,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", "cpu": [ "ppc64" ], @@ -2509,13 +1016,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", "cpu": [ "arm" ], @@ -2525,13 +1032,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", "cpu": [ "arm64" ], @@ -2541,13 +1048,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", "cpu": [ "x64" ], @@ -2557,13 +1064,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], @@ -2573,13 +1080,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", "cpu": [ "x64" ], @@ -2589,13 +1096,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", "cpu": [ "arm64" ], @@ -2605,13 +1112,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", "cpu": [ "x64" ], @@ -2621,13 +1128,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", "cpu": [ "arm" ], @@ -2637,13 +1144,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", "cpu": [ "arm64" ], @@ -2653,13 +1160,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", "cpu": [ "ia32" ], @@ -2669,13 +1176,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", "cpu": [ "loong64" ], @@ -2685,13 +1192,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", "cpu": [ "mips64el" ], @@ -2701,13 +1208,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", "cpu": [ "ppc64" ], @@ -2717,13 +1224,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", "cpu": [ "riscv64" ], @@ -2733,13 +1240,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", "cpu": [ "s390x" ], @@ -2749,13 +1256,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", "cpu": [ "x64" ], @@ -2765,13 +1272,29 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", "cpu": [ "x64" ], @@ -2781,13 +1304,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", "cpu": [ "x64" ], @@ -2797,13 +1336,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", "cpu": [ "x64" ], @@ -2813,13 +1352,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", "cpu": [ "arm64" ], @@ -2829,13 +1368,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", "cpu": [ "ia32" ], @@ -2845,13 +1384,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", "cpu": [ "x64" ], @@ -2861,7 +1400,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -2880,24 +1419,81 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.6.2.tgz", - "integrity": "sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", + "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", + "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^10.0.1", + "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -2905,7 +1501,7 @@ "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2927,96 +1523,118 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "type-fest": "^0.20.2" - }, + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "engines": { - "node": ">=10" + "dependencies": { + "brace-expansion": "^1.1.7" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "*" } }, "node_modules/@eslint/js": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz", - "integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", + "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", "dev": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", - "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "engines": { - "node": ">=14" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", + "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.12.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz", - "integrity": "sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==", - "hasInstallScript": true, + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", "engines": { "node": ">=6" } }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", - "minimatch": "^3.0.5" + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" }, "engines": { - "node": ">=10.10.0" + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/module-importer": { @@ -3032,35 +1650,346 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", - "dev": true + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", + "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } }, "node_modules/@iharbeck/ngx-virtual-scroller": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-17.0.0.tgz", - "integrity": "sha512-20BvzxEHvp6yQmB3q4idyzuQ1MsQOJaEnoXLtrsCsU2NM7kJxyWTIVwHAxLX56G99gdKxU8T3GDxb+fVS6GEzw==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/@iharbeck/ngx-virtual-scroller/-/ngx-virtual-scroller-19.0.1.tgz", + "integrity": "sha512-dtn4CpbEY92H9nd1A48WNhsyUgtFBjC83xcsc9VzlSQT/KN2fEx0oBs0Obnn6ZdPanDP/IQdlBgmANmlds/wHA==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@tweenjs/tween.js": "^21.0.0" + "@tweenjs/tween.js": "^25.0.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.4.tgz", + "integrity": "sha512-d30576EZdApjAMceijXA5jDzRQHT/MygbC+J8I7EqA6f/FRpYxlRtRJbHF8gHeWYeSdOuTEJqonn7QLB1ELezA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.6.tgz", + "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.7", + "@inquirer/type": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.9.tgz", + "integrity": "sha512-sXhVB8n20NYkUBfDYgizGHlpRVaCRjtuzNZA6xpALIUbkgfd2Hjz+DfEN6+h1BRnuxw0/P4jCIMjMsEOAMwAJw==", + "dev": true, + "dependencies": { + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.9.tgz", + "integrity": "sha512-8HjOppAxO7O4wV1ETUlJFg6NDjp/W2NP5FB9ZPAcinAlNT4ZIWOLe2pUVwmmPRSV0NMdI5r/+lflN55AwZOKSw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "external-editor": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.11.tgz", + "integrity": "sha512-OZSUW4hFMW2TYvX/Sv+NnOZgO8CHT2TU1roUCUIF2T+wfw60XFRRp9MRUPCT06cRnKL+aemt2YmTWwt7rOrNEA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", + "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.8.tgz", + "integrity": "sha512-WXJI16oOZ3/LiENCAxe8joniNp8MQxF6Wi5V+EBbVA0ZIOpFcL4I9e7f7cXse0HJeIPCWO8Lcgnk98juItCi7Q==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.11.tgz", + "integrity": "sha512-pQK68CsKOgwvU2eA53AG/4npRTH2pvs/pZ2bFvzpBhrznh8Mcwt19c+nMO7LHRr3Vreu1KPhNBF3vQAKrjIulw==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.11.tgz", + "integrity": "sha512-dH6zLdv+HEv1nBs96Case6eppkRggMe8LoOTl30+Gq5Wf27AO/vHFgStTVz4aoevLdNXqwE23++IXGw4eiOXTg==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.11.tgz", + "integrity": "sha512-uAYtTx0IF/PqUAvsRrF3xvnxJV516wmR6YVONOmCWJbbt87HcDHLfL9wmBQFbNJRv5kCjdYKrZcavDkH3sVJPg==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.11.tgz", + "integrity": "sha512-9CWQT0ikYcg6Ls3TOa7jljsD7PgjcsYEM0bYE+Gkz+uoW9u8eaJCRHJKkucpRE5+xKtaaDbrND+nPDoxzjYyew==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.1.0.tgz", + "integrity": "sha512-z0a2fmgTSRN+YBuiK1ROfJ2Nvrpij5lVN3gPDkQGhavdvIVGHGW29LwYZfM/j42Ai2hUghTI/uoBuTbrJk42bA==", + "dev": true, + "dependencies": { + "@inquirer/core": "^10.1.9", + "@inquirer/figures": "^1.0.11", + "@inquirer/type": "^3.0.5", + "ansi-escapes": "^4.3.2", + "yoctocolors-cjs": "^2.1.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.5.tgz", + "integrity": "sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==", + "dev": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@iplab/ngx-file-upload": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-17.0.0.tgz", - "integrity": "sha512-uOJyNRwErF0d1lHOAUQGASK1BCsTY9e3GpkbZIifQOle5h922F1YzVEkzpyhiAK6TxTlvJQP/GdlMiuhw7y6aw==", + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-19.0.3.tgz", + "integrity": "sha512-PXQroFbMrQwg69b/j6Im9R8DkLz15YxiA0ATlFpOTPRtDhAWQMIRNdxbcqRLmBLdPvrsXpH/gN30f0GyC1k/fw==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/animations": "^17.0.0", - "@angular/common": "^17.0.0", - "@angular/core": "^17.0.0", - "@angular/forms": "^17.0.0", + "@angular/animations": "^19.0.0", + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", "rxjs": "^7.0.0" } }, @@ -3153,20 +2082,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "minipass": "^7.0.4" }, "engines": { - "node": ">=8" + "node": ">=18.0.0" } }, "node_modules/@istanbuljs/schema": { @@ -3178,129 +2103,55 @@ "node": ">=8" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "engines": { "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", - "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", - "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", - "dev": true - }, - "node_modules/@ljharb/through": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.11.tgz", - "integrity": "sha512-ccfcIDlogiXNq5KcbAwbaO7lMh3Tm1i3khMPYpxlK8hH/W53zN81KM9coerRLOnTGu3nfXIniAmQbRI9OxbC0w==", - "dev": true, + "node_modules/@jsverse/transloco": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@jsverse/transloco/-/transloco-7.6.1.tgz", + "integrity": "sha512-hFFKJ1pVFYeW2E4UFETQpOeOn0tuncCSUdRewbq3LiV+qS9x4Z2XVuCaAaFPdiNhy4nUKHWFX1pWjpZ5XjUPaQ==", "dependencies": { - "call-bind": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@microsoft/signalr": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-7.0.12.tgz", - "integrity": "sha512-k1Xu+a73PsWgHwHDm6ctHwHTBnlqCzq7L33cbxdWhj90AGDFpxDSzaGCkZDoJFNHveUetix65zIWiazMvmMg3w==", - "dependencies": { - "abort-controller": "^3.0.0", - "eventsource": "^2.0.2", - "fetch-cookie": "^2.0.3", - "node-fetch": "^2.6.7", - "ws": "^7.4.5" - } - }, - "node_modules/@ng-bootstrap/ng-bootstrap": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-16.0.0.tgz", - "integrity": "sha512-+FJ3e6cX9DW2t7021Ji3oz433rk3+4jLfqzU+Jyx6/vJz1dIOaML3EAY6lYuW4TLiXgMPOMvs6KzPFALGh4Lag==", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^17.0.0", - "@angular/core": "^17.0.0", - "@angular/forms": "^17.0.0", - "@angular/localize": "^17.0.0", - "@popperjs/core": "^2.11.8", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@ngneat/transloco": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@ngneat/transloco/-/transloco-6.0.4.tgz", - "integrity": "sha512-hQSPdmzuxJIu2SBwvoiwjoUjxSnUGFyCOkJnV8IwzzmBSdgQxqMMci5WXg/bQeCYggA+RyXpUjjTudEvkWy5Rw==", - "dependencies": { - "@ngneat/transloco-utils": "^5.0.0", - "flat": "6.0.1", + "@jsverse/transloco-utils": "^7.0.0", "fs-extra": "^11.0.0", "glob": "^10.0.0", "lodash.kebabcase": "^4.1.1", @@ -3312,59 +2163,59 @@ "@angular/core": ">=16.0.0" } }, - "node_modules/@ngneat/transloco-locale": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@ngneat/transloco-locale/-/transloco-locale-5.1.1.tgz", - "integrity": "sha512-klGQPwYi50hnLkVl619ywttLPigR+zVR4JeeETKyeIJ5bNSNI1oXABPME+CP1Viht2hOsfKdNIQ3GPCIdIJHRQ==", + "node_modules/@jsverse/transloco-locale": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-locale/-/transloco-locale-7.0.1.tgz", + "integrity": "sha512-mx43h2FKMKxx+Er18qArBJMxmGGW2+EShkH+xueAp+VC/ivBNQDyXWpg8hOsfNFqFQAjzlCAie1mXpbGmbM0uw==", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { - "@angular/core": ">=13.0.0", - "@ngneat/transloco": ">=4.0.0", + "@angular/core": ">=16.0.0", + "@jsverse/transloco": ">=7.0.0", "rxjs": ">=6.0.0" } }, - "node_modules/@ngneat/transloco-persist-lang": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@ngneat/transloco-persist-lang/-/transloco-persist-lang-5.0.0.tgz", - "integrity": "sha512-vBpHQqTeKZT+V+uvIIEv+KyCq+8HFkCa7lnjvWwcgGupSYjTvZp4PxUm+KOLLmaTIzJDL1OQEaszQ84EzX6Mzg==", + "node_modules/@jsverse/transloco-persist-lang": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-lang/-/transloco-persist-lang-7.0.2.tgz", + "integrity": "sha512-VPB/IbukOS64RUM0NQk2rS/wmezo8JYucYerC/94nyF50LM5tR59SyJTPHSFHBTBqXykOQSXUxLRRwzt8UrfPg==", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ngneat/transloco": ">=5.0.0" + "@jsverse/transloco": ">=7.0.0" } }, - "node_modules/@ngneat/transloco-persist-translations": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@ngneat/transloco-persist-translations/-/transloco-persist-translations-5.0.0.tgz", - "integrity": "sha512-QLM9X9aDRPLZhNK8f8h/4eqjhSJvHoGHRSQ+CoS3qkOXteEdOQXeYzWPHSmvDHc5lN3zNRy6sjHrBQEiZQLCKw==", + "node_modules/@jsverse/transloco-persist-translations": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-persist-translations/-/transloco-persist-translations-7.0.1.tgz", + "integrity": "sha512-BUGpcD4MrIBUbo7/G06yGdkWuVTKXVESyAJp107yUbE34Ami0+4BEK7vfLTl09ARwhBQsNKIzZgTAIpzrlK98A==", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ngneat/transloco": ">=5.0.0" + "@jsverse/transloco": ">=7.0.0" } }, - "node_modules/@ngneat/transloco-preload-langs": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@ngneat/transloco-preload-langs/-/transloco-preload-langs-5.0.1.tgz", - "integrity": "sha512-+HDsEtBCFTD8YY31VX9N0dPcVp/CozxmcHXTvqjJ3M0BEkkygZIoiTQwaOPiJziNjFKl8FRhAvovWVV/t8hd8g==", + "node_modules/@jsverse/transloco-preload-langs": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-preload-langs/-/transloco-preload-langs-7.0.1.tgz", + "integrity": "sha512-J9G+r9g8UnLWsEdf0XTUhSIX/CFoKEPP6bEfyXQ7f36FFVu3raPRoEXnqE8gQGCPiyFPG0J8YSf7lyJtUHIgHA==", "dependencies": { "tslib": "^2.2.0" }, "peerDependencies": { "@angular/core": ">=16.0.0", - "@ngneat/transloco": ">=5.0.0" + "@jsverse/transloco": ">=7.0.0" } }, - "node_modules/@ngneat/transloco-utils": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@ngneat/transloco-utils/-/transloco-utils-5.0.0.tgz", - "integrity": "sha512-e0S+GWyBTmLix9KfYWW/rScYdqQz3z3znNSb+foaA5T3jWs4CPLVo+PV0No7kGjqom8Wy8H3lLvztfhHxYSLyA==", + "node_modules/@jsverse/transloco-utils": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@jsverse/transloco-utils/-/transloco-utils-7.0.2.tgz", + "integrity": "sha512-zud1M68mMC/Pu6irEba+Z2SzmwmmPzUPnBzMKlcGdIhzUe1z41cqQutK1M0QaQpY4h4yhumXcNaY/Ot6piv6QQ==", "dependencies": { "cosmiconfig": "^8.1.3", "tslib": "^2.3.0" @@ -3373,31 +2224,512 @@ "node": ">=16" } }, - "node_modules/@ngneat/transloco/node_modules/flat": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/flat/-/flat-6.0.1.tgz", - "integrity": "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==", - "bin": { - "flat": "cli.js" + "node_modules/@listr2/prompt-adapter-inquirer": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.18.tgz", + "integrity": "sha512-0hz44rAcrphyXcA8IS7EJ2SCoaBZD2u5goE8S/e+q/DL+dOGpqpcLidVOFeLG3VgML62SXmfRLAhWt0zL1oW4Q==", + "dev": true, + "dependencies": { + "@inquirer/type": "^1.5.5" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 8" + } + }, + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/@inquirer/type": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.5.tgz", + "integrity": "sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==", + "dev": true, + "dependencies": { + "mute-stream": "^1.0.0" }, "engines": { "node": ">=18" } }, - "node_modules/@ngtools/webpack": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.1.0.tgz", - "integrity": "sha512-FAp5Vh4Y4DFDnrxEitggEkeDwHCml7m6hZUgohvA6n6mwrMT0ZZXnk3MIrKRnT6A9cr1wcnxMW+jIXx/cJZGlw==", + "node_modules/@listr2/prompt-adapter-inquirer/node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", "dev": true, "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.2.6.tgz", + "integrity": "sha512-yF/ih9EJJZc72psFQbwnn8mExIWfTnzWJg+N02hnpXtDPETYLmQswIMBn7+V88lfCaFrMozJsUvcEQIkEPU0Gg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.2.6.tgz", + "integrity": "sha512-5BbCumsFLbCi586Bb1lTWQFkekdQUw8/t8cy++Uq251cl3hbDIGEwD9HAwh8H6IS2F6QA9KdKmO136LmipRNkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.2.6.tgz", + "integrity": "sha512-+6XgLpMb7HBoWxXj+bLbiiB4s0mRRcDPElnRS3LpWRzdYSe+gFk5MT/4RrVNqd2MESUDmb53NUXw1+BP69bjiQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.2.6.tgz", + "integrity": "sha512-l5VmJamJ3nyMmeD1ANBQCQqy7do1ESaJQfKPSm2IG9/ADZryptTyCj8N6QaYgIWewqNUrcbdMkJajRQAt5Qjfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.2.6.tgz", + "integrity": "sha512-nDYT8qN9si5+onHYYaI4DiauDMx24OAiuZAUsEqrDy+ja/3EbpXPX/VAkMV8AEaQhy3xc4dRC+KcYIvOFefJ4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.2.6.tgz", + "integrity": "sha512-XlqVtILonQnG+9fH2N3Aytria7P/1fwDgDhl29rde96uH2sLB8CHORIf2PfuLVzFQJ7Uqp8py9AYwr3ZUCFfWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.7.tgz", + "integrity": "sha512-PHcdMv8v5hJlBkRHAuKG5trGViQEkPYee36LnJQx4xHOQ5LL4X0nEWIxOp5cCtZ7tu+30quz5V3k0b1YNuc6lw==", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/nice": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", + "integrity": "sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.0.1", + "@napi-rs/nice-android-arm64": "1.0.1", + "@napi-rs/nice-darwin-arm64": "1.0.1", + "@napi-rs/nice-darwin-x64": "1.0.1", + "@napi-rs/nice-freebsd-x64": "1.0.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.0.1", + "@napi-rs/nice-linux-arm64-gnu": "1.0.1", + "@napi-rs/nice-linux-arm64-musl": "1.0.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.0.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.0.1", + "@napi-rs/nice-linux-s390x-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-gnu": "1.0.1", + "@napi-rs/nice-linux-x64-musl": "1.0.1", + "@napi-rs/nice-win32-arm64-msvc": "1.0.1", + "@napi-rs/nice-win32-ia32-msvc": "1.0.1", + "@napi-rs/nice-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/nice-android-arm-eabi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.0.1.tgz", + "integrity": "sha512-5qpvOu5IGwDo7MEKVqqyAxF90I6aLj4n07OzpARdgDRfz8UbBztTByBp0RC59r3J1Ij8uzYi6jI7r5Lws7nn6w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.0.1.tgz", + "integrity": "sha512-GqvXL0P8fZ+mQqG1g0o4AO9hJjQaeYG84FRfZaYjyJtZZZcMjXW5TwkL8Y8UApheJgyE13TQ4YNUssQaTgTyvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-91k3HEqUl2fsrz/sKkuEkscj6EAj3/eZNCLqzD2AA0TtVbkQi8nqxZCZDMkfklULmxLkMxuUdKe7RvG/T6s2AA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.0.1.tgz", + "integrity": "sha512-jXnMleYSIR/+TAN/p5u+NkCA7yidgswx5ftqzXdD5wgy/hNR92oerTXHc0jrlBisbd7DpzoaGY4cFD7Sm5GlgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-j+iJ/ezONXRQsVIB/FJfwjeQXX7A2tf3gEXs4WUGFrJjpe/z2KB7sOv6zpkm08PofF36C9S7wTNuzHZ/Iiccfw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-G8RgJ8FYXYkkSGQwywAUh84m946UTn6l03/vmEXBYNJxQJcD+I3B3k5jmjFG/OPiU8DfvxutOP8bi+F89MCV7Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-IMDak59/W5JSab1oZvmNbrms3mHqcreaCeClUjwlwDr0m3BoR09ZiN8cKFBzuSlXgRdZ4PNqCYNeGQv7YMTjuA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-wG8fa2VKuWM4CfjOjjRX9YLIbysSVV1S3Kgm2Fnc67ap/soHBeYZa6AGMeR5BJAylYRjnoVOzV19Cmkco3QEPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-lxQ9WrBf0IlNTCA9oS2jg/iAjQyTI6JHzABV664LLrLA/SIdD+I1i3Mjf7TsnoUbgopBcCuDztVLfJ0q9ubf6Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-riscv64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.0.1.tgz", + "integrity": "sha512-3xs69dO8WSWBb13KBVex+yvxmUeEsdWexxibqskzoKaWx9AIqkMbWmE2npkazJoopPKX2ULKd8Fm9veEn0g4Ig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-lMFI3i9rlW7hgToyAzTaEybQYGbQHDrpRkg+1gJWEpH0PLAQoZ8jiY0IzakLfNWnVda1eTYYlxxFYzW8Rqczkg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XQAJs7DRN2GpLN6Fb+ZdGFeYZDdGl2Fn3TmFlqEL5JorgWKrQGRUrpGKbgZ25UeZPILuTKJ+OowG2avN8mThBA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-/rodHpRSgiI9o1faq9SZOp/o2QkKQg7T+DK0R5AkbnI/YxvAIEHf2cngjYzLMQSQgUhxym+LFr+UGZx4vK4QdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-rEcz9vZymaCB3OqEXoHnp9YViLct8ugF+6uO5McifTedjq4QMQs3DHz35xBEGhH3gJWEsXMUbzazkz5KNM5YUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-ia32-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.0.1.tgz", + "integrity": "sha512-t7eBAyPUrWL8su3gDxw9xxxqNwZzAqKo0Szv3IjVQd1GpXXVkb6vBBQUuxfIYaXMzZLwlxRQ7uzM2vdUE9ULGw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-JlF+uDcatt3St2ntBG8H02F1mM45i5SF9W+bIKiReVE6wiy3o16oBP/yxt+RZ+N6LbCImJXJ6bXNO2kn9AXicg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ng-bootstrap/ng-bootstrap": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-18.0.0.tgz", + "integrity": "sha512-GeSAz4yiGq49psdte8kcf+Y562wB3jK/qKRAkh6iA32lcXmy2sfQXVAmlHdjZ3AyP+E8lf3yMwuPdSKiYcDgSg==", + "dependencies": { + "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/compiler-cli": "^17.0.0", - "typescript": ">=5.2 <5.4", - "webpack": "^5.54.0" + "@angular/common": "^19.0.0", + "@angular/core": "^19.0.0", + "@angular/forms": "^19.0.0", + "@angular/localize": "^19.0.0", + "@popperjs/core": "^2.11.8", + "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -3433,59 +2765,56 @@ } }, "node_modules/@npmcli/agent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.0.tgz", - "integrity": "sha512-2yThA1Es98orMkpSLVqlDZAMPK3jHJhifP2gnNUdk1754uZ8yI5c+ulCoVG+WlntQA6MzhrURMXjSd9Z7dJ2/Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", "dev": true, "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.1" + "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", - "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", "dev": true, "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/git": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-5.0.4.tgz", - "integrity": "sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-6.0.3.tgz", + "integrity": "sha512-GUYESQlxZRAdhs3UhbB6pVRNUELQOHXwK9ruDkwmCv2aZ5y0SApQzUJCg02p3A7Ue2J5hxvlk1YI53c00NmRyQ==", "dev": true, "dependencies": { - "@npmcli/promise-spawn": "^7.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", "lru-cache": "^10.0.1", - "npm-pick-manifest": "^9.0.0", - "proc-log": "^3.0.0", - "promise-inflight": "^1.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "semver": "^7.3.5", - "which": "^4.0.0" + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/git/node_modules/isexe": { @@ -3498,18 +2827,15 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/@npmcli/git/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -3518,44 +2844,62 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/installed-package-contents": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", - "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-3.0.0.tgz", + "integrity": "sha512-fkxoPuFGvxyrH+OQzyTkX2LUEamrF4jZSmxjAtPPHHGO0dqsQ8tTKjnIS8SAnPHdk2I03BDtSMR5K/4loKg79Q==", "dev": true, "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" }, "bin": { - "installed-package-contents": "lib/index.js" + "installed-package-contents": "bin/index.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", - "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-4.0.0.tgz", + "integrity": "sha512-+t5DZ6mO/QFh78PByMq1fGSAub/agLJZDRfJRMeOSNCt8s9YVlTjmGpIPwPhvXTGUIJk+WszlT0rQa1W33yzNA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", + "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", + "dev": true, + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/promise-spawn": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz", - "integrity": "sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-8.0.2.tgz", + "integrity": "sha512-/bNJhjc+o6qL+Dwz/bqfTQClkEO5nTQ1ZEcdCkAQjhkZMHIh22LPG7fNh1enJP1NKWDqYiiABnjFCY7E0zHYtQ==", "dev": true, "dependencies": { - "which": "^4.0.0" + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/promise-spawn/node_modules/isexe": { @@ -3568,9 +2912,9 @@ } }, "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -3579,23 +2923,33 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-3.1.1.tgz", + "integrity": "sha512-3Hc2KGIkrvJWJqTbvueXzBeZlmvoOxc2jyX00yzr3+sNFquJg0N8hH4SAPLPVrkWIRQICVpVgjrss971awXVnA==", + "dev": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/run-script": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-7.0.3.tgz", - "integrity": "sha512-ZMWGLHpzMq3rBGIwPyeaoaleaLMvrBrH8nugHxTi5ACkJZXTxXPtVuEH91ifgtss5hUwJQ2VDnzDBWPmz78rvg==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-9.1.0.tgz", + "integrity": "sha512-aoNSbxtkePXUlbZB+anS1LqsJdctG5n3UVhfU47+CDdwMi6uNTBMF9gPcQRnqghQd2FGzcwwIFBruFMxjhBewg==", "dev": true, "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/promise-spawn": "^7.0.0", - "node-gyp": "^10.0.0", - "read-package-json-fast": "^3.0.0", - "which": "^4.0.0" + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/run-script/node_modules/isexe": { @@ -3608,9 +2962,9 @@ } }, "node_modules/@npmcli/run-script/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -3619,86 +2973,69 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@nrwl/devkit": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nrwl/devkit/-/devkit-17.2.8.tgz", - "integrity": "sha512-l2dFy5LkWqSA45s6pee6CoqJeluH+sjRdVnAAQfjLHRNSx6mFAKblyzq5h1f4P0EUCVVVqLs+kVqmNx5zxYqvw==", + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", "dev": true, + "hasInstallScript": true, + "optional": true, "dependencies": { - "@nx/devkit": "17.2.8" - } - }, - "node_modules/@nrwl/tao": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nrwl/tao/-/tao-17.2.8.tgz", - "integrity": "sha512-Qpk5YKeJ+LppPL/wtoDyNGbJs2MsTi6qyX/RdRrEc8lc4bk6Cw3Oul1qTXCI6jT0KzTz+dZtd0zYD/G7okkzvg==", - "dev": true, - "dependencies": { - "nx": "17.2.8", - "tslib": "^2.3.0" - }, - "bin": { - "tao": "index.js" - } - }, - "node_modules/@nx/devkit": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/devkit/-/devkit-17.2.8.tgz", - "integrity": "sha512-6LtiQihtZwqz4hSrtT5cCG5XMCWppG6/B8c1kNksg97JuomELlWyUyVF+sxmeERkcLYFaKPTZytP0L3dmCFXaw==", - "dev": true, - "dependencies": { - "@nrwl/devkit": "17.2.8", - "ejs": "^3.1.7", - "enquirer": "~2.3.6", - "ignore": "^5.0.4", - "semver": "7.5.3", - "tmp": "~0.2.1", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "nx": ">= 16 <= 18" - } - }, - "node_modules/@nx/devkit/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" }, "engines": { - "node": ">=10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" } }, - "node_modules/@nx/devkit/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/devkit/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/@nx/nx-darwin-arm64": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-arm64/-/nx-darwin-arm64-17.2.8.tgz", - "integrity": "sha512-dMb0uxug4hM7tusISAU1TfkDK3ixYmzc1zhHSZwpR7yKJIyKLtUpBTbryt8nyso37AS1yH+dmfh2Fj2WxfBHTg==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", "cpu": [ "arm64" ], @@ -3708,13 +3045,17 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-darwin-x64": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-darwin-x64/-/nx-darwin-x64-17.2.8.tgz", - "integrity": "sha512-0cXzp1tGr7/6lJel102QiLA4NkaLCkQJj6VzwbwuvmuCDxPbpmbz7HC1tUteijKBtOcdXit1/MEoEU007To8Bw==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", "cpu": [ "x64" ], @@ -3724,13 +3065,17 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-freebsd-x64": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-freebsd-x64/-/nx-freebsd-x64-17.2.8.tgz", - "integrity": "sha512-YFMgx5Qpp2btCgvaniDGdu7Ctj56bfFvbbaHQWmOeBPK1krNDp2mqp8HK6ZKOfEuDJGOYAp7HDtCLvdZKvJxzA==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", "cpu": [ "x64" ], @@ -3740,13 +3085,17 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-arm-gnueabihf": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm-gnueabihf/-/nx-linux-arm-gnueabihf-17.2.8.tgz", - "integrity": "sha512-iN2my6MrhLRkVDtdivQHugK8YmR7URo1wU9UDuHQ55z3tEcny7LV3W9NSsY9UYPK/FrxdDfevj0r2hgSSdhnzA==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", "cpu": [ "arm" ], @@ -3756,13 +3105,37 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-arm64-gnu": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-gnu/-/nx-linux-arm64-gnu-17.2.8.tgz", - "integrity": "sha512-Iy8BjoW6mOKrSMiTGujUcNdv+xSM1DALTH6y3iLvNDkGbjGK1Re6QNnJAzqcXyDpv32Q4Fc57PmuexyysZxIGg==", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", "cpu": [ "arm64" ], @@ -3772,13 +3145,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-arm64-musl": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-arm64-musl/-/nx-linux-arm64-musl-17.2.8.tgz", - "integrity": "sha512-9wkAxWzknjpzdofL1xjtU6qPFF1PHlvKCZI3hgEYJDo4mQiatGI+7Ttko+lx/ZMP6v4+Umjtgq7+qWrApeKamQ==", + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", "cpu": [ "arm64" ], @@ -3788,13 +3165,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-x64-gnu": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-gnu/-/nx-linux-x64-gnu-17.2.8.tgz", - "integrity": "sha512-sjG1bwGsjLxToasZ3lShildFsF0eyeGu+pOQZIp9+gjFbeIkd19cTlCnHrOV9hoF364GuKSXQyUlwtFYFR4VTQ==", + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", "cpu": [ "x64" ], @@ -3804,13 +3185,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-linux-x64-musl": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-linux-x64-musl/-/nx-linux-x64-musl-17.2.8.tgz", - "integrity": "sha512-QiakXZ1xBCIptmkGEouLHQbcM4klQkcr+kEaz2PlNwy/sW3gH1b/1c0Ed5J1AN9xgQxWspriAONpScYBRgxdhA==", + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", "cpu": [ "x64" ], @@ -3820,13 +3205,17 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-win32-arm64-msvc": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-arm64-msvc/-/nx-win32-arm64-msvc-17.2.8.tgz", - "integrity": "sha512-XBWUY/F/GU3vKN9CAxeI15gM4kr3GOBqnzFZzoZC4qJt2hKSSUEWsMgeZtsMgeqEClbi4ZyCCkY7YJgU32WUGA==", + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", "cpu": [ "arm64" ], @@ -3836,13 +3225,37 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@nx/nx-win32-x64-msvc": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/@nx/nx-win32-x64-msvc/-/nx-win32-x64-msvc-17.2.8.tgz", - "integrity": "sha512-HTqDv+JThlLzbcEm/3f+LbS5/wYQWzb5YDXbP1wi7nlCTihNZOLNqGOkEmwlrR5tAdNHPRpHSmkYg4305W0CtA==", + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", "cpu": [ "x64" ], @@ -3852,9 +3265,33 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3865,9 +3302,9 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.23", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", - "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", "dev": true }, "node_modules/@popperjs/core": { @@ -3880,9 +3317,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", "cpu": [ "arm" ], @@ -3893,9 +3330,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", "cpu": [ "arm64" ], @@ -3906,9 +3343,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", "cpu": [ "arm64" ], @@ -3919,9 +3356,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", "cpu": [ "x64" ], @@ -3931,10 +3368,49 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", "cpu": [ "arm" ], @@ -3945,9 +3421,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", "cpu": [ "arm64" ], @@ -3958,9 +3434,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", "cpu": [ "arm64" ], @@ -3970,10 +3446,36 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", "cpu": [ "riscv64" ], @@ -3983,10 +3485,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", + "integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==", "cpu": [ "x64" ], @@ -3997,9 +3512,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz", + "integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==", "cpu": [ "x64" ], @@ -4010,9 +3525,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", "cpu": [ "arm64" ], @@ -4023,9 +3538,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", "cpu": [ "ia32" ], @@ -4036,9 +3551,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", "cpu": [ "x64" ], @@ -4049,135 +3564,145 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.1.0.tgz", - "integrity": "sha512-u9pCesRWb6mVtLnFLSfZ8R21TDz8YCebAxViefWsJlb0+p0yknesVL1nG/Oi9tgfhczS991HGIVsLT41bZthUw==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.6.tgz", + "integrity": "sha512-fmbF9ONmEZqxHocCwOSWG2mHp4a22d1uW+DZUBUgZSBUFIrnFw42deOxDq8mkZOZ1Tc73UpLN2GKI7iJeUqS2A==", "dev": true, "dependencies": { - "@angular-devkit/core": "17.1.0", - "@angular-devkit/schematics": "17.1.0", - "jsonc-parser": "3.2.0" + "@angular-devkit/core": "19.2.6", + "@angular-devkit/schematics": "19.2.6", + "jsonc-parser": "3.3.1" }, "engines": { - "node": "^18.13.0 || >=20.9.0", + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", "yarn": ">= 1.13.0" } }, + "node_modules/@siemens/ngx-datatable": { + "version": "22.4.1", + "resolved": "https://registry.npmjs.org/@siemens/ngx-datatable/-/ngx-datatable-22.4.1.tgz", + "integrity": "sha512-Z19zaxu7tpwMHWc1h5Om9/sZJ39MWTQypju6T6WH7QIkelKgZE7DbYk3siD41vkR/62vT+q0Z1voC2OyxgRX9g==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=17.0.0", + "@angular/core": ">=17.0.0", + "@angular/platform-browser": ">=17.0.0", + "rxjs": "^7.8.0" + } + }, "node_modules/@sigstore/bundle": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-2.1.1.tgz", - "integrity": "sha512-v3/iS+1nufZdKQ5iAlQKcCsoh0jffQyABvYIxKsZQFWc4ubuGjwZklFHpDgV6O6T7vvV78SW5NHI91HFKEcxKg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", + "integrity": "sha512-Mm1E3/CmDDCz3nDhFKTuYdB47EdRFRQMOE/EAbiG1MJW77/w1b3P7Qx7JSrVJs8PfwOLOVcKQCHErIwCTyPbag==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.2.1" + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-0.2.0.tgz", - "integrity": "sha512-THobAPPZR9pDH2CAvDLpkrYedt7BlZnsyxDe+Isq4ZmGfPy5juOFZq487vCU2EgKD7aHSiTfE/i7sN7aEdzQnA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-2.0.0.tgz", + "integrity": "sha512-nYxaSb/MtlSI+JWcwTHQxyNmWeWrUXJJ/G4liLrGG7+tS4vAz6LF3xRXqLH6wPIVUoZQel2Fs4ddLx4NCpiIYg==", "dev": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/protobuf-specs": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz", - "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.4.0.tgz", + "integrity": "sha512-o09cLSIq9EKyRXwryWDOJagkml9XgQCoCSRjHOnHLnvsivaW7Qznzz6yjfV7PHJHhIvyp8OH7OX8w0Dc5bQK7A==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/sign": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-2.2.1.tgz", - "integrity": "sha512-U5sKQEj+faE1MsnLou1f4DQQHeFZay+V9s9768lw48J4pKykPj34rWyI1lsMOGJ3Mae47Ye6q3HAJvgXO21rkQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-3.1.0.tgz", + "integrity": "sha512-knzjmaOHOov1Ur7N/z4B1oPqZ0QX5geUfhrVaqVlu+hl0EAoL4o+l0MSULINcD5GCWe3Z0+YJO8ues6vFlW0Yw==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.1.1", - "@sigstore/core": "^0.2.0", - "@sigstore/protobuf-specs": "^0.2.1", - "make-fetch-happen": "^13.0.0" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/tuf": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-2.3.0.tgz", - "integrity": "sha512-S98jo9cpJwO1mtQ+2zY7bOdcYyfVYCUaofCG6wWRzk3pxKHVAkSfshkfecto2+LKsx7Ovtqbgb2LS8zTRhxJ9Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-3.1.0.tgz", + "integrity": "sha512-suVMQEA+sKdOz5hwP9qNcEjX6B45R+hFFr4LAWzbRc5O+U2IInwvay/bpG5a4s+qR35P/JK/PiKiRGjfuLy1IA==", "dev": true, "dependencies": { - "@sigstore/protobuf-specs": "^0.2.1", - "tuf-js": "^2.2.0" + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@sigstore/verify": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-0.1.0.tgz", - "integrity": "sha512-2UzMNYAa/uaz11NhvgRnIQf4gpLTJ59bhb8ESXaoSS5sxedfS+eLak8bsdMc+qpNQfITUTFoSKFx5h8umlRRiA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-2.1.0.tgz", + "integrity": "sha512-kAAM06ca4CzhvjIZdONAL9+MLppW3K48wOFy1TbuaWFW/OMfl8JuTgW0Bm02JB1WJGT/ET2eqav0KTEKmxqkIA==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.1.1", - "@sigstore/core": "^0.2.0", - "@sigstore/protobuf-specs": "^0.2.1" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, "node_modules/@swimlane/ngx-charts": { - "version": "20.5.0", - "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-20.5.0.tgz", - "integrity": "sha512-PNBIHdu/R3ceD7jnw1uCBVOj4k3T6IxfdW6xsDsglGkZyoWMEEq4tLoEurjLEKzmDtRv9c35kVNOXy0lkOuXeA==", + "version": "22.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-charts/-/ngx-charts-22.0.0-alpha.0.tgz", + "integrity": "sha512-sauI4QcfpuKXmRWajpeVtAoT7z8uI3u1+hvfcsJ796LRr06C676dkjoZsk7aX3EU+6uF8mJpXClOT/JcfnZrEA==", "dependencies": { - "d3-array": "^3.1.1", + "d3-array": "^3.2.0", "d3-brush": "^3.0.0", "d3-color": "^3.1.0", "d3-ease": "^3.0.1", "d3-format": "^3.1.0", - "d3-hierarchy": "^3.1.0", + "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", - "d3-time-format": "^3.0.0", + "d3-time-format": "^4.1.0", "d3-transition": "^3.0.1", - "rfdc": "^1.3.0", - "tslib": "^2.0.0" + "gradient-path": "^2.3.0", + "tslib": "^2.3.1" }, "peerDependencies": { - "@angular/animations": ">=12.0.0", - "@angular/cdk": ">=12.0.0", - "@angular/common": ">=12.0.0", - "@angular/core": ">=12.0.0", - "@angular/forms": ">=12.0.0", - "@angular/platform-browser": ">=12.0.0", - "@angular/platform-browser-dynamic": ">=12.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "@angular/animations": "17.x || 18.x || 19.x", + "@angular/cdk": "17.x || 18.x || 19.x", + "@angular/common": "17.x || 18.x || 19.x", + "@angular/core": "17.x || 18.x || 19.x", + "@angular/forms": "17.x || 18.x || 19.x", + "@angular/platform-browser": "17.x || 18.x || 19.x", + "@angular/platform-browser-dynamic": "17.x || 18.x || 19.x", + "rxjs": "7.x" } }, "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", "dev": true }, "node_modules/@tsconfig/node12": { @@ -4208,51 +3733,27 @@ } }, "node_modules/@tufjs/models": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-2.0.0.tgz", - "integrity": "sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-3.0.1.tgz", + "integrity": "sha512-UUYHISyhCU3ZgN8yaear3cGATHb3SMuKHsQ/nVbHXcmnBf+LzQ/cQfhNG+rfaSHgqGKNEm2cOCLVLELStUQ1JA==", "dev": true, "dependencies": { "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.3" + "minimatch": "^9.0.5" }, "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@tweenjs/tween.js": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-21.0.0.tgz", - "integrity": "sha512-qVfOiFh0U8ZSkLgA6tf7kj2MciqRbSCWaJZRwftVO7UbtVDNsZAXpWXqvCDtIefvjC83UJB+vHTDOGm5ibXjEA==" + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==" }, "node_modules/@types/babel__core": { - "version": "7.20.2", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", - "integrity": "sha512-pNpr1T1xLUc2l3xJKuPtsEky3ybxN3m4fJkknfIpTCTfIZCDW57oAg+EfCgIIp2rvCe0Wn++/FfodDS4YXxBwA==", + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -4286,44 +3787,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -4363,45 +3826,45 @@ } }, "node_modules/@types/d3-array": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.5.tgz", - "integrity": "sha512-Qk7fpJ6qFp+26VeQ47WY0mkwXaiq8+76RJcncDEfMc2ocRzXLO67bLFRNI4OX1aGBoPzsM5Y2T+/m1pldOgD+A==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", "dev": true }, "node_modules/@types/d3-axis": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.2.tgz", - "integrity": "sha512-uGC7DBh0TZrU/LY43Fd8Qr+2ja1FKmH07q2FoZFHo1eYl8aj87GhfVoY1saJVJiq24rp1+wpI6BvQJMKgQm8oA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", "dev": true, "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-brush": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.2.tgz", - "integrity": "sha512-2TEm8KzUG3N7z0TrSKPmbxByBx54M+S9lHoP2J55QuLU0VSQ9mE96EJSAOVNEqd1bbynMjeTS9VHmz8/bSw8rA==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", "dev": true, "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-chord": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.2.tgz", - "integrity": "sha512-abT/iLHD3sGZwqMTX1TYCMEulr+wBd0SzyOQnjYNLp7sngdOHYtNkMRI5v3w5thoN+BWtlHVDx2Osvq6fxhZWw==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", "dev": true }, "node_modules/@types/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "dev": true }, "node_modules/@types/d3-contour": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.2.tgz", - "integrity": "sha512-k6/bGDoAGJZnZWaKzeB+9glgXCYGvh6YlluxzBREiVo8f/X2vpTEdgPy9DN7Z2i42PZOZ4JDhVdlTSTSkLDPlQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", "dev": true, "dependencies": { "@types/d3-array": "*", @@ -4409,224 +3872,180 @@ } }, "node_modules/@types/d3-delaunay": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", - "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", "dev": true }, "node_modules/@types/d3-dispatch": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.2.tgz", - "integrity": "sha512-rxN6sHUXEZYCKV05MEh4z4WpPSqIw+aP7n9ZN6WYAAvZoEAghEK1WeVZMZcHRBwyaKflU43PCUAJNjFxCzPDjg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz", + "integrity": "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==", "dev": true }, "node_modules/@types/d3-drag": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.2.tgz", - "integrity": "sha512-qmODKEDvyKWVHcWWCOVcuVcOwikLVsyc4q4EBJMREsoQnR2Qoc2cZQUyFUPgO9q4S3qdSqJKBsuefv+h0Qy+tw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", "dev": true, "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-76pBHCMTvPLt44wFOieouXcGXWOF0AJCceUvaFkxSZEu4VDUdv93JfpMa6VGNFs01FHfuP4a5Ou68eRG1KBfTw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", "dev": true }, "node_modules/@types/d3-ease": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.0.tgz", - "integrity": "sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "dev": true }, "node_modules/@types/d3-fetch": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.2.tgz", - "integrity": "sha512-gllwYWozWfbep16N9fByNBDTkJW/SyhH6SGRlXloR7WdtAaBui4plTP+gbUgiEot7vGw/ZZop1yDZlgXXSuzjA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", "dev": true, "dependencies": { "@types/d3-dsv": "*" } }, "node_modules/@types/d3-force": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.4.tgz", - "integrity": "sha512-q7xbVLrWcXvSBBEoadowIUJ7sRpS1yvgMWnzHJggFy5cUZBq2HZL5k/pBSm0GdYWS1vs5/EDwMjSKF55PDY4Aw==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.9.tgz", + "integrity": "sha512-IKtvyFdb4Q0LWna6ymywQsEYjK/94SGhPrMfEr1TIc5OBeziTi+1jcCvttts8e0UWZIxpasjnQk9MNk/3iS+kA==", "dev": true }, "node_modules/@types/d3-format": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", - "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", "dev": true }, "node_modules/@types/d3-geo": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.0.3.tgz", - "integrity": "sha512-bK9uZJS3vuDCNeeXQ4z3u0E7OeJZXjUgzFdSOtNtMCJCLvDtWDwfpRVWlyt3y8EvRzI0ccOu9xlMVirawolSCw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", "dev": true, "dependencies": { "@types/geojson": "*" } }, "node_modules/@types/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-9hjRTVoZjRFR6xo8igAJyNXQyPX6Aq++Nhb5ebrUF414dv4jr2MitM2fWiOY475wa3Za7TOS2Gh9fmqEhLTt0A==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", "dev": true }, "node_modules/@types/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "dev": true, "dependencies": { "@types/d3-color": "*" } }, "node_modules/@types/d3-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", - "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", "dev": true }, "node_modules/@types/d3-polygon": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.0.tgz", - "integrity": "sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", "dev": true }, "node_modules/@types/d3-quadtree": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.2.tgz", - "integrity": "sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", "dev": true }, "node_modules/@types/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", "dev": true }, "node_modules/@types/d3-scale": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.3.tgz", - "integrity": "sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", "dev": true, "dependencies": { "@types/d3-time": "*" } }, "node_modules/@types/d3-scale-chromatic": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", - "integrity": "sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==", "dev": true }, "node_modules/@types/d3-selection": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.5.tgz", - "integrity": "sha512-xCB0z3Hi8eFIqyja3vW8iV01+OHGYR2di/+e+AiOcXIOrY82lcvWW8Ke1DYE/EUVMsBl4Db9RppSBS3X1U6J0w==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.10.tgz", + "integrity": "sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==", "dev": true }, "node_modules/@types/d3-shape": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.1.tgz", - "integrity": "sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", "dev": true, "dependencies": { "@types/d3-path": "*" } }, "node_modules/@types/d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", "dev": true }, "node_modules/@types/d3-time-format": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.0.tgz", - "integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", "dev": true }, "node_modules/@types/d3-timer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz", - "integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "dev": true }, "node_modules/@types/d3-transition": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.3.tgz", - "integrity": "sha512-/S90Od8Id1wgQNvIA8iFv9jRhCiZcGhPd2qX0bKF/PS+y0W5CrXKgIiELd2CvG1mlQrWK/qlYh3VxicqG1ZvgA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.8.tgz", + "integrity": "sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==", "dev": true, "dependencies": { "@types/d3-selection": "*" } }, "node_modules/@types/d3-zoom": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.3.tgz", - "integrity": "sha512-OWk1yYIIWcZ07+igN6BeoG6rqhnJ/pYe+R1qWFM2DtW49zsoSjgb9G5xB0ZXA8hh2jAzey1XuRmMSoXdKw8MDA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", "dev": true, "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, - "node_modules/@types/eslint": { - "version": "8.40.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.40.2.tgz", - "integrity": "sha512-PRVjQ4Eh9z9pmmtaq8nTjZjQwKFk7YIHIud3lRoKRBgUQjgjRmoGxxGEPXQkF+lH7QkHJRNr5F4aBgYCW0lqpQ==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", - "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, - "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", - "dev": true, - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.17.41", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", - "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", - "dev": true, - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, "node_modules/@types/file-saver": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", @@ -4634,334 +4053,101 @@ "dev": true }, "node_modules/@types/geojson": { - "version": "7946.0.10", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", - "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", "dev": true }, - "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true - }, - "node_modules/@types/http-proxy": { - "version": "1.17.14", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", - "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/json-schema": { - "version": "7.0.12", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", - "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, "node_modules/@types/luxon": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.0.tgz", - "integrity": "sha512-PEVoA4MOfSsFNaPrZjIUGUZujBDxnO/tj2A2N9KfzlR+pNgpBdDuk0TmRvSMAVUP5q4q8IkMEZ8UOp3MIr+QgA==", - "dev": true - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz", + "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==", "dev": true }, "node_modules/@types/node": { - "version": "20.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", - "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", + "version": "22.13.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", + "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", "dev": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.20.0" } }, - "node_modules/@types/node-forge": { - "version": "1.3.11", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", - "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/qs": { - "version": "6.9.11", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", - "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==", - "dev": true - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true - }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", - "dev": true - }, - "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", - "dev": true, - "dependencies": { - "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", - "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", - "dev": true, - "dependencies": { - "@types/node": "*" - } + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.0.tgz", - "integrity": "sha512-HTvbSd0JceI2GW5DHS3R9zbarOqjkM9XDR7zL8eCsBUO/eSiHcoNE7kSL5sjGXmVa9fjH5LCfHDXNnH4QLp7tQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.28.0.tgz", + "integrity": "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg==", "dev": true, "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.13.0", - "@typescript-eslint/type-utils": "6.13.0", - "@typescript-eslint/utils": "6.13.0", - "@typescript-eslint/visitor-keys": "6.13.0", - "debug": "^4.3.4", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/type-utils": "8.28.0", + "@typescript-eslint/utils": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", - "ignore": "^5.2.4", + "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.0.tgz", - "integrity": "sha512-2x0K2/CujsokIv+LN2T0l5FVDMtsCjkUyYtlcY4xxnxLAW+x41LXr16duoicHpGtLhmtN7kqvuFJ3zbz00Ikhw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.13.0", - "@typescript-eslint/visitor-keys": "6.13.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.0.tgz", - "integrity": "sha512-YHufAmZd/yP2XdoD3YeFEjq+/Tl+myhzv+GJHSOz+ro/NFGS84mIIuLU3pVwUcauSmwlCrVXbBclkn1HfjY0qQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "6.13.0", - "@typescript-eslint/utils": "6.13.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.0.tgz", - "integrity": "sha512-oXg7DFxx/GmTrKXKKLSoR2rwiutOC7jCQ5nDH5p5VS6cmHE1TcPTaYQ0VPSSUvj7BnNqCgQ/NXcTBxn59pfPTQ==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.0.tgz", - "integrity": "sha512-IT4O/YKJDoiy/mPEDsfOfp+473A9GVqXlBKckfrAOuVbTqM8xbc0LuqyFCcgeFWpqu3WjQexolgqN2CuWBYbog==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.13.0", - "@typescript-eslint/visitor-keys": "6.13.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.0.tgz", - "integrity": "sha512-V+txaxARI8yznDkcQ6FNRXxG+T37qT3+2NsDTZ/nKLxv6VfGrRhTnuvxPUxpVuWWr+eVeIxU53PioOXbz8ratQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.13.0", - "@typescript-eslint/types": "6.13.0", - "@typescript-eslint/typescript-estree": "6.13.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.0.tgz", - "integrity": "sha512-UQklteCEMCRoq/1UhKFZsHv5E4dN1wQSzJoxTfABasWk1HgJRdg1xNUve/Kv/Sdymt4x+iEzpESOqRFlQr/9Aw==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.13.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.19.0.tgz", - "integrity": "sha512-1DyBLG5SH7PYCd00QlroiW60YJ4rWMuUGa/JBV0iZuqi4l4IK3twKPq5ZkEebmGqRjXWVgsUzfd3+nZveewgow==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.28.0.tgz", + "integrity": "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.19.0.tgz", - "integrity": "sha512-dO1XMhV2ehBI6QN8Ufi7I10wmUovmLU0Oru3n5LVlM2JuzB4M+dVphCPLkVpKvGij2j/pHBWuJ9piuXx+BhzxQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.28.0.tgz", + "integrity": "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0" + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -4969,39 +4155,35 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.19.0.tgz", - "integrity": "sha512-mcvS6WSWbjiSxKCwBcXtOM5pRkPQ6kcDds/juxcy/727IQr3xMEcwr/YLHW2A2+Fp5ql6khjbKBzOyjuPqGi/w==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.28.0.tgz", + "integrity": "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.19.0", - "@typescript-eslint/utils": "6.19.0", + "@typescript-eslint/typescript-estree": "8.28.0", + "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.19.0.tgz", - "integrity": "sha512-lFviGV/vYhOy3m8BJ/nAKoAyNhInTdXpftonhWle66XHAtT1ouBlkjL496b5H5hb8dWXHwtypTqgtb/DEa+j5A==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.28.0.tgz", + "integrity": "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", @@ -5009,319 +4191,108 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.19.0.tgz", - "integrity": "sha512-o/zefXIbbLBZ8YJ51NlkSAt2BamrK6XOmuxSR3hynMIzzyMY33KuJ9vuMdFSXW+H0tVvdF9qBPTHA91HDb4BIQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.28.0.tgz", + "integrity": "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/visitor-keys": "6.19.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", - "globby": "^11.1.0", + "fast-glob": "^3.3.2", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.19.0.tgz", - "integrity": "sha512-QR41YXySiuN++/dC9UArYOg4X86OAYP83OWTewpVx5ct1IZhjjgTLocj7QNxGhWoTqknsgpl7L+hGygCO+sdYw==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.28.0.tgz", + "integrity": "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.19.0", - "@typescript-eslint/types": "6.19.0", - "@typescript-eslint/typescript-estree": "6.19.0", - "semver": "^7.5.4" + "@typescript-eslint/scope-manager": "8.28.0", + "@typescript-eslint/types": "8.28.0", + "@typescript-eslint/typescript-estree": "8.28.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.19.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.19.0.tgz", - "integrity": "sha512-hZaUCORLgubBvtGpp1JEFEazcuEdfxta9j4iUwdSAr7mEsYYAp3EAUyCZk3VEEqGj6W+AV4uWyrDGtrlawAsgQ==", + "version": "8.28.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.28.0.tgz", + "integrity": "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.19.0", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "8.28.0", + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.0.2.tgz", - "integrity": "sha512-DKHKVtpI+eA5fvObVgQ3QtTGU70CcCnedalzqmGSR050AzKZMdUzgC8KmlOneHWH8dF2hJ3wkC9+8FDVAaDRCw==", + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, "engines": { - "node": ">=14.6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.2.0.tgz", + "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", + "dev": true, + "engines": { + "node": ">=14.21.3" }, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", - "dev": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", - "dev": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", - "dev": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", - "dev": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", - "dev": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", - "dev": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", - "dev": true, - "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true - }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true }, - "node_modules/@yarnpkg/parsers": { - "version": "3.0.0-rc.46", - "resolved": "https://registry.npmjs.org/@yarnpkg/parsers/-/parsers-3.0.0-rc.46.tgz", - "integrity": "sha512-aiATs7pSutzda/rq8fnuPwTglyVwjM22bNnK2ZgjrpAjQHSSl3lztd2f9evst1W/qnC58DRz7T7QndUDumAR4Q==", - "dev": true, - "dependencies": { - "js-yaml": "^3.10.0", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.15.0" - } - }, - "node_modules/@zkochan/js-yaml": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz", - "integrity": "sha512-nzvgl3VfhcELQ8LyVrYOru+UtAy1nrygk2+AGbTm8a5YcO6o8lSjAT+pfg3vJWxIoZKOUhrK6UU7xW/+00kQrg==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@zkochan/js-yaml/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, "node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", + "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/abort-controller": { @@ -5335,23 +4306,10 @@ "node": ">=6.5" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -5360,15 +4318,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "dev": true, - "peerDependencies": { - "acorn": "^8" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -5379,76 +4328,33 @@ } }, "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", "dev": true, "engines": { "node": ">=0.4.0" } }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, "node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5456,9 +4362,9 @@ } }, "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, "dependencies": { "ajv": "^8.0.0" @@ -5472,27 +4378,6 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -5508,18 +4393,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "bin": { - "ansi-html": "bin/ansi-html" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -5529,27 +4402,17 @@ } }, "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dependencies": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "node": ">=8" }, - "engines": { - "node": ">= 8" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/arg": { @@ -5559,191 +4422,26 @@ "dev": true }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "engines": { - "node": ">=8" - } - }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/autoprefixer": { - "version": "10.4.16", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", - "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001538", - "fraction.js": "^4.3.6", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 0.4" } }, "node_modules/axobject-query": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", - "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/babel-loader": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.3.tgz", - "integrity": "sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==", - "dev": true, - "dependencies": { - "find-cache-dir": "^4.0.0", - "schema-utils": "^4.0.0" - }, "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0", - "webpack": ">=5" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", - "integrity": "sha512-OtIuQfafSzpo/LhnJaykc0R/MMnuLSSVjVYy9mHArIZ9qTCSZ6TpWCuEKZYVoN//t8HqBNScHrOtCrIK5IaGLg==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.5.0", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.7.tgz", - "integrity": "sha512-KyDvZYxAzkC0Aj2dAPyDzi2Ym15e5JKZSK+maI7NAwSqofvuFglbSsxE7wUOvTg9oFVnHMzVzBKcqEb4PJgtOA==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.4.4", - "core-js-compat": "^3.33.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3/node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.4.tgz", - "integrity": "sha512-QcJMILQCu2jm5TFPGA3lCpJJTeEP+mqeXooG/NZbg/h5FTFi6V0+99ahlRsW8/kRLyb24LZVCCiclDedhLKcBA==", - "dev": true, - "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.5.tgz", - "integrity": "sha512-OJGYZlhLqBh2DDHeqAxWB1XIvr49CxiJ2gIt61/PU55CQK4Z58OzMqjDe1zwQdQk+rBYsRc+1rJmdajM3gimHg==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.5.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "node": ">= 0.4" } }, "node_modules/balanced-match": { @@ -5770,28 +4468,23 @@ } ] }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "node_modules/beasties": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.2.0.tgz", + "integrity": "sha512-Ljqskqx/tbZagIglYoJIMzH5zgssyp+in9+9sAyh15N22AornBeIDnb8EZ6Rk+6ShfMxd92uO3gfpT0NtZbpow==", "dev": true, + "dependencies": { + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "htmlparser2": "^9.1.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3" + }, "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, "node_modules/bl": { @@ -5804,64 +4497,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/bonjour-service": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", - "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -5869,9 +4504,9 @@ "dev": true }, "node_modules/bootstrap": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.2.tgz", - "integrity": "sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", "funding": [ { "type": "github", @@ -5887,30 +4522,28 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^1.0.0" } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "funding": [ { "type": "opencollective", @@ -5926,10 +4559,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -5967,31 +4600,13 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/builtins": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz", - "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==", - "dev": true, - "dependencies": { - "semver": "^7.0.0" - } - }, - "node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cacache": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz", - "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", "dev": true, "dependencies": { - "@npmcli/fs": "^3.1.0", + "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", @@ -5999,36 +4614,69 @@ "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, - "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", "dev": true, - "dependencies": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/callsites": { @@ -6039,19 +4687,10 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001707", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz", + "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==", "funding": [ { "type": "opencollective", @@ -6068,16 +4707,18 @@ ] }, "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/chardet": { @@ -6091,33 +4732,6 @@ "resolved": "https://registry.npmjs.org/charts.css/-/charts.css-1.1.0.tgz", "integrity": "sha512-K1Qyb8ZKsu5cDrVbZeHECk/xSq6iOl8IDTR35uaMdhr/Vyyxvg9nYQy3KNB3aidxJ2E251afX5q2725N0uL3Vw==" }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -6127,24 +4741,6 @@ "node": ">=10" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", - "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", - "dev": true, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6167,6 +4763,72 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -6189,6 +4851,22 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -6197,32 +4875,21 @@ "node": ">=0.8" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" + "color-name": "~1.1.4" }, "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/colorette": { "version": "2.0.20", @@ -6230,236 +4897,18 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", - "dev": true, - "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/compression/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true - }, - "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", - "dev": true, - "dependencies": { - "is-what": "^3.14.1" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/copy-webpack-plugin": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", - "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", - "dev": true, - "dependencies": { - "fast-glob": "^3.2.11", - "glob-parent": "^6.0.1", - "globby": "^13.1.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.0.0", - "serialize-javascript": "^6.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/globby": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", - "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", - "dev": true, - "dependencies": { - "dir-glob": "^3.0.1", - "fast-glob": "^3.3.0", - "ignore": "^5.2.4", - "merge2": "^1.4.1", - "slash": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/copy-webpack-plugin/node_modules/slash": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", - "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/core-js-compat": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.35.1.tgz", - "integrity": "sha512-sftHa5qUJY3rs9Zht1WEnmkvXputCyDBczPnr7QDgL8n3qrF3CMXY4VPSYtOLLiOUJcah2WNXREd48iOl6mQIw==", - "dev": true, - "dependencies": { - "browserslist": "^4.22.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", @@ -6485,117 +4934,16 @@ } } }, - "node_modules/cosmiconfig/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, - "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, - "node_modules/critters": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.20.tgz", - "integrity": "sha512-CImNRorKOl5d8TWcnAz5n5izQ6HFsvz29k327/ELy6UFcmbiZNOsinaKvzv16WZR0P6etfSWYzE47C4/56B3Uw==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "css-select": "^5.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.2", - "htmlparser2": "^8.0.2", - "postcss": "^8.4.23", - "pretty-bytes": "^5.3.0" - } - }, - "node_modules/critters/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/critters/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/critters/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/critters/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/critters/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/critters/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6605,32 +4953,6 @@ "node": ">= 8" } }, - "node_modules/css-loader": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.8.1.tgz", - "integrity": "sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==", - "dev": true, - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.21", - "postcss-modules-extract-imports": "^3.0.0", - "postcss-modules-local-by-default": "^4.0.3", - "postcss-modules-scope": "^3.0.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -6659,18 +4981,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -6849,34 +5159,16 @@ } }, "node_modules/d3-time-format": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", - "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", "dependencies": { - "d3-time": "1 - 2" + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" } }, - "node_modules/d3-time-format/node_modules/d3-array": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", - "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "dependencies": { - "internmap": "^1.0.0" - } - }, - "node_modules/d3-time-format/node_modules/d3-time": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", - "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", - "dependencies": { - "d3-array": "2" - } - }, - "node_modules/d3-time-format/node_modules/internmap": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", - "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" - }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -6910,11 +5202,11 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -6931,18 +5223,6 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, - "node_modules/default-gateway": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", - "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", - "dev": true, - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -6954,76 +5234,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-it": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/detect-it/-/detect-it-4.0.1.tgz", "integrity": "sha512-dg5YBTJYvogK1+dA2mBUDKzOWfYZtHVba89SyZUhc4+e3i2tzgjANFg5lDRCd3UOtRcw00vUTMK8LELcMdicug==" }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "optional": true, + "engines": { + "node": ">=8" + } }, "node_modules/detect-passive-events": { "version": "2.0.3", @@ -7042,51 +5266,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7137,9 +5316,9 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, "dependencies": { "dom-serializer": "^2.0.0", @@ -7150,27 +5329,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" - } - }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -7182,55 +5340,16 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true - }, - "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", - "dev": true, - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { - "version": "1.4.640", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.640.tgz", - "integrity": "sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==" + "version": "1.5.46", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.46.tgz", + "integrity": "sha512-1XDk0Z8/YRgB2t5GeEg8DPK592DLjVmd/5uwAu6c/S4Z0CUwV/RwYqe5GWxQqcoN3bJ5U7hYMiMRPZzpCzSBhQ==" }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/encoding": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", @@ -7254,45 +5373,10 @@ "node": ">=0.10.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "dev": true, - "dependencies": { - "ansi-colors": "^4.1.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "devOptional": true, "engines": { "node": ">=0.12" }, @@ -7309,25 +5393,24 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "optional": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7336,150 +5419,125 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-module-lexer": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", - "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", - "dev": true - }, "node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", + "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" - } - }, - "node_modules/esbuild-wasm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.19.11.tgz", - "integrity": "sha512-MIhnpc1TxERUHomteO/ZZHp+kUawGEc03D/8vMHGzffLvbFLeDe6mwxqEZwlqBNY7SLWbyp6bBQAcCen8+wpjQ==", - "dev": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" + "@esbuild/aix-ppc64": "0.25.1", + "@esbuild/android-arm": "0.25.1", + "@esbuild/android-arm64": "0.25.1", + "@esbuild/android-x64": "0.25.1", + "@esbuild/darwin-arm64": "0.25.1", + "@esbuild/darwin-x64": "0.25.1", + "@esbuild/freebsd-arm64": "0.25.1", + "@esbuild/freebsd-x64": "0.25.1", + "@esbuild/linux-arm": "0.25.1", + "@esbuild/linux-arm64": "0.25.1", + "@esbuild/linux-ia32": "0.25.1", + "@esbuild/linux-loong64": "0.25.1", + "@esbuild/linux-mips64el": "0.25.1", + "@esbuild/linux-ppc64": "0.25.1", + "@esbuild/linux-riscv64": "0.25.1", + "@esbuild/linux-s390x": "0.25.1", + "@esbuild/linux-x64": "0.25.1", + "@esbuild/netbsd-arm64": "0.25.1", + "@esbuild/netbsd-x64": "0.25.1", + "@esbuild/openbsd-arm64": "0.25.1", + "@esbuild/openbsd-x64": "0.25.1", + "@esbuild/sunos-x64": "0.25.1", + "@esbuild/win32-arm64": "0.25.1", + "@esbuild/win32-ia32": "0.25.1", + "@esbuild/win32-x64": "0.25.1" } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "engines": { "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz", - "integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", + "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.54.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.2", + "@eslint/config-helpers": "^0.2.0", + "@eslint/core": "^0.12.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.23.0", + "@eslint/plugin-kit": "^0.2.7", + "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", + "cross-spawn": "^7.0.6", "debug": "^4.3.2", - "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", + "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" + "optionator": "^0.9.3" }, "bin": { "eslint": "bin/eslint.js" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7513,61 +5571,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -7580,6 +5593,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint/node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -7608,42 +5633,6 @@ "node": ">=10.13.0" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7665,6 +5654,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/eslint/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7695,58 +5696,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", "dev": true, "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^4.2.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, "engines": { - "node": ">=4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { @@ -7791,15 +5767,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -7809,20 +5776,11 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -7831,96 +5789,10 @@ "node": ">=12.0.0" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", - "dev": true - }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "dev": true, - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "dev": true }, "node_modules/external-editor": { @@ -7937,18 +5809,6 @@ "node": ">=4" } }, - "node_modules/external-editor/node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7956,15 +5816,15 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -7982,85 +5842,39 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true + }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dependencies": { "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/fetch-cookie": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.1.0.tgz", - "integrity": "sha512-39+cZRbWfbibmj22R2Jy6dmTbAWC+oqun1f1FzQaNurkPDUP4C38jpeZbiXCR88RKRVDp8UcDrbFXkNhN+NjYg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", "dependencies": { "set-cookie-parser": "^2.4.8", "tough-cookie": "^4.0.0" } }, - "node_modules/figures": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", - "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^5.0.0", - "is-unicode-supported": "^1.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "dependencies": { - "flat-cache": "^3.0.4" + "flat-cache": "^4.0.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" } }, "node_modules/file-saver": { @@ -8068,40 +5882,10 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -8109,116 +5893,25 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/find-cache-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", - "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", - "dev": true, - "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^7.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "dependencies": { - "flatted": "^3.1.0", - "rimraf": "^3.0.2" + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16" } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true }, - "node_modules/follow-redirects": { - "version": "1.15.4", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", - "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -8234,61 +5927,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true - }, "node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8310,12 +5952,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/fs-monkey": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.4.tgz", - "integrity": "sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ==", - "dev": true - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8360,52 +5996,28 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "node_modules/get-east-asian-width": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz", + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==", "dev": true, "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "version": "10.3.12", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", + "integrity": "sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", + "jackspeak": "^2.3.6", "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "minipass": "^7.0.4", + "path-scurry": "^1.10.2" }, "bin": { "glob": "dist/esm/bin.mjs" @@ -8434,28 +6046,6 @@ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -8464,43 +6054,19 @@ "node": ">=4" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gradient-path": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/gradient-path/-/gradient-path-2.3.0.tgz", + "integrity": "sha512-vZdF/Z0EpqUztzWXFjFC16lqcialHacYoRonslk/bC6CuujkuIrqx7etlzdYHY4SnUU94LRWESamZKfkGh7yYQ==", + "dependencies": { + "tinygradient": "^1.0.0" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -8522,60 +6088,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true - }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "engines": { - "node": ">=4" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", - "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", - "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -8584,102 +6108,24 @@ "node": ">= 0.4" } }, - "node_modules/hdr-histogram-js": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz", - "integrity": "sha512-Hkn78wwzWHNCp2uarhzQ2SGFLU3JY8SBDDd3TAABK4fc30wm+MuPOrg5QVFVfkKOQd6Bfz3ukJEI+q9sXEkK1g==", - "dev": true, - "dependencies": { - "@assemblyscript/loader": "^0.10.1", - "base64-js": "^1.2.0", - "pako": "^1.0.3" - } - }, - "node_modules/hdr-histogram-percentiles-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hdr-histogram-percentiles-obj/-/hdr-histogram-percentiles-obj-3.0.0.tgz", - "integrity": "sha512-7kIufnBqdsBGcSZLPJwqHT3yhk1QTsSlFsVD3kx5ixH/AlgBs9yM1q6DPhXZ8f8gtdqgh7N7/5btRLpQsS2gHw==", - "dev": true - }, "node_modules/hosted-git-info": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", - "integrity": "sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.0.2.tgz", + "integrity": "sha512-sYKnA7eGln5ov8T8gnYlkSOxFJvywzEx9BueN6xo/GKO8PGiI6uK6xx+DIGe45T3bdVjLAQDQW1aicT8z8JwQg==", "dev": true, "dependencies": { "lru-cache": "^10.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-entities": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/mdevils" - }, - { - "type": "patreon", - "url": "https://patreon.com/mdevils" - } - ] - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8687,9 +6133,9 @@ "dev": true }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", @@ -8701,8 +6147,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, "node_modules/http-cache-semantics": { @@ -8711,52 +6157,10 @@ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", "dev": true }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", - "dev": true - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz", - "integrity": "sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "dependencies": { "agent-base": "^7.1.0", @@ -8766,52 +6170,19 @@ "node": ">= 14" } }, - "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", - "dev": true, - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, "node_modules/https-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", - "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -8824,18 +6195,6 @@ "node": ">=0.10.0" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -8856,67 +6215,30 @@ ] }, "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/ignore-walk": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz", - "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-7.0.0.tgz", + "integrity": "sha512-T4gbf83A4NH95zvhVYZc+qWocBBGlpzUXLPGurJggw/WIOwicfXJChLDP/iBZnN5WqROSu5Bm3hhle4z8a8YGQ==", "dev": true, "dependencies": { "minimatch": "^9.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/ignore-walk/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "optional": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/immutable": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", - "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "dev": true }, "node_modules/import-fresh": { @@ -8951,15 +6273,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8975,97 +6288,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/inquirer": { - "version": "9.2.12", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.12.tgz", - "integrity": "sha512-mg3Fh9g2zfuVWJn6lhST0O7x4n03k7G8Tx5nvikJkbq8/CK47WDVm+UznF0G6s5Zi0KcyUisr6DU8T67N5U+1Q==", - "dev": true, - "dependencies": { - "@ljharb/through": "^2.3.11", - "ansi-escapes": "^4.3.2", - "chalk": "^5.3.0", - "cli-cursor": "^3.1.0", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "figures": "^5.0.0", - "lodash": "^4.17.21", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/inquirer/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/inquirer/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/inquirer/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/inquirer/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/inquirer/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/internmap": { @@ -9076,19 +6304,17 @@ "node": ">=12" } }, - "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", - "dev": true - }, - "node_modules/ipaddr.js": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", - "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", "dev": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, "engines": { - "node": ">= 10" + "node": ">= 12" } }, "node_modules/is-arrayish": { @@ -9096,45 +6322,21 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "dependencies": { - "binary-extensions": "^2.0.0" + "hasown": "^2.0.2" }, "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9170,12 +6372,6 @@ "node": ">=8" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9184,51 +6380,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -9240,48 +6391,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "engines": { "node": ">=8" @@ -9313,38 +6431,17 @@ } }, "node_modules/istanbul-lib-report": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", - "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "dependencies": { "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^3.0.0", + "make-dir": "^4.0.0", "supports-color": "^7.1.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": ">=10" } }, "node_modules/istanbul-lib-source-maps": { @@ -9371,9 +6468,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", - "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, "dependencies": { "html-escaper": "^2.0.0", @@ -9400,268 +6497,53 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jake": { - "version": "10.8.7", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", - "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", - "dev": true, - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jake/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jake/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jake/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jake/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jake/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jake/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-diff/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-diff/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-diff/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-diff/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-diff/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true + }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-4.0.0.tgz", + "integrity": "sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==", + "dev": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -9687,9 +6569,9 @@ } }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true }, "node_modules/jsonfile": { @@ -9739,121 +6621,35 @@ "node": ">=10.0.0" } }, - "node_modules/karma-source-map-support": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", - "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", + "node_modules/karma-coverage/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "source-map-support": "^0.5.5" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/klona": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", - "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/launch-editor": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", - "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "node_modules/karma-coverage/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { - "picocolors": "^1.0.0", - "shell-quote": "^1.8.1" + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "node_modules/less": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", - "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=6" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" - } - }, - "node_modules/less-loader": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/less-loader/-/less-loader-11.1.0.tgz", - "integrity": "sha512-C+uDBV7kS7W5fJlUjq5mPBeBVhYpTIm5gB09APT9o3n/ILeaXVsiSFTbZpTJCJwQ/Crczfn3DmfQFwxYusWFug==", - "dev": true, - "dependencies": { - "klona": "^2.0.4" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "less": "^3.5.0 || ^4.0.0", - "webpack": "^5.0.0" - } - }, - "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "optional": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "optional": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" + "json-buffer": "3.0.1" } }, "node_modules/levn": { @@ -9869,78 +6665,127 @@ "node": ">= 0.8.0" } }, - "node_modules/license-webpack-plugin": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", - "integrity": "sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==", + "node_modules/listr2": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.5.tgz", + "integrity": "sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==", "dev": true, "dependencies": { - "webpack-sources": "^3.0.0" + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-sources": { - "optional": true - } + "engines": { + "node": ">=18.0.0" } }, - "node_modules/lines-and-columns": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", - "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "engines": { - "node": ">=6.11.5" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/loader-utils": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", - "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", - "dev": true, - "engines": { - "node": ">= 12.13.0" - } + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "dependencies": { - "p-locate": "^4.1.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } }, - "node_modules/lodash.deburr": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz", - "integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==" + "node_modules/lmdb": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.2.6.tgz", + "integrity": "sha512-SuHqzPl7mYStna8WRotY8XX/EUZBjjv3QyKIByeCLFfC9uXT/OIHByEcA07PzbMfQAM0KYJtLgtpMRlIe5dErQ==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "msgpackr": "^1.11.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.5.3", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.2.6", + "@lmdb/lmdb-darwin-x64": "3.2.6", + "@lmdb/lmdb-linux-arm": "3.2.6", + "@lmdb/lmdb-linux-arm64": "3.2.6", + "@lmdb/lmdb-linux-x64": "3.2.6", + "@lmdb/lmdb-win32-x64": "3.2.6" + } }, "node_modules/lodash.kebabcase": { "version": "4.1.1", @@ -9968,68 +6813,194 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, "dependencies": { - "color-name": "~1.1.4" + "get-east-asian-width": "^1.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "dependencies": { + "mimic-function": "^5.0.0" + }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/supports-color": { + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/lru-cache": { @@ -10041,49 +7012,37 @@ } }, "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", "engines": { "node": ">=12" } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "dependencies": { - "semver": "^6.0.0" + "semver": "^7.5.3" }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -10091,60 +7050,27 @@ "dev": true }, "node_modules/make-fetch-happen": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz", - "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", "dev": true, "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", + "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "ssri": "^10.0.0" + "ssri": "^12.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "dev": true, - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "dev": true - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10153,58 +7079,27 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "bin": { - "mime": "cli.js" + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/mimic-fn": { @@ -10215,56 +7110,36 @@ "node": ">=6" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.7.6", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz", - "integrity": "sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, - "dependencies": { - "schema-utils": "^4.0.0" - }, "engines": { - "node": ">= 12.13.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, + "node": ">=16 || 14 >=14.17" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { "node": ">=16 || 14 >=14.17" } @@ -10282,17 +7157,17 @@ } }, "node_modules/minipass-fetch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", - "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", "dev": true, "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" + "minizlib": "^3.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -10328,34 +7203,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/minipass-json-stream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", - "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", - "dev": true, - "dependencies": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "node_modules/minipass-json-stream/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-json-stream/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", @@ -10417,36 +7264,33 @@ "dev": true }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", "dev": true, "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.0.4", + "rimraf": "^5.0.5" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", "dev": true, "dependencies": { - "yallist": "^4.0.0" + "glob": "^10.3.7" }, - "engines": { - "node": ">=8" + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -10460,45 +7304,64 @@ } }, "node_modules/mrmime": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", - "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, "engines": { "node": ">=10" } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "node_modules/msgpackr": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", "dev": true, + "optional": true, + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "dev": true, + "hasInstallScript": true, + "optional": true, "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" + "node-gyp-build-optional-packages": "5.2.2" }, "bin": { - "multicast-dns": "cli.js" + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" } }, "node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -10519,62 +7382,15 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/needle": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.2.0.tgz", - "integrity": "sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==", - "dev": true, - "optional": true, - "dependencies": { - "debug": "^3.2.6", - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "optional": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "dev": true, "engines": { "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true - }, "node_modules/ng-circle-progress": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/ng-circle-progress/-/ng-circle-progress-1.7.1.tgz", @@ -10602,23 +7418,23 @@ } }, "node_modules/ng-select2-component": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-14.0.0.tgz", - "integrity": "sha512-AYfF3Hyc+RtryoWIpBVBOBqjF6wKFB6KfzYVrtENEqv6W3qDRRQDfm17CFXm1UZxgoprlsYOeefo4aD4R06s5g==", + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.4.tgz", + "integrity": "sha512-pfRQg1gY1NsQkBNAYYeSYJjejKwz1z+9bKWor8/8toCNbvh9TYMOKpcz3FrNvhR6v/Hto/quddajaxjD81TOgg==", "dependencies": { - "ngx-infinite-scroll": ">=17.0.0", + "ngx-infinite-scroll": ">=18.0.0 || >=19.0.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": ">=17.0.0", - "@angular/common": ">=17.0.0", - "@angular/core": ">=17.0.0" + "@angular/cdk": ">=18.1.0 || >=19.0.0", + "@angular/common": ">=18.1.0 || >=19.0.0", + "@angular/core": ">=18.1.0 || >=19.0.0" } }, "node_modules/ngx-color-picker": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-16.0.0.tgz", - "integrity": "sha512-Dk2FvcbebD6STZSVzkI5oFHOlTrrNC5bOHh+YVaFgaWuWrVUdVIJm68ocUvTgr/qxTEJjrfcnRnS4wi7BJ2hKg==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-19.0.0.tgz", + "integrity": "sha512-jZs7nk/DJB6FryElYnfkojWYCgpEc650s800g+39ebocVMZ18fAHf/CQd5+Bdm4E3zoRod0a0sErJ+c8tGQcCg==", "dependencies": { "tslib": "^2.3.0" }, @@ -10629,16 +7445,15 @@ } }, "node_modules/ngx-extended-pdf-viewer": { - "version": "18.1.14", - "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-18.1.14.tgz", - "integrity": "sha512-8nz0sQWPn3BrN8rLy0vHrORZ3FJWPKDBt2eOJANxTmEKr0mkVECHqOoK47EfZMrc/+zwCzJkzTskA9w3CzJM/A==", + "version": "23.0.0-alpha.7", + "resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.0.0-alpha.7.tgz", + "integrity": "sha512-S5jI9Z6p6wglLwvpf85MddxGKYUiJczb02nZcFWztDSZ7BlKXkjdtssW+chBOc/sg46p2kTDoa0M/R07yqRFcA==", "dependencies": { - "lodash.deburr": "^4.1.0", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=12.0.0 <18.0.0", - "@angular/core": ">=12.0.0 <18.0.0" + "@angular/common": ">=17.0.0 <20.0.0", + "@angular/core": ">=17.0.0 <20.0.0" } }, "node_modules/ngx-file-drop": { @@ -10658,30 +7473,15 @@ } }, "node_modules/ngx-infinite-scroll": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-17.0.0.tgz", - "integrity": "sha512-pQXLuRiuhRuDKD3nmgyW1V08JVNBepmk6nb8qjHc5hgsWNts01+R/p33rYcRDzcut6/PWqGyrZ9o9i8swzMYMA==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-infinite-scroll/-/ngx-infinite-scroll-19.0.0.tgz", + "integrity": "sha512-Ft4xNNDLXoDGi2hF6ylehjxbG8JIgfoL6qDWWcebGMcbh1CEfEsh0HGkDuFlX/cBBMenRh2HFbXlYq8BAtbvLw==", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=17.0.0 <18.0.0", - "@angular/core": ">=17.0.0 <18.0.0" - } - }, - "node_modules/ngx-slider-v2": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/ngx-slider-v2/-/ngx-slider-v2-17.0.0.tgz", - "integrity": "sha512-226pb8TeZWhmMiQQnPVBZmos2qZua2NgMsuwumxatIBy7UwOGg8xkcUl8HvXVg+rO5w733uvPMN/yz//7rReJw==", - "dependencies": { - "detect-passive-events": "^2.0.3", - "rxjs": "^7.8.1", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^17.0.3", - "@angular/core": "^17.0.3", - "@angular/forms": "^17.0.3" + "@angular/common": ">=19.0.0 <20.0.0", + "@angular/core": ">=19.0.0 <20.0.0" } }, "node_modules/ngx-stars": { @@ -10696,23 +7496,10 @@ "@angular/core": ">=2.0.0" } }, - "node_modules/ngx-toaster": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ngx-toaster/-/ngx-toaster-1.0.1.tgz", - "integrity": "sha512-2XxTCT7+EWffb8wDpMLiFhwwZJ4B36Y1RM4m3rgG2cCt/8edsj3UzvqZjapF5wKwB+Jz8lVuVYJ94Hztcj83Cg==", - "peerDependencies": { - "@angular/common": ">=4.3.0 || >5.0.0", - "@angular/compiler": ">=4.3.0 || >5.0.0", - "@angular/core": ">=4.3.0 || >5.0.0", - "@angular/forms": ">=4.3.0 || >5.0.0", - "rxjs": ">=5.4.3", - "typescript": ">=2.3.0" - } - }, "node_modules/ngx-toastr": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-18.0.0.tgz", - "integrity": "sha512-jZ3rOG6kygl8ittY8OltIMSo47P1VStuS01igm3MZXK6InJwHVvxU7wDHI/HGMlXSyNvWncyOuFHnnMEAifsew==", + "version": "19.0.0", + "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-19.0.0.tgz", + "integrity": "sha512-6pTnktwwWD+kx342wuMOWB4+bkyX9221pAgGz3SHOJH0/MI9erLucS8PeeJDFwbUYyh75nQ6AzVtolgHxi52dQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -10722,32 +7509,17 @@ "@angular/platform-browser": ">=16.0.0-0" } }, - "node_modules/nice-napi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", - "integrity": "sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "!win32" - ], - "dependencies": { - "node-addon-api": "^3.0.0", - "node-gyp-build": "^4.2.2" - } - }, "node_modules/node-addon-api": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", - "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, "optional": true }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -10763,49 +7535,52 @@ } } }, - "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", - "dev": true, - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-gyp": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz", - "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.1.0.tgz", + "integrity": "sha512-/+7TuHKnBpnMvUQnsYEb0JOozDZqarQbfNuSGLXIjhStMT0fbw7IdSqWgopOP5xhRZE+lsbIvAHcekddruPZgQ==", "dev": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^10.3.10", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^3.0.0", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^4.0.0" + "tar": "^7.4.3", + "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.0.tgz", - "integrity": "sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==", + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", "dev": true, "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "engines": { + "node": ">=18" } }, "node_modules/node-gyp/node_modules/isexe": { @@ -10817,10 +7592,42 @@ "node": ">=16" } }, + "node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-gyp/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "dependencies": { "isexe": "^3.1.1" @@ -10829,66 +7636,36 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-machine-id": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", - "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", - "dev": true + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "engines": { + "node": ">=18" + } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" }, "node_modules/nopt": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz", - "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "dev": true, "dependencies": { - "abbrev": "^2.0.0" + "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.0.tgz", - "integrity": "sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg==", - "dev": true, - "dependencies": { - "hosted-git-info": "^7.0.0", - "is-core-module": "^2.8.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nosleep.js": { @@ -10897,108 +7674,97 @@ "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==" }, "node_modules/npm-bundled": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", - "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-4.0.0.tgz", + "integrity": "sha512-IxaQZDMsqfQ2Lz37VvyyEtKLe8FsRZuysmedy/N06TU1RyVppYKXrO4xIhR0F+7ubIBox6Q7nir6fQI3ej39iA==", "dev": true, "dependencies": { - "npm-normalize-package-bin": "^3.0.0" + "npm-normalize-package-bin": "^4.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-install-checks": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-7.1.1.tgz", + "integrity": "sha512-u6DCwbow5ynAX5BdiHQ9qvexme4U3qHW3MWe5NqH+NeBm0LbiH6zvGjNNew1fY+AZZUtVHbOPF3j7mJxbUzpXg==", "dev": true, "dependencies": { "semver": "^7.1.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-package-arg": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.1.tgz", - "integrity": "sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", + "integrity": "sha512-f1NpFjNI9O4VbKMOlA5QoBq/vSQPORHcTZ2feJpFkTHJ9eQkdlmZEKSjcAhxTGInC7RlEyScT9ui67NaOsjFWA==", "dev": true, "dependencies": { - "hosted-git-info": "^7.0.0", - "proc-log": "^3.0.0", + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" + "validate-npm-package-name": "^6.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-packlist": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-8.0.2.tgz", - "integrity": "sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-9.0.0.tgz", + "integrity": "sha512-8qSayfmHJQTx3nJWYbbUmflpyarbLMBc6LCAjYsiGtXxDB68HaZpb8re6zeaLGxZzDuMdhsg70jryJe+RrItVQ==", "dev": true, "dependencies": { - "ignore-walk": "^6.0.4" + "ignore-walk": "^7.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-pick-manifest": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz", - "integrity": "sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-10.0.0.tgz", + "integrity": "sha512-r4fFa4FqYY8xaM7fHecQ9Z2nE9hgNfJR+EmoKv0+chvzWkBcORX3r0FpTByP+CbOVJDladMXnPQGVN8PBLGuTQ==", "dev": true, "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^11.0.0", + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", "semver": "^7.3.5" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/npm-registry-fetch": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz", - "integrity": "sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw==", + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-18.0.2.tgz", + "integrity": "sha512-LeVMZBBVy+oQb5R6FDV9OlJCcWDU+al10oKpe+nsvcHnG24Z3uM3SvJYKfGJlfGjVU8v9liejCrUR/M5HO5NEQ==", "dev": true, "dependencies": { - "make-fetch-happen": "^13.0.0", + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^11.0.0", - "proc-log": "^3.0.0" + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/nth-check": { @@ -11013,289 +7779,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nx": { - "version": "17.2.8", - "resolved": "https://registry.npmjs.org/nx/-/nx-17.2.8.tgz", - "integrity": "sha512-rM5zXbuXLEuqQqcjVjClyvHwRJwt+NVImR2A6KFNG40Z60HP6X12wAxxeLHF5kXXTDRU0PFhf/yACibrpbPrAw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@nrwl/tao": "17.2.8", - "@yarnpkg/lockfile": "^1.1.0", - "@yarnpkg/parsers": "3.0.0-rc.46", - "@zkochan/js-yaml": "0.0.6", - "axios": "^1.5.1", - "chalk": "^4.1.0", - "cli-cursor": "3.1.0", - "cli-spinners": "2.6.1", - "cliui": "^8.0.1", - "dotenv": "~16.3.1", - "dotenv-expand": "~10.0.0", - "enquirer": "~2.3.6", - "figures": "3.2.0", - "flat": "^5.0.2", - "fs-extra": "^11.1.0", - "glob": "7.1.4", - "ignore": "^5.0.4", - "jest-diff": "^29.4.1", - "js-yaml": "4.1.0", - "jsonc-parser": "3.2.0", - "lines-and-columns": "~2.0.3", - "minimatch": "3.0.5", - "node-machine-id": "1.1.12", - "npm-run-path": "^4.0.1", - "open": "^8.4.0", - "semver": "7.5.3", - "string-width": "^4.2.3", - "strong-log-transformer": "^2.1.0", - "tar-stream": "~2.2.0", - "tmp": "~0.2.1", - "tsconfig-paths": "^4.1.2", - "tslib": "^2.3.0", - "yargs": "^17.6.2", - "yargs-parser": "21.1.1" - }, - "bin": { - "nx": "bin/nx.js", - "nx-cloud": "bin/nx-cloud.js" - }, - "optionalDependencies": { - "@nx/nx-darwin-arm64": "17.2.8", - "@nx/nx-darwin-x64": "17.2.8", - "@nx/nx-freebsd-x64": "17.2.8", - "@nx/nx-linux-arm-gnueabihf": "17.2.8", - "@nx/nx-linux-arm64-gnu": "17.2.8", - "@nx/nx-linux-arm64-musl": "17.2.8", - "@nx/nx-linux-x64-gnu": "17.2.8", - "@nx/nx-linux-x64-musl": "17.2.8", - "@nx/nx-win32-arm64-msvc": "17.2.8", - "@nx/nx-win32-x64-msvc": "17.2.8" - }, - "peerDependencies": { - "@swc-node/register": "^1.6.7", - "@swc/core": "^1.3.85" - }, - "peerDependenciesMeta": { - "@swc-node/register": { - "optional": true - }, - "@swc/core": { - "optional": true - } - } - }, - "node_modules/nx/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/nx/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/nx/node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", - "dev": true, - "dependencies": { - "follow-redirects": "^1.15.4", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/nx/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/nx/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/nx/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/nx/node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/nx/node_modules/glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/nx/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/nx/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/nx/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/nx/node_modules/minimatch": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz", - "integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/nx/node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/nx/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nx/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -11318,23 +7801,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -11383,69 +7849,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ora/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ora/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/ora/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/ora/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/ora/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/ordered-binary": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.3.tgz", + "integrity": "sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA==", + "dev": true, + "optional": true }, "node_modules/os-tmpdir": { "version": "1.0.2", @@ -11456,117 +7865,49 @@ "node": ">=0.10.0" } }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "dev": true, - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/pacote": { - "version": "17.0.5", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-17.0.5.tgz", - "integrity": "sha512-TAE0m20zSDMnchPja9vtQjri19X3pZIyRpm2TJVeI+yU42leJBBDTRYhOcWFsPhaMxf+3iwQkFiKz16G9AEeeA==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-20.0.0.tgz", + "integrity": "sha512-pRjC5UFwZCgx9kUFDVM9YEahv4guZ1nSLqwmWiLUnDbGsjs+U5w7z6Uc8HNR1a6x8qnu5y9xtGE6D1uAuYz+0A==", "dev": true, "dependencies": { - "@npmcli/git": "^5.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/promise-spawn": "^7.0.0", - "@npmcli/run-script": "^7.0.0", - "cacache": "^18.0.0", + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", "fs-minipass": "^3.0.0", "minipass": "^7.0.2", - "npm-package-arg": "^11.0.0", - "npm-packlist": "^8.0.0", - "npm-pick-manifest": "^9.0.0", - "npm-registry-fetch": "^16.0.0", - "proc-log": "^3.0.0", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^9.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "read-package-json": "^7.0.0", - "read-package-json-fast": "^3.0.0", - "sigstore": "^2.0.0", - "ssri": "^10.0.0", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", "tar": "^6.1.11" }, "bin": { - "pacote": "lib/bin.js" + "pacote": "bin/index.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11595,25 +7936,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-json/node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, "node_modules/parse-json/node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/parse5": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "devOptional": true, "dependencies": { "entities": "^4.4.0" }, @@ -11647,15 +7983,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11665,15 +7992,6 @@ "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -11689,11 +8007,11 @@ "dev": true }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", + "integrity": "sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { @@ -11704,19 +8022,13 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.1.tgz", - "integrity": "sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", "engines": { "node": "14 || >=16.14" } }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "dev": true - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11726,145 +8038,35 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/piscina": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.2.1.tgz", - "integrity": "sha512-LShp0+lrO+WIzB9LXO+ZmO4zGHxtTJNZhEO56H9SSu+JPaUQb6oLcTCzWi5IL2DS8/vIkCE88ElahuSSw4TAkA==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-4.8.0.tgz", + "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", "dev": true, - "dependencies": { - "hdr-histogram-js": "^2.0.1", - "hdr-histogram-percentiles-obj": "^3.0.0" - }, "optionalDependencies": { - "nice-napi": "^1.0.2" - } - }, - "node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dev": true, - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dev": true, - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dev": true, - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dev": true, - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/pkg-dir/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "@napi-rs/nice": "^1.0.1" } }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -11881,112 +8083,18 @@ } ], "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-loader": { - "version": "7.3.4", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", - "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", - "dev": true, - "dependencies": { - "cosmiconfig": "^8.3.5", - "jiti": "^1.20.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "postcss": "^7.0.0 || ^8.0.1", - "webpack": "^5.0.0" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", - "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", - "dev": true, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", - "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", - "dev": true, - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^6.0.2", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", - "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.4" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "node_modules/postcss-media-query-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", + "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true }, "node_modules/prelude-ls": { @@ -11998,65 +8106,15 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/proc-log": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", - "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", "dev": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -12070,69 +8128,19 @@ "node": ">=10" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true - }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "optional": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "engines": { "node": ">=6" } }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -12157,100 +8165,6 @@ } ] }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, - "node_modules/read-package-json": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-7.0.0.tgz", - "integrity": "sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg==", - "dev": true, - "dependencies": { - "glob": "^10.2.2", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/read-package-json-fast": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", - "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", - "dev": true, - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-package-json/node_modules/json-parse-even-better-errors": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz", - "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -12264,105 +8178,16 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/reflect-metadata": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", - "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", - "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", - "dev": true, - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.8.4" - } - }, - "node_modules/regex-parser": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", - "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", - "dev": true - }, - "node_modules/regexpu-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", - "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", - "dev": true, - "dependencies": { - "@babel/regjsgen": "^0.8.0", - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.1.0", - "regjsparser": "^0.9.1", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsparser": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", - "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", - "dev": true, - "dependencies": { - "jsesc": "~0.5.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - } - }, "node_modules/replace-in-file": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-7.0.1.tgz", - "integrity": "sha512-KbhgPq04eA+TxXuUxpgWIH9k/TjF+28ofon2PXP7vq6izAILhxOtksCVcLuuQLtyjouBaPdlH6RJYYcSPVxCOA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/replace-in-file/-/replace-in-file-7.1.0.tgz", + "integrity": "sha512-1uZmJ78WtqNYCSuPC9IWbweXkGxPOtk2rKuar8diTw7naVIQZiE3Tm8ACx2PCMXDtVH6N+XxwaRY2qZ2xHPqXw==", "dependencies": { "chalk": "^4.1.2", "glob": "^8.1.0", @@ -12375,59 +8200,6 @@ "node": ">=10" } }, - "node_modules/replace-in-file/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/replace-in-file/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/replace-in-file/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/replace-in-file/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/replace-in-file/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/replace-in-file/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", @@ -12446,14 +8218,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/replace-in-file/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "engines": { - "node": ">=8" - } - }, "node_modules/replace-in-file/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", @@ -12465,17 +8229,6 @@ "node": ">=10" } }, - "node_modules/replace-in-file/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -12499,70 +8252,25 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", - "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", - "dev": true, - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/resolve-url-loader/node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/resolve-url-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -12599,52 +8307,18 @@ } }, "node_modules/rfdc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz", - "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true }, "node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz", + "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", "dev": true, "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -12654,31 +8328,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", + "@rollup/rollup-android-arm-eabi": "4.34.8", + "@rollup/rollup-android-arm64": "4.34.8", + "@rollup/rollup-darwin-arm64": "4.34.8", + "@rollup/rollup-darwin-x64": "4.34.8", + "@rollup/rollup-freebsd-arm64": "4.34.8", + "@rollup/rollup-freebsd-x64": "4.34.8", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.8", + "@rollup/rollup-linux-arm-musleabihf": "4.34.8", + "@rollup/rollup-linux-arm64-gnu": "4.34.8", + "@rollup/rollup-linux-arm64-musl": "4.34.8", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.8", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.8", + "@rollup/rollup-linux-riscv64-gnu": "4.34.8", + "@rollup/rollup-linux-s390x-gnu": "4.34.8", + "@rollup/rollup-linux-x64-gnu": "4.34.8", + "@rollup/rollup-linux-x64-musl": "4.34.8", + "@rollup/rollup-win32-arm64-msvc": "4.34.8", + "@rollup/rollup-win32-ia32-msvc": "4.34.8", + "@rollup/rollup-win32-x64-msvc": "4.34.8", "fsevents": "~2.3.2" } }, - "node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12702,9 +8373,9 @@ } }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dependencies": { "tslib": "^2.1.0" } @@ -12735,13 +8406,13 @@ "dev": true }, "node_modules/sass": { - "version": "1.69.7", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.7.tgz", - "integrity": "sha512-rzj2soDeZ8wtE2egyLXgOOHQvaC2iosZrkF6v3EUG+tBwEvhqUCzm0VP3k9gHF9LXbSrRhT5SksoI56Iw8NPnQ==", + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.85.0.tgz", + "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", "dev": true, "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -12749,69 +8420,37 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, - "node_modules/sass-loader": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.3.tgz", - "integrity": "sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA==", + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "dependencies": { - "neo-async": "^2.6.2" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 14.15.0" + "node": ">= 14.16.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "fibers": ">= 3.1.0", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "fibers": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - } + "url": "https://paulmillr.com/funding/" } }, - "node_modules/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "optional": true - }, - "node_modules/schema-utils": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", - "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, "engines": { - "node": ">= 12.13.0" + "node": ">= 14.18.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/screenfull": { @@ -12825,33 +8464,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "dev": true, - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -12859,210 +8476,11 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dev": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/serialize-javascript": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", - "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dev": true, - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dev": true, - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dev": true, - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" }, - "node_modules/set-function-length": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.0.tgz", - "integrity": "sha512-4DBHDoyHlM1IRPGYcoxexgh67y4ueR53FKV1yyxwFMY7aCqcN/38M1+SwZ/qJQ8iLv7+ck385ot4CcisOAPT9w==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.1", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.2", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13082,33 +8500,10 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/signal-exit": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", - "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "engines": { "node": ">=14" }, @@ -13117,43 +8512,74 @@ } }, "node_modules/sigstore": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-2.2.0.tgz", - "integrity": "sha512-fcU9clHwEss2/M/11FFM8Jwc4PjBgbhXoNskoK5guoK0qGQBSeUbQZRJ+B2fDFIvhyf0gqCaPrel9mszbhAxug==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-3.1.0.tgz", + "integrity": "sha512-ZpzWAFHIFqyFE56dXqgX/DkDRZdz+rRcjoIk/RQU4IX0wiCv1l8S7ZrXDHcCc+uaf+6o7w3h2l3g6GYG5TKN9Q==", "dev": true, "dependencies": { - "@sigstore/bundle": "^2.1.1", - "@sigstore/core": "^0.2.0", - "@sigstore/protobuf-specs": "^0.2.1", - "@sigstore/sign": "^2.2.1", - "@sigstore/tuf": "^2.3.0", - "@sigstore/verify": "^0.1.0" + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/sirv": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", - "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", "dev": true, "dependencies": { - "@polka/url": "^1.0.0-next.20", - "mrmime": "^1.0.0", + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { "node": ">= 10" } }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/smart-buffer": { @@ -13166,40 +8592,29 @@ "npm": ">= 3.0.0" } }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, "node_modules/socks": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", - "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "dev": true, "dependencies": { - "ip": "^2.0.0", + "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" }, "engines": { - "node": ">= 10.13.0", + "node": ">= 10.0.0", "npm": ">= 3.0.0" } }, "node_modules/socks-proxy-agent": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz", - "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", - "socks": "^2.7.1" + "socks": "^2.8.3" }, "engines": { "node": ">= 14" @@ -13215,46 +8630,14 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", - "dev": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" - } - }, - "node_modules/source-map-loader/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -13285,9 +8668,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -13301,45 +8684,15 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", "dev": true }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, "node_modules/ssr-window": { @@ -13348,24 +8701,15 @@ "integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ==" }, "node_modules/ssri": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", - "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", "dev": true, "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "engines": { - "node": ">= 0.8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/string_decoder": { @@ -13426,24 +8770,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13456,32 +8782,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strong-log-transformer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz", - "integrity": "sha512-B3Hgul+z0L9a236FAUC9iZsL+nVHgoCJnqCbN588DjYxvGXaXaaFbfmQ/JhvKjZwsOukuR72XbHv71Qkug0HxA==", - "dev": true, - "dependencies": { - "duplexer": "^0.1.1", - "minimist": "^1.2.0", - "through": "^2.3.4" - }, - "bin": { - "sl-log-transformer": "bin/sl-log-transformer.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -13528,19 +8837,10 @@ "node": ">=0.10" } }, - "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dev": true, "dependencies": { "chownr": "^2.0.0", @@ -13554,22 +8854,6 @@ "node": ">=10" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -13603,183 +8887,61 @@ "node": ">=8" } }, + "node_modules/tar/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/tar/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tar/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/terser": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz", - "integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==", - "dev": true, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" } }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", - "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", - "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.16.8" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true - }, "node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "dev": true, "dependencies": { - "rimraf": "^3.0.0" + "os-tmpdir": "~1.0.2" }, "engines": { - "node": ">=8.17.0" - } - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" + "node": ">=0.6.0" } }, "node_modules/to-regex-range": { @@ -13793,15 +8955,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -13838,31 +8991,22 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/ts-api-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.1.tgz", - "integrity": "sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "engines": { - "node": ">=16.13.0" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -13902,37 +9046,23 @@ } } }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tuf-js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.0.tgz", - "integrity": "sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", + "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", "dev": true, "dependencies": { - "@tufjs/models": "2.0.0", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.0" + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/type-check": { @@ -13959,29 +9089,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true - }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -13991,109 +9102,48 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.2.1.tgz", - "integrity": "sha512-7Wa9thEM6/LMnnKtxJHlc8SrTlDmxqJecgz1iy8KlsN0/iskQXOQCuPkrZLXbElPaSw5slFFyKIKXyJ3UtbApw==", - "dev": true, - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=18.0" - } - }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "dev": true }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", - "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", - "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", "dev": true, "dependencies": { - "unique-slug": "^4.0.0" + "unique-slug": "^5.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", "dev": true, "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "engines": { "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "funding": [ { "type": "opencollective", @@ -14109,8 +9159,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -14142,24 +9192,6 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -14177,41 +9209,29 @@ } }, "node_modules/validate-npm-package-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", - "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", - "dev": true, - "dependencies": { - "builtins": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.0.tgz", + "integrity": "sha512-d7KLgL1LD3U3fgnvWEY1cQXoO/q6EQ1BSz48Sa149V/5zVTAbgmZIpyI8TRi6U9/JNyeYLlTKsEMPtLC27RFUg==", "dev": true, "engines": { - "node": ">= 0.8" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/vite": { - "version": "5.0.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz", - "integrity": "sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==", + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", + "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", "dev": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" + "esbuild": "^0.25.0", + "postcss": "^8.5.3", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -14220,18 +9240,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -14241,6 +9268,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -14249,13 +9279,19 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -14265,15 +9301,6 @@ "node": ">=10.13.0" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -14282,62 +9309,22 @@ "defaults": "^1.0.3" } }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", + "dev": true, + "optional": true + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/webpack": { - "version": "5.89.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", - "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", - "dev": true, - "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", - "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/webpack-bundle-analyzer": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz", - "integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", "dev": true, "dependencies": { "@discoveryjs/json-ext": "0.5.7", @@ -14348,7 +9335,6 @@ "escape-string-regexp": "^4.0.0", "gzip-size": "^6.0.0", "html-escaper": "^2.0.2", - "is-plain-object": "^5.0.0", "opener": "^1.5.2", "picocolors": "^1.0.0", "sirv": "^2.0.3", @@ -14382,284 +9368,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-bundle-analyzer/node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/webpack-dev-middleware": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-6.1.1.tgz", - "integrity": "sha512-y51HrHaFeeWir0YO4f0g+9GwZawuigzcAdRNon6jErXy/SqV/+O6eaVAzDqE6t3e3NpGeR5CS+cCDaTC+V3yEQ==", - "dev": true, - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.12", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server": { - "version": "4.15.1", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", - "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", - "dev": true, - "dependencies": { - "@types/bonjour": "^3.5.9", - "@types/connect-history-api-fallback": "^1.3.5", - "@types/express": "^4.17.13", - "@types/serve-index": "^1.9.1", - "@types/serve-static": "^1.13.10", - "@types/sockjs": "^0.3.33", - "@types/ws": "^8.5.5", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.0.11", - "chokidar": "^3.5.3", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "default-gateway": "^6.0.3", - "express": "^4.17.3", - "graceful-fs": "^4.2.6", - "html-entities": "^2.3.2", - "http-proxy-middleware": "^2.0.3", - "ipaddr.js": "^2.0.1", - "launch-editor": "^2.6.0", - "open": "^8.0.9", - "p-retry": "^4.5.0", - "rimraf": "^3.0.2", - "schema-utils": "^4.0.0", - "selfsigned": "^2.1.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^5.3.1", - "ws": "^8.13.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.37.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", - "dev": true, - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^3.4.3", - "mime-types": "^2.1.31", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/webpack-dev-server/node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "dev": true, - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", - "dev": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack-subresource-integrity": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/webpack-subresource-integrity/-/webpack-subresource-integrity-5.1.0.tgz", - "integrity": "sha512-sacXoX+xd8r4WKsy9MvH/q/vBtEHr86cpImXwyg74pFIpERKt6FmB8cXpeuh0ZLgclOlHI4Wcll7+R5L02xk9Q==", - "dev": true, - "dependencies": { - "typed-assert": "^1.0.8" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "html-webpack-plugin": ">= 5.0.0-beta.1 < 6", - "webpack": "^5.12.0" - }, - "peerDependenciesMeta": { - "html-webpack-plugin": { - "optional": true - } - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -14683,26 +9391,18 @@ "node": ">= 8" } }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true - }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=8" } }, "node_modules/wrap-ansi-cjs": { @@ -14722,75 +9422,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "engines": { "node": ">=8.3.0" }, @@ -14866,13 +9506,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zone.js": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.2.tgz", - "integrity": "sha512-X4U7J1isDhoOmHmFWiLhloWc2lzMkdnumtfQ1LXzf/IOZp5NQYuMUTaviVzG/q1ugMBIXzin2AqeVJUoSEkNyQ==", - "dependencies": { - "tslib": "^2.3.0" + "node_modules/yoctocolors-cjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", + "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zone.js": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz", + "integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==" } } } diff --git a/UI/Web/package.json b/UI/Web/package.json index 011146c69..05d539aed 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -3,82 +3,83 @@ "version": "0.7.12.1", "scripts": { "ng": "ng", - "start": "npm run cache-locale && ng serve", + "start": "npm run cache-locale && ng serve --host 0.0.0.0", "build": "npm run cache-locale && ng build", "minify-langs": "node minify-json.js", "cache-locale": "node hash-localization.js", "cache-locale-prime": "node hash-localization-prime.js", - "prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale", + "sync-locale": "node sync-locales.js", + "prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale", "explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json", "lint": "ng lint", "e2e": "ng e2e" }, "private": true, "dependencies": { - "@angular/animations": "^17.1.0", - "@angular/cdk": "^17.1.0", - "@angular/common": "^17.1.0", - "@angular/compiler": "^17.1.0", - "@angular/core": "^17.1.0", - "@angular/forms": "^17.1.0", - "@angular/localize": "^17.1.0", - "@angular/platform-browser": "^17.1.0", - "@angular/platform-browser-dynamic": "^17.1.0", - "@angular/router": "^17.1.0", - "@fortawesome/fontawesome-free": "^6.5.1", - "@iharbeck/ngx-virtual-scroller": "^17.0.0", - "@iplab/ngx-file-upload": "^17.0.0", - "@microsoft/signalr": "^7.0.12", - "@ng-bootstrap/ng-bootstrap": "^16.0.0", - "@ngneat/transloco": "^6.0.4", - "@ngneat/transloco-locale": "^5.1.1", - "@ngneat/transloco-persist-lang": "^5.0.0", - "@ngneat/transloco-persist-translations": "^5.0.0", - "@ngneat/transloco-preload-langs": "^5.0.1", + "@angular-slider/ngx-slider": "^19.0.0", + "@angular/animations": "^19.2.5", + "@angular/cdk": "^19.2.8", + "@angular/common": "^19.2.5", + "@angular/compiler": "^19.2.5", + "@angular/core": "^19.2.5", + "@angular/forms": "^19.2.5", + "@angular/localize": "^19.2.5", + "@angular/platform-browser": "^19.2.5", + "@angular/platform-browser-dynamic": "^19.2.5", + "@angular/router": "^19.2.5", + "@fortawesome/fontawesome-free": "^6.7.2", + "@iharbeck/ngx-virtual-scroller": "^19.0.1", + "@iplab/ngx-file-upload": "^19.0.3", + "@jsverse/transloco": "^7.6.1", + "@jsverse/transloco-locale": "^7.0.1", + "@jsverse/transloco-persist-lang": "^7.0.2", + "@jsverse/transloco-persist-translations": "^7.0.1", + "@jsverse/transloco-preload-langs": "^7.0.1", + "@microsoft/signalr": "^8.0.7", + "@ng-bootstrap/ng-bootstrap": "^18.0.0", "@popperjs/core": "^2.11.7", - "@swimlane/ngx-charts": "^20.5.0", - "@tweenjs/tween.js": "^21.0.0", + "@siemens/ngx-datatable": "^22.4.1", + "@swimlane/ngx-charts": "^22.0.0-alpha.0", + "@tweenjs/tween.js": "^25.0.0", "bootstrap": "^5.3.2", "charts.css": "^1.1.0", "file-saver": "^2.0.5", - "luxon": "^3.4.4", + "luxon": "^3.6.1", "ng-circle-progress": "^1.7.1", "ng-lazyload-image": "^9.1.3", - "ng-select2-component": "^14.0.0", - "ngx-color-picker": "^16.0.0", - "ngx-extended-pdf-viewer": "^18.1.9", + "ng-select2-component": "^17.2.4", + "ngx-color-picker": "^19.0.0", + "ngx-extended-pdf-viewer": "^23.0.0-alpha.7", "ngx-file-drop": "^16.0.0", - "ngx-slider-v2": "^17.0.0", "ngx-stars": "^1.6.5", - "ngx-toaster": "^1.0.1", - "ngx-toastr": "^18.0.0", + "ngx-toastr": "^19.0.0", "nosleep.js": "^0.12.0", - "rxjs": "^7.8.0", + "rxjs": "^7.8.2", "screenfull": "^6.0.2", "swiper": "^8.4.6", - "tslib": "^2.6.2", - "zone.js": "^0.14.2" + "tslib": "^2.8.1", + "zone.js": "^0.15.0" }, "devDependencies": { - "@angular-devkit/build-angular": "^17.1.0", - "@angular-eslint/builder": "^17.2.1", - "@angular-eslint/eslint-plugin": "^17.2.1", - "@angular-eslint/eslint-plugin-template": "^17.2.1", - "@angular-eslint/schematics": "^17.2.1", - "@angular-eslint/template-parser": "^17.2.1", - "@angular/cli": "^17.1.0", - "@angular/compiler-cli": "^17.1.0", + "@angular-eslint/builder": "^19.3.0", + "@angular-eslint/eslint-plugin": "^19.3.0", + "@angular-eslint/eslint-plugin-template": "^19.3.0", + "@angular-eslint/schematics": "^19.3.0", + "@angular-eslint/template-parser": "^19.3.0", + "@angular/build": "^19.2.6", + "@angular/cli": "^19.2.6", + "@angular/compiler-cli": "^19.2.5", "@types/d3": "^7.4.3", "@types/file-saver": "^2.0.7", - "@types/luxon": "^3.4.0", - "@types/node": "^20.10.0", - "@typescript-eslint/eslint-plugin": "^6.13.0", - "@typescript-eslint/parser": "^6.19.0", - "eslint": "^8.54.0", + "@types/luxon": "^3.6.2", + "@types/node": "^22.13.13", + "@typescript-eslint/eslint-plugin": "^8.28.0", + "@typescript-eslint/parser": "^8.28.0", + "eslint": "^9.23.0", "jsonminify": "^0.4.2", "karma-coverage": "~2.2.0", "ts-node": "~10.9.1", - "typescript": "^5.2.2", - "webpack-bundle-analyzer": "^4.10.1" + "typescript": "^5.5.4", + "webpack-bundle-analyzer": "^4.10.2" } } diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss new file mode 100644 index 000000000..1c6f916f3 --- /dev/null +++ b/UI/Web/src/_card-item-common.scss @@ -0,0 +1,246 @@ +$image-height: 232.91px; +$image-width: 160px; + +.error-banner { + width: $image-width; + height: 18px; + background-color: var(--toast-error-bg-color); + font-size: 12px; + color: white; + text-transform: uppercase; + text-align: center; + + position: absolute; + top: 0px; + right: 0px; +} + +.selected-highlight { + outline: 2px solid var(--primary-color); +} + +.progress-banner { + width: $image-width; + height: 5px; + + .progress { + color: var(--card-progress-bar-color); + background-color: transparent; + } +} + +.download { + width: 80px; + height: 80px; + position: absolute; + top: 25%; + right: 30%; +} + +.badge-container { + border-radius: 4px; + display: block; + height: $image-height; + left: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + top: 0; + width: 160px; +} + +.not-read-badge { + position: absolute; + top: calc(-1 * (var(--card-progress-triangle-size) / 2)); + right: -14px; + z-index: 1000; + height: var(--card-progress-triangle-size); + width: var(--card-progress-triangle-size); + background-color: var(--primary-color); + transform: rotate(45deg); +} + +.bulk-mode { + position: absolute; + top: 5px; + left: 5px; + visibility: hidden; + + &.always-show { + visibility: visible !important; + width: $image-width; + height: $image-height; + } + + input[type="checkbox"] { + width: 20px; + height: 20px; + color: var(--checkbox-bg-color); + } +} + +.meta-title { + display: none; + visibility: hidden; + pointer-events: none; + border-width: 0; +} + +.overlay { + &:hover { + .bulk-mode { + visibility: visible; + z-index: 110; + } + + &:hover { + visibility: visible; + + .overlay-information { + visibility: visible; + display: block; + } + + & + .meta-title { + display: -webkit-box; + visibility: visible; + pointer-events: none; + } + } + + .overlay-information { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 232.91px; + transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + &:hover { + background-color: var(--card-overlay-hover-bg-color); + cursor: pointer; + } + + .overlay-information--centered { + position: absolute; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 50px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 115; + + &:hover { + background-color: var(--primary-color) !important; + cursor: pointer; + } + } + } + } + + .count { + top: 5px; + right: 10px; + position: absolute; + } +} + +.card-actions { + z-index: 115; +} + +.library { + font-size: 13px; + text-decoration: none; + margin-top: 0px; +} + +.card-title-container { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 5px; + + :first-child { + min-width: 22px; + } + + .card-title { + font-size: 0.8rem; + margin: 0; + text-align: center; + max-width: 90px; + + a { + overflow: hidden; + text-overflow: ellipsis; + } + } +} + +.card-actions { + min-width: 15.82px; +} + +.card-format { + min-width: 22px; +} + +::ng-deep app-card-actionables .dropdown .dropdown-toggle { + padding: 0 5px; +} + +.meta-title { + .card-title { + max-width: unset; + } +} + +.card-title { + font-size: 0.8rem; + margin: 0; + padding: 10px 0; + text-align: center; + max-width: 120px; + + a { + overflow: hidden; + text-overflow: ellipsis; + } +} + +.card-body > div:nth-child(2) { + height: 40px; + overflow: hidden; + -webkit-line-clamp: 2; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + font-size: 0.8rem; +} + +.overlay-information { + visibility: hidden; + display: none; + .card-title { + padding: 10px; + } +} + +.chapter, +.volume, +.series, +.expected { + .overlay-information--centered { + div { + height: 32px; + width: 32px; + i { + font-size: 1.4rem; + line-height: 32px; + } + } + } +} diff --git a/UI/Web/src/_manga-reader-common.scss b/UI/Web/src/_manga-reader-common.scss index 1f1af75fa..9b54a5fad 100644 --- a/UI/Web/src/_manga-reader-common.scss +++ b/UI/Web/src/_manga-reader-common.scss @@ -1,4 +1,4 @@ -$scrollbarHeight: 34px; +$scrollbarHeight: 35px; img { user-select: none; @@ -9,29 +9,31 @@ img { align-items: center; &.full-width { - height: calc(var(--vh)*100); + height: 100dvh; display: grid; } &.full-height { - height: calc(100vh); // We need to - $scrollbarHeight when there is a horizontal scroll on macos + height: calc(100dvh); // We need to - $scrollbarHeight when there is a horizontal scroll on macos display: flex; align-content: center; + overflow-y: hidden; } &.original { - height: 100vh; + height: calc(100dvh); display: grid; } .full-height { width: auto; margin: auto; - max-height: calc(var(--vh)*100); - overflow: hidden; // This technically will crop and make it just fit + max-height: calc(100dvh); + height: calc(100dvh); vertical-align: top; + object-fit: cover; &.wide { - height: 100vh; + height: calc(100dvh); } } @@ -46,12 +48,13 @@ img { width: 100%; margin: 0 auto; vertical-align: top; - max-width: fit-content; + object-fit: contain; + width: 100%; } .fit-to-screen.full-width { width: 100%; - max-height: calc(var(--vh)*100); + max-height: calc(100dvh); } } diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss new file mode 100644 index 000000000..f043dec17 --- /dev/null +++ b/UI/Web/src/_series-detail-common.scss @@ -0,0 +1,209 @@ +@use './theme/variables' as theme; + +.title { + color: white; + font-weight: bold; + font-size: 1.75rem; +} + +.image-container { + align-self: flex-start; + max-height: 400px; + max-width: 280px; +} + +.subtitle { + color: lightgrey; + font-weight: bold; + font-size: 0.8rem; +} + +.main-container { + overflow: unset !important; + margin-top: 15px; +} + +::ng-deep .badge-expander .content a { + font-size: 0.8rem; +} + +.btn-group > .btn.dropdown-toggle-split:not(first-child){ + border-top-right-radius: var(--bs-border-radius) !important; + border-bottom-right-radius: var(--bs-border-radius) !important; + border-width: 1px 1px 1px 0 !important; +} + +.btn-group > .btn:not(:last-child):not(.dropdown-toggle) { + border-width: 1px 0 1px 1px !important; +} + +.card-body > div:nth-child(2) { + height: 50px; + overflow: hidden; + -webkit-line-clamp: 2; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; +} + +.under-image ~ .overlay-information { + top: -404px; + height: 364px; +} + +.overlay-information { + position: relative; + top: -364px; + height: 364px; + transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + &:hover { + cursor: pointer; + background-color: var(--card-overlay-hover-bg-color) !important; + + .overlay-information--centered { + visibility: visible; + } + } + + .overlay-information--centered { + position: absolute; + border-radius: 15px; + background-color: rgba(0, 0, 0, .7); + border-radius: 50px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 115; + visibility: hidden; + + &:hover { + background-color: var(--primary-color) !important; + cursor: pointer; + } + + div { + width: 60px; + height: 60px; + i { + font-size: 1.6rem; + line-height: 60px; + width: 100%; + } + } + } +} + +.progress { + border-radius: 0; +} + +.progress-banner.series { + position: relative; +} + +::ng-deep .progress-banner.series span { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + color: white; + top: 50%; +} + +.carousel-tabs-container { + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; + scrollbar-width: none; + box-shadow: inset -1px -2px 0px -1px var(--elevation-layer9); +} +.carousel-tabs-container::-webkit-scrollbar { + display: none; +} +.nav-tabs { + flex-wrap: nowrap; +} + +.upper-details { + font-size: 0.9rem; +} + +::ng-deep .carousel-container .header i.fa-plus, ::ng-deep .carousel-container .header i.fa-pen{ + border-width: 1px; + border-style: solid; + border-radius: 5px; + border-color: var(--primary-color); + padding: 5px; + vertical-align: middle; + + &:hover { + background-color: var(--primary-color-dark-shade); + } +} + +::ng-deep .image-container.mobile-bg app-image img { + max-height: 400px; + object-fit: contain; +} + +@media (max-width: theme.$grid-breakpoints-lg) { + .carousel-tabs-container { + mask-image: linear-gradient(transparent, black 0%, black 90%, transparent 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); + } +} + +::ng-deep .image-container.mobile-bg app-image img { + max-height: 100dvh !important; + object-fit: cover !important; +} + +/* col-lg */ +@media (max-width: theme.$grid-breakpoints-lg) { + .image-container.mobile-bg{ + width: 100vw; + top: calc(var(--nav-offset) - 20px); + left: 0; + pointer-events: none; + position: fixed !important; + display: block !important; + max-height: unset !important; + max-width: unset !important; + height: 100dvh !important; + } + + ::ng-deep .image-container.mobile-bg app-image img { + max-height: unset !important; + opacity: 0.05 !important; + filter: blur(5px) !important; + max-width: 100dvw; + height: 100dvh !important; + overflow: hidden; + position: absolute; + top: 0; + left: 0; + object-fit: cover; + } + + .progress-banner { + display:none; + } + + .under-image { + display: none; + } + +} +.upper-details { + font-size: 0.9rem; +} + +@media (max-width: theme.$grid-breakpoints-lg) { + .carousel-tabs-container { + mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%); + } +} diff --git a/UI/Web/src/app/_directives/dbl-click.directive.ts b/UI/Web/src/app/_directives/dbl-click.directive.ts new file mode 100644 index 000000000..ab1d0bcde --- /dev/null +++ b/UI/Web/src/app/_directives/dbl-click.directive.ts @@ -0,0 +1,36 @@ +import {Directive, EventEmitter, HostListener, Output} from '@angular/core'; + +@Directive({ + selector: '[appDblClick]', + standalone: true +}) +export class DblClickDirective { + + @Output() singleClick = new EventEmitter(); + @Output() doubleClick = new EventEmitter(); + + private lastTapTime = 0; + private tapTimeout = 300; // Time threshold for a double tap (in milliseconds) + private singleClickTimeout: any; + + @HostListener('click', ['$event']) + handleClick(event: Event): void { + const currentTime = new Date().getTime(); + + if (currentTime - this.lastTapTime < this.tapTimeout) { + // Detected a double click/tap + clearTimeout(this.singleClickTimeout); // Prevent single-click emission + event.stopPropagation(); + event.preventDefault(); + this.doubleClick.emit(event); + } else { + // Delay single-click emission to check if a double-click occurs + this.singleClickTimeout = setTimeout(() => { + this.singleClick.emit(event); // Optional: emit single-click if no double-click follows + }, this.tapTimeout); + } + + this.lastTapTime = currentTime; + } + +} diff --git a/UI/Web/src/app/_directives/enter-blur.directive.ts b/UI/Web/src/app/_directives/enter-blur.directive.ts new file mode 100644 index 000000000..30329f724 --- /dev/null +++ b/UI/Web/src/app/_directives/enter-blur.directive.ts @@ -0,0 +1,13 @@ +import { Directive, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appEnterBlur]', + standalone: true, +}) +export class EnterBlurDirective { + @HostListener('keydown.enter', ['$event']) + onEnter(event: KeyboardEvent): void { + event.preventDefault(); + document.body.click(); + } +} diff --git a/UI/Web/src/app/_guards/admin.guard.ts b/UI/Web/src/app/_guards/admin.guard.ts index 9ab6dea95..ade795609 100644 --- a/UI/Web/src/app/_guards/admin.guard.ts +++ b/UI/Web/src/app/_guards/admin.guard.ts @@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ providedIn: 'root' diff --git a/UI/Web/src/app/_guards/auth.guard.ts b/UI/Web/src/app/_guards/auth.guard.ts index 5d403469e..41a8b1eef 100644 --- a/UI/Web/src/app/_guards/auth.guard.ts +++ b/UI/Web/src/app/_guards/auth.guard.ts @@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr'; import { Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ providedIn: 'root' diff --git a/UI/Web/src/app/_helpers/browser.ts b/UI/Web/src/app/_helpers/browser.ts new file mode 100644 index 000000000..4d92e207c --- /dev/null +++ b/UI/Web/src/app/_helpers/browser.ts @@ -0,0 +1,62 @@ +export const isSafari = [ + 'iPad Simulator', + 'iPhone Simulator', + 'iPod Simulator', + 'iPad', + 'iPhone', + 'iPod' + ].includes(navigator.platform) + // iPad on iOS 13 detection + || (navigator.userAgent.includes("Mac") && "ontouchend" in document); + +/** + * Represents a Version for a browser + */ +export class Version { + major: number; + minor: number; + patch: number; + + constructor(major: number, minor: number, patch: number) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + isLessThan(other: Version): boolean { + if (this.major < other.major) return true; + if (this.major > other.major) return false; + if (this.minor < other.minor) return true; + if (this.minor > other.minor) return false; + return this.patch < other.patch; + } + + isGreaterThan(other: Version): boolean { + if (this.major > other.major) return true; + if (this.major < other.major) return false; + if (this.minor > other.minor) return true; + if (this.minor < other.minor) return false; + return this.patch > other.patch; + } + + isEqualTo(other: Version): boolean { + return ( + this.major === other.major && + this.minor === other.minor && + this.patch === other.patch + ); + } +} + + +export const getIosVersion = () => { + const match = navigator.userAgent.match(/OS (\d+)_(\d+)_?(\d+)?/); + if (match) { + const major = parseInt(match[1], 10); + const minor = parseInt(match[2], 10); + const patch = parseInt(match[3] || '0', 10); + + return new Version(major, minor, patch); + } + return null; +} diff --git a/UI/Web/src/app/_interceptors/error.interceptor.ts b/UI/Web/src/app/_interceptors/error.interceptor.ts index 8f9c31fed..503ca4516 100644 --- a/UI/Web/src/app/_interceptors/error.interceptor.ts +++ b/UI/Web/src/app/_interceptors/error.interceptor.ts @@ -1,16 +1,11 @@ import {Injectable} from '@angular/core'; -import { - HttpRequest, - HttpHandler, - HttpEvent, - HttpInterceptor -} from '@angular/common/http'; +import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { catchError } from 'rxjs/operators'; import { AccountService } from '../_services/account.service'; -import {translate, TranslocoService} from "@ngneat/transloco"; +import {translate, TranslocoService} from "@jsverse/transloco"; @Injectable() export class ErrorInterceptor implements HttpInterceptor { diff --git a/UI/Web/src/app/_interceptors/jwt.interceptor.ts b/UI/Web/src/app/_interceptors/jwt.interceptor.ts index c81a784e6..711b8ee11 100644 --- a/UI/Web/src/app/_interceptors/jwt.interceptor.ts +++ b/UI/Web/src/app/_interceptors/jwt.interceptor.ts @@ -1,10 +1,5 @@ import {Injectable} from '@angular/core'; -import { - HttpRequest, - HttpHandler, - HttpEvent, - HttpInterceptor -} from '@angular/common/http'; +import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http'; import {Observable, switchMap} from 'rxjs'; import { AccountService } from '../_services/account.service'; import { take } from 'rxjs/operators'; diff --git a/UI/Web/src/app/_models/auth/member.ts b/UI/Web/src/app/_models/auth/member.ts index 31238c68b..aaa45f332 100644 --- a/UI/Web/src/app/_models/auth/member.ts +++ b/UI/Web/src/app/_models/auth/member.ts @@ -1,16 +1,16 @@ -import { AgeRestriction } from '../metadata/age-restriction'; -import { Library } from '../library/library'; +import {AgeRestriction} from '../metadata/age-restriction'; +import {Library} from '../library/library'; export interface Member { - id: number; - username: string; - email: string; - lastActive: string; // datetime - lastActiveUtc: string; // datetime - created: string; // datetime - createdUtc: string; // datetime - roles: string[]; - libraries: Library[]; - ageRestriction: AgeRestriction; - isPending: boolean; + id: number; + username: string; + email: string; + lastActive: string; // datetime + lastActiveUtc: string; // datetime + created: string; // datetime + createdUtc: string; // datetime + roles: string[]; + libraries: Library[]; + ageRestriction: AgeRestriction; + isPending: boolean; } diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index f3010fb95..e52efd202 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -1,13 +1,29 @@ import { MangaFile } from './manga-file'; import { AgeRating } from './metadata/age-rating'; +import {PublicationStatus} from "./metadata/publication-status"; +import {Genre} from "./metadata/genre"; +import {Tag} from "./tag"; +import {Person} from "./metadata/person"; +import {IHasCast} from "./common/i-has-cast"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasCover} from "./common/i-has-cover"; +import {IHasProgress} from "./common/i-has-progress"; + +export const LooseLeafOrDefaultNumber = -100000; +export const SpecialVolumeNumber = 100000; /** * Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields. */ -export interface Chapter { +export interface Chapter extends IHasCast, IHasReadingTime, IHasCover, IHasProgress { id: number; range: string; + /** + * @deprecated Use minNumber/maxNumber + */ number: string; + minNumber: number; + maxNumber: number; files: Array; /** * This is used in the UI, it is not updated or sent to Backend @@ -42,4 +58,53 @@ export interface Chapter { webLinks: string; isbn: string; lastReadingProgress: string; + sortOrder: number; + + primaryColor: string; + secondaryColor: string; + + year: string; + language: string; + publicationStatus: PublicationStatus; + count: number; + totalCount: number; + + genres: Array; + tags: Array; + writers: Array; + coverArtists: Array; + publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + imprints: Array; + colorists: Array; + letterers: Array; + editors: Array; + translators: Array; + teams: Array; + locations: Array; + + summaryLocked: boolean; + genresLocked: boolean; + tagsLocked: boolean; + writerLocked: boolean; + coverArtistLocked: boolean; + publisherLocked: boolean; + characterLocked: boolean; + pencillerLocked: boolean; + inkerLocked: boolean; + imprintLocked: boolean; + coloristLocked: boolean; + lettererLocked: boolean; + editorLocked: boolean; + translatorLocked: boolean; + teamLocked: boolean; + locationLocked: boolean; + ageRatingLocked: boolean; + languageLocked: boolean; + isbnLocked: boolean; + titleNameLocked: boolean; + sortOrderLocked: boolean; + releaseDateLocked: boolean; } diff --git a/UI/Web/src/app/_models/collection-tag.ts b/UI/Web/src/app/_models/collection-tag.ts index af0952f83..2679b5f73 100644 --- a/UI/Web/src/app/_models/collection-tag.ts +++ b/UI/Web/src/app/_models/collection-tag.ts @@ -1,11 +1,25 @@ -export interface CollectionTag { - id: number; - title: string; - promoted: boolean; - /** - * This is used as a placeholder to store the coverImage url. The backend does not use this or send it. - */ - coverImage: string; - coverImageLocked: boolean; - summary: string; -} \ No newline at end of file +import {ScrobbleProvider} from "../_services/scrobbling.service"; +import {AgeRating} from "./metadata/age-rating"; + +export interface UserCollection { + id: number; + title: string; + promoted: boolean; + /** + * This is used as a placeholder to store the coverImage url. The backend does not use this or send it. + */ + coverImage: string; + coverImageLocked: boolean; + summary: string; + lastSyncUtc: string; + owner: string; + source: ScrobbleProvider; + sourceUrl: string | null; + totalSourceCount: number; + /** + * HTML anchors separated by
+ */ + missingSeriesFromSource: string | null; + ageRating: AgeRating; + itemCount: number; +} diff --git a/UI/Web/src/app/_models/collection/mal-stack.ts b/UI/Web/src/app/_models/collection/mal-stack.ts new file mode 100644 index 000000000..5868a202d --- /dev/null +++ b/UI/Web/src/app/_models/collection/mal-stack.ts @@ -0,0 +1,8 @@ +export interface MalStack { + title: string; + stackId: number; + url: string; + author?: string; + seriesCount: number; + restackCount: number; +} diff --git a/UI/Web/src/app/_models/common/i-has-cast.ts b/UI/Web/src/app/_models/common/i-has-cast.ts new file mode 100644 index 000000000..351352cb2 --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-cast.ts @@ -0,0 +1,50 @@ +import {Person} from "../metadata/person"; + +export interface IHasCast { + writerLocked: boolean; + coverArtistLocked: boolean; + publisherLocked: boolean; + characterLocked: boolean; + pencillerLocked: boolean; + inkerLocked: boolean; + imprintLocked: boolean; + coloristLocked: boolean; + lettererLocked: boolean; + editorLocked: boolean; + translatorLocked: boolean; + teamLocked: boolean; + locationLocked: boolean; + languageLocked: boolean; + + writers: Array; + coverArtists: Array; + publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + imprints: Array; + colorists: Array; + letterers: Array; + editors: Array; + translators: Array; + teams: Array; + locations: Array; +} + +export function hasAnyCast(entity: IHasCast | null | undefined): boolean { + if (entity === null || entity === undefined) return false; + + return entity.writers.length > 0 || + entity.coverArtists.length > 0 || + entity.publishers.length > 0 || + entity.characters.length > 0 || + entity.pencillers.length > 0 || + entity.inkers.length > 0 || + entity.imprints.length > 0 || + entity.colorists.length > 0 || + entity.letterers.length > 0 || + entity.editors.length > 0 || + entity.translators.length > 0 || + entity.teams.length > 0 || + entity.locations.length > 0; +} diff --git a/UI/Web/src/app/_models/common/i-has-cover.ts b/UI/Web/src/app/_models/common/i-has-cover.ts new file mode 100644 index 000000000..7e58bbcbb --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-cover.ts @@ -0,0 +1,5 @@ +export interface IHasCover { + coverImage?: string; + primaryColor: string; + secondaryColor: string; +} diff --git a/UI/Web/src/app/_models/common/i-has-progress.ts b/UI/Web/src/app/_models/common/i-has-progress.ts new file mode 100644 index 000000000..4605dc0a8 --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-progress.ts @@ -0,0 +1,4 @@ +export interface IHasProgress { + pages: number; + pagesRead: number; +} diff --git a/UI/Web/src/app/_models/common/i-has-reading-time.ts b/UI/Web/src/app/_models/common/i-has-reading-time.ts new file mode 100644 index 000000000..41753d1fd --- /dev/null +++ b/UI/Web/src/app/_models/common/i-has-reading-time.ts @@ -0,0 +1,8 @@ +export interface IHasReadingTime { + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; + pages: number; + wordCount: number; + +} diff --git a/UI/Web/src/app/_models/default-modal-options.ts b/UI/Web/src/app/_models/default-modal-options.ts new file mode 100644 index 000000000..4dbb391a9 --- /dev/null +++ b/UI/Web/src/app/_models/default-modal-options.ts @@ -0,0 +1 @@ +export const DefaultModalOptions = {scrollable: true, size: 'xl', fullscreen: 'xl'}; diff --git a/UI/Web/src/app/_models/email-history.ts b/UI/Web/src/app/_models/email-history.ts new file mode 100644 index 000000000..0805704fb --- /dev/null +++ b/UI/Web/src/app/_models/email-history.ts @@ -0,0 +1,7 @@ +export interface EmailHistory { + sent: boolean; + sendDate: string; + emailTemplate: string; + errorMessage: string; + toUserName: string; +} diff --git a/UI/Web/src/app/_models/events/chapter-removed-event.ts b/UI/Web/src/app/_models/events/chapter-removed-event.ts new file mode 100644 index 000000000..5413a1923 --- /dev/null +++ b/UI/Web/src/app/_models/events/chapter-removed-event.ts @@ -0,0 +1,4 @@ +export interface ChapterRemovedEvent { + chapterId: number; + seriesId: number; +} diff --git a/UI/Web/src/app/_models/events/site-theme-updated-event.ts b/UI/Web/src/app/_models/events/site-theme-updated-event.ts new file mode 100644 index 000000000..fea80c979 --- /dev/null +++ b/UI/Web/src/app/_models/events/site-theme-updated-event.ts @@ -0,0 +1,3 @@ +export interface SiteThemeUpdatedEvent { + themeName: string; +} diff --git a/UI/Web/src/app/_models/events/update-version-event.ts b/UI/Web/src/app/_models/events/update-version-event.ts index c74e49af6..4e7e82ce6 100644 --- a/UI/Web/src/app/_models/events/update-version-event.ts +++ b/UI/Web/src/app/_models/events/update-version-event.ts @@ -1,12 +1,26 @@ export interface UpdateVersionEvent { - currentVersion: string; - updateVersion: string; - updateBody: string; - updateTitle: string; - updateUrl: string; - isDocker: boolean; - publishDate: string; - isOnNightlyInRelease: boolean; - isReleaseNewer: boolean; - isReleaseEqual: boolean; + currentVersion: string; + updateVersion: string; + updateBody: string; + updateTitle: string; + updateUrl: string; + isDocker: boolean; + publishDate: string; + isOnNightlyInRelease: boolean; + isReleaseNewer: boolean; + isReleaseEqual: boolean; + + added: Array; + removed: Array; + changed: Array; + fixed: Array; + theme: Array; + developer: Array; + api: Array; + featureRequests: Array; + knownIssues: Array; + /** + * The part above the changelog part + */ + blogPart: string; } diff --git a/UI/Web/src/app/_models/events/volume-removed-event.ts b/UI/Web/src/app/_models/events/volume-removed-event.ts new file mode 100644 index 000000000..1ce2dc2ed --- /dev/null +++ b/UI/Web/src/app/_models/events/volume-removed-event.ts @@ -0,0 +1,4 @@ +export interface VolumeRemovedEvent { + volumeId: number; + seriesId: number; +} diff --git a/UI/Web/src/app/_models/kavitaplus/license-info.ts b/UI/Web/src/app/_models/kavitaplus/license-info.ts new file mode 100644 index 000000000..4a724b3ff --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/license-info.ts @@ -0,0 +1,9 @@ +export interface LicenseInfo { + expirationDate: string; + isActive: boolean; + isCancelled: boolean; + isValidVersion: boolean; + registeredEmail: string; + totalMonthsSubbed: number; + hasLicense: boolean; +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts new file mode 100644 index 000000000..a8dc1ce06 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-filter.ts @@ -0,0 +1,6 @@ +import {MatchStateOption} from "./match-state-option"; + +export interface ManageMatchFilter { + matchStateOption: MatchStateOption; + searchTerm: string; +} diff --git a/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts b/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts new file mode 100644 index 000000000..4138279e6 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/manage-match-series.ts @@ -0,0 +1,7 @@ +import {Series} from "../series"; + +export interface ManageMatchSeries { + series: Series; + isMatched: boolean; + validUntilUtc: string; +} diff --git a/UI/Web/src/app/_models/kavitaplus/match-state-option.ts b/UI/Web/src/app/_models/kavitaplus/match-state-option.ts new file mode 100644 index 000000000..a52c5efad --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/match-state-option.ts @@ -0,0 +1,11 @@ +export enum MatchStateOption { + All = 0, + Matched = 1, + NotMatched = 2, + Error = 3, + DontMatch = 4 +} + +export const allMatchStates = [ + MatchStateOption.Matched, MatchStateOption.NotMatched, MatchStateOption.Error, MatchStateOption.DontMatch +]; diff --git a/UI/Web/src/app/_models/kavitaplus/user-token-info.ts b/UI/Web/src/app/_models/kavitaplus/user-token-info.ts new file mode 100644 index 000000000..1dcab9c91 --- /dev/null +++ b/UI/Web/src/app/_models/kavitaplus/user-token-info.ts @@ -0,0 +1,8 @@ +export interface UserTokenInfo { + userId: number; + username: string; + isAniListTokenSet: boolean; + aniListValidUntilUtc: string; + isAniListTokenValid: boolean; + isMalTokenSet: boolean; +} diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index d5ea0da8a..000723568 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -6,9 +6,12 @@ export enum LibraryType { Book = 2, Images = 3, LightNovel = 4, - Magazine = 5 + ComicVine = 5, + Magazine = 6 } +export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images, LibraryType.Magazine]; + export interface Library { id: number; name: string; @@ -23,6 +26,7 @@ export interface Library { manageCollections: boolean; manageReadingLists: boolean; allowScrobbling: boolean; + allowMetadataMatching: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/_models/metadata/chapter-metadata.ts b/UI/Web/src/app/_models/metadata/chapter-metadata.ts deleted file mode 100644 index 4606021a9..000000000 --- a/UI/Web/src/app/_models/metadata/chapter-metadata.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Genre } from "./genre"; -import { AgeRating } from "./age-rating"; -import { PublicationStatus } from "./publication-status"; -import { Person } from "./person"; -import { Tag } from "../tag"; - -export interface ChapterMetadata { - id: number; - chapterId: number; - title: string; - year: string; - - ageRating: AgeRating; - releaseDate: string; - language: string; - publicationStatus: PublicationStatus; - summary: string; - count: number; - totalCount: number; - wordCount: number; - - - - genres: Array; - tags: Array; - writers: Array; - coverArtists: Array; - publishers: Array; - characters: Array; - pencillers: Array; - inkers: Array; - colorists: Array; - letterers: Array; - editors: Array; - translators: Array; - - - -} \ No newline at end of file diff --git a/UI/Web/src/app/_models/metadata/language.ts b/UI/Web/src/app/_models/metadata/language.ts index e8f606bec..8b68c7233 100644 --- a/UI/Web/src/app/_models/metadata/language.ts +++ b/UI/Web/src/app/_models/metadata/language.ts @@ -3,3 +3,10 @@ export interface Language { title: string; } +export interface KavitaLocale { + fileName: string; // isoCode aka what maps to the file on disk and what transloco loads + renderName: string; + translationCompletion: number; + isRtL: boolean; + hash: string; +} diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index e23925cef..c8a4c566e 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -1,20 +1,33 @@ +import {IHasCover} from "../common/i-has-cover"; + export enum PersonRole { - Other = 1, - Artist = 2, - Writer = 3, - Penciller = 4, - Inker = 5, - Colorist = 6, - Letterer = 7, - CoverArtist = 8, - Editor = 9, - Publisher = 10, - Character = 11, - Translator = 12 + Other = 1, + Artist = 2, + Writer = 3, + Penciller = 4, + Inker = 5, + Colorist = 6, + Letterer = 7, + CoverArtist = 8, + Editor = 9, + Publisher = 10, + Character = 11, + Translator = 12, + Imprint = 13, + Team = 14, + Location = 15 } -export interface Person { - id: number; - name: string; - role: PersonRole; -} \ No newline at end of file +export interface Person extends IHasCover { + id: number; + name: string; + description: string; + coverImage?: string; + coverImageLocked: boolean; + malId?: number; + aniListId?: number; + hardcoverId?: string; + asin?: string; + primaryColor: string; + secondaryColor: string; +} diff --git a/UI/Web/src/app/_models/metadata/series-filter.ts b/UI/Web/src/app/_models/metadata/series-filter.ts index 663fc2380..7d043aa3c 100644 --- a/UI/Web/src/app/_models/metadata/series-filter.ts +++ b/UI/Web/src/app/_models/metadata/series-filter.ts @@ -1,6 +1,5 @@ -import { MangaFormat } from "../manga-format"; -import { SeriesFilterV2 } from "./v2/series-filter-v2"; -import {FilterField} from "./v2/filter-field"; +import {MangaFormat} from "../manga-format"; +import {SeriesFilterV2} from "./v2/series-filter-v2"; export interface FilterItem { title: string; @@ -24,7 +23,8 @@ export enum SortField { /** * Kavita+ only */ - AverageRating = 8 + AverageRating = 8, + Random = 9 } export const allSortFields = Object.keys(SortField) @@ -33,22 +33,22 @@ export const allSortFields = Object.keys(SortField) export const mangaFormatFilters = [ { - title: 'Images', + title: 'images', value: MangaFormat.IMAGE, selected: false }, { - title: 'EPUB', + title: 'epub', value: MangaFormat.EPUB, selected: false }, { - title: 'PDF', + title: 'pdf', value: MangaFormat.PDF, selected: false }, { - title: 'ARCHIVE', + title: 'archive', value: MangaFormat.ARCHIVE, selected: false } diff --git a/UI/Web/src/app/_models/metadata/series-metadata.ts b/UI/Web/src/app/_models/metadata/series-metadata.ts index 27e0e1917..fc691ee93 100644 --- a/UI/Web/src/app/_models/metadata/series-metadata.ts +++ b/UI/Web/src/app/_models/metadata/series-metadata.ts @@ -1,18 +1,17 @@ -import { CollectionTag } from "../collection-tag"; import { Genre } from "./genre"; import { AgeRating } from "./age-rating"; import { PublicationStatus } from "./publication-status"; import { Person } from "./person"; import { Tag } from "../tag"; +import {IHasCast} from "../common/i-has-cast"; -export interface SeriesMetadata { +export interface SeriesMetadata extends IHasCast { seriesId: number; summary: string; totalCount: number; maxCount: number; - collectionTags: Array; genres: Array; tags: Array; writers: Array; @@ -21,10 +20,13 @@ export interface SeriesMetadata { characters: Array; pencillers: Array; inkers: Array; + imprints: Array; colorists: Array; letterers: Array; editors: Array; translators: Array; + teams: Array; + locations: Array; ageRating: AgeRating; releaseYear: number; language: string; @@ -40,10 +42,13 @@ export interface SeriesMetadata { characterLocked: boolean; pencillerLocked: boolean; inkerLocked: boolean; + imprintLocked: boolean; coloristLocked: boolean; lettererLocked: boolean; editorLocked: boolean; translatorLocked: boolean; + teamLocked: boolean; + locationLocked: boolean; ageRatingLocked: boolean; releaseYearLocked: boolean; languageLocked: boolean; diff --git a/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts b/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts index fa30dc786..2dafc0e48 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-comparison.ts @@ -43,4 +43,5 @@ export enum FilterComparison { /// Is Date not between now and X seconds ago /// IsNotInLast = 15, + IsEmpty = 16 } diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index 76c44b01c..08005d5c8 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -1,3 +1,5 @@ +import {PersonRole} from "../person"; + export enum FilterField { None = -1, @@ -29,7 +31,11 @@ export enum FilterField FilePath = 25, WantToRead = 26, ReadingDate = 27, - AverageRating = 28 + AverageRating = 28, + Imprint = 29, + Team = 30, + Location = 31, + ReadLast = 32 } @@ -44,3 +50,36 @@ enumArray.sort((a, b) => a.value.localeCompare(b.value)); export const allFields = enumArray .map(key => parseInt(key.key, 10))as FilterField[]; + +export const allPeople = [ + FilterField.Characters, + FilterField.Colorist, + FilterField.CoverArtist, + FilterField.Editor, + FilterField.Inker, + FilterField.Letterer, + FilterField.Penciller, + FilterField.Publisher, + FilterField.Translators, + FilterField.Writers, +]; + +export const personRoleForFilterField = (role: PersonRole) => { + switch (role) { + case PersonRole.Artist: return FilterField.CoverArtist; + case PersonRole.Character: return FilterField.Characters; + case PersonRole.Colorist: return FilterField.Colorist; + case PersonRole.CoverArtist: return FilterField.CoverArtist; + case PersonRole.Editor: return FilterField.Editor; + case PersonRole.Inker: return FilterField.Inker; + case PersonRole.Letterer: return FilterField.Letterer; + case PersonRole.Penciller: return FilterField.Penciller; + case PersonRole.Publisher: return FilterField.Publisher; + case PersonRole.Translator: return FilterField.Translators; + case PersonRole.Writer: return FilterField.Writers; + case PersonRole.Imprint: return FilterField.Imprint; + case PersonRole.Location: return FilterField.Location; + case PersonRole.Team: return FilterField.Team; + case PersonRole.Other: return FilterField.None; + } +}; diff --git a/UI/Web/src/app/_models/metadata/v2/query-context.ts b/UI/Web/src/app/_models/metadata/v2/query-context.ts new file mode 100644 index 000000000..63a5c0032 --- /dev/null +++ b/UI/Web/src/app/_models/metadata/v2/query-context.ts @@ -0,0 +1,7 @@ +export enum QueryContext +{ + None = 1, + Search = 2, + Recommended = 3, + Dashboard = 4, +} diff --git a/UI/Web/src/app/_models/person/browse-person.ts b/UI/Web/src/app/_models/person/browse-person.ts new file mode 100644 index 000000000..aeddac7cd --- /dev/null +++ b/UI/Web/src/app/_models/person/browse-person.ts @@ -0,0 +1,6 @@ +import {Person} from "../metadata/person"; + +export interface BrowsePerson extends Person { + seriesCount: number; + issueCount: number; +} diff --git a/UI/Web/src/app/_models/preferences/pdf-layout-mode.ts b/UI/Web/src/app/_models/preferences/pdf-layout-mode.ts new file mode 100644 index 000000000..53a54a851 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-layout-mode.ts @@ -0,0 +1,6 @@ +export enum PdfLayoutMode { + Multiple = 0, + Single = 1, + Book = 2, + InfiniteScroll = 3 +} diff --git a/UI/Web/src/app/_models/preferences/pdf-scroll-mode.ts b/UI/Web/src/app/_models/preferences/pdf-scroll-mode.ts new file mode 100644 index 000000000..2a122590d --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-scroll-mode.ts @@ -0,0 +1,6 @@ +export enum PdfScrollMode { + Vertical = 0, + Horizontal = 1, + Wrapped = 2, + Page = 3 +} diff --git a/UI/Web/src/app/_models/preferences/pdf-spread-mode.ts b/UI/Web/src/app/_models/preferences/pdf-spread-mode.ts new file mode 100644 index 000000000..7bd437add --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-spread-mode.ts @@ -0,0 +1,5 @@ +export enum PdfSpreadMode { + None = 0, + Odd = 1, + Even = 2 +} diff --git a/UI/Web/src/app/_models/preferences/pdf-theme.ts b/UI/Web/src/app/_models/preferences/pdf-theme.ts new file mode 100644 index 000000000..b3ecc1796 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/pdf-theme.ts @@ -0,0 +1,4 @@ +export enum PdfTheme{ + Dark = 0, + Light = 1 +} diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 83b7907a8..1dd5731e5 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,48 +1,61 @@ - -import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; -import { BookPageLayoutMode } from '../readers/book-page-layout-mode'; -import { PageLayoutMode } from '../page-layout-mode'; -import { PageSplitOption } from './page-split-option'; -import { ReaderMode } from './reader-mode'; -import { ReadingDirection } from './reading-direction'; -import { ScalingOption } from './scaling-option'; -import { SiteTheme } from './site-theme'; +import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode'; +import {BookPageLayoutMode} from '../readers/book-page-layout-mode'; +import {PageLayoutMode} from '../page-layout-mode'; +import {PageSplitOption} from './page-split-option'; +import {ReaderMode} from './reader-mode'; +import {ReadingDirection} from './reading-direction'; +import {ScalingOption} from './scaling-option'; +import {SiteTheme} from './site-theme'; import {WritingStyle} from "./writing-style"; +import {PdfTheme} from "./pdf-theme"; +import {PdfScrollMode} from "./pdf-scroll-mode"; +import {PdfLayoutMode} from "./pdf-layout-mode"; +import {PdfSpreadMode} from "./pdf-spread-mode"; export interface Preferences { - // Manga Reader - readingDirection: ReadingDirection; - scalingOption: ScalingOption; - pageSplitOption: PageSplitOption; - readerMode: ReaderMode; - autoCloseMenu: boolean; - layoutMode: LayoutMode; - backgroundColor: string; - showScreenHints: boolean; - emulateBook: boolean; - swipeToPaginate: boolean; + // Manga Reader + readingDirection: ReadingDirection; + scalingOption: ScalingOption; + pageSplitOption: PageSplitOption; + readerMode: ReaderMode; + autoCloseMenu: boolean; + layoutMode: LayoutMode; + backgroundColor: string; + showScreenHints: boolean; + emulateBook: boolean; + swipeToPaginate: boolean; + allowAutomaticWebtoonReaderDetection: boolean; - // Book Reader - bookReaderMargin: number; - bookReaderLineSpacing: number; - bookReaderFontSize: number; - bookReaderFontFamily: string; - bookReaderTapToPaginate: boolean; - bookReaderReadingDirection: ReadingDirection; - bookReaderWritingStyle: WritingStyle; - bookReaderThemeName: string; - bookReaderLayoutMode: BookPageLayoutMode; - bookReaderImmersiveMode: boolean; + // Book Reader + bookReaderMargin: number; + bookReaderLineSpacing: number; + bookReaderFontSize: number; + bookReaderFontFamily: string; + bookReaderTapToPaginate: boolean; + bookReaderReadingDirection: ReadingDirection; + bookReaderWritingStyle: WritingStyle; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; + bookReaderImmersiveMode: boolean; - // Global - theme: SiteTheme; - globalPageLayoutMode: PageLayoutMode; - blurUnreadSummaries: boolean; - promptForDownloadSize: boolean; - noTransitions: boolean; - collapseSeriesRelationships: boolean; - shareReviews: boolean; - locale: string; + // PDF Reader + pdfTheme: PdfTheme; + pdfScrollMode: PdfScrollMode; + pdfSpreadMode: PdfSpreadMode; + + // Global + theme: SiteTheme; + globalPageLayoutMode: PageLayoutMode; + blurUnreadSummaries: boolean; + promptForDownloadSize: boolean; + noTransitions: boolean; + collapseSeriesRelationships: boolean; + shareReviews: boolean; + locale: string; + + // Kavita+ + aniListScrobblingEnabled: boolean; + wantToReadSync: boolean; } export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}]; @@ -50,6 +63,10 @@ export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horiz export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}]; export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}]; export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}]; -export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // , {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} +export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover} export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}]; export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}]; +export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}]; +export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}]; +export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}]; +export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}]; diff --git a/UI/Web/src/app/_models/preferences/site-theme.ts b/UI/Web/src/app/_models/preferences/site-theme.ts index 675d4dad3..a861a5a11 100644 --- a/UI/Web/src/app/_models/preferences/site-theme.ts +++ b/UI/Web/src/app/_models/preferences/site-theme.ts @@ -3,9 +3,9 @@ */ export enum ThemeProvider { System = 1, - User = 2 + Custom = 2, } - + /** * Theme for the whole instance */ @@ -20,4 +20,8 @@ * The actual class the root is defined against. It is generated at the backend. */ selector: string; - } \ No newline at end of file + description: string; + previewUrls: Array; + author: string; + + } diff --git a/UI/Web/src/app/_models/readers/full-progress.ts b/UI/Web/src/app/_models/readers/full-progress.ts new file mode 100644 index 000000000..2b34be267 --- /dev/null +++ b/UI/Web/src/app/_models/readers/full-progress.ts @@ -0,0 +1,11 @@ +export interface FullProgress { + id: number; + chapterId: number; + pagesRead: number; + lastModified: string; + lastModifiedUtc: string; + created: string; + createdUtc: string; + appUserId: number; + userName: string; +} diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 697087274..646360153 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -1,37 +1,57 @@ -import { LibraryType } from "./library/library"; -import { MangaFormat } from "./manga-format"; +import {LibraryType} from "./library/library"; +import {MangaFormat} from "./manga-format"; +import {IHasCover} from "./common/i-has-cover"; +import {AgeRating} from "./metadata/age-rating"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasCast} from "./common/i-has-cast"; export interface ReadingListItem { - pagesRead: number; - pagesTotal: number; - seriesName: string; - seriesFormat: MangaFormat; - seriesId: number; - chapterId: number; - order: number; - chapterNumber: string; - volumeNumber: string; - libraryId: number; - id: number; - releaseDate: string; - title: string; - libraryType: LibraryType; - libraryName: string; + pagesRead: number; + pagesTotal: number; + seriesName: string; + seriesFormat: MangaFormat; + seriesId: number; + chapterId: number; + order: number; + chapterNumber: string; + volumeNumber: string; + libraryId: number; + id: number; + releaseDate: string; + title: string; + libraryType: LibraryType; + libraryName: string; + summary?: string; } -export interface ReadingList { - id: number; - title: string; - summary: string; - promoted: boolean; - coverImageLocked: boolean; - items: Array; - /** - * If this is empty or null, the cover image isn't set. Do not use this externally. - */ - coverImage: string; - startingYear: number; - startingMonth: number; - endingYear: number; - endingMonth: number; +export interface ReadingList extends IHasCover { + id: number; + title: string; + summary: string; + promoted: boolean; + coverImageLocked: boolean; + items: Array; + /** + * If this is empty or null, the cover image isn't set. Do not use this externally. + */ + coverImage?: string; + primaryColor: string; + secondaryColor: string; + startingYear: number; + startingMonth: number; + endingYear: number; + endingMonth: number; + itemCount: number; + ageRating: AgeRating; } + +export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime { + pages: number; + wordCount: number; + isAllEpub: boolean; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; +} + +export interface ReadingListCast extends IHasCast {} diff --git a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts index 102cf89d1..c0ea95d64 100644 --- a/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts +++ b/UI/Web/src/app/_models/scrobbling/scrobble-event-filter.ts @@ -4,7 +4,8 @@ export enum ScrobbleEventSortField { LastModified = 2, Type= 3, Series = 4, - IsProcessed = 5 + IsProcessed = 5, + ScrobbleEvent = 6 } export interface ScrobbleEventFilter { diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts index a96670aaf..7391cdad9 100644 --- a/UI/Web/src/app/_models/search/search-result-group.ts +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -4,14 +4,18 @@ import { MangaFile } from "../manga-file"; import { SearchResult } from "./search-result"; import { Tag } from "../tag"; import {BookmarkSearchResult} from "./bookmark-search-result"; +import {Genre} from "../metadata/genre"; +import {ReadingList} from "../reading-list"; +import {UserCollection} from "../collection-tag"; +import {Person} from "../metadata/person"; export class SearchResultGroup { libraries: Array = []; series: Array = []; - collections: Array = []; - readingLists: Array = []; - persons: Array = []; - genres: Array = []; + collections: Array = []; + readingLists: Array = []; + persons: Array = []; + genres: Array = []; tags: Array = []; files: Array = []; chapters: Array = []; diff --git a/UI/Web/src/app/_models/series-detail/external-series-detail.ts b/UI/Web/src/app/_models/series-detail/external-series-detail.ts index 85d89c760..db25782ca 100644 --- a/UI/Web/src/app/_models/series-detail/external-series-detail.ts +++ b/UI/Web/src/app/_models/series-detail/external-series-detail.ts @@ -27,8 +27,9 @@ export interface MetadataTagDto { export interface ExternalSeriesDetail { name: string; - aniListId?: number; - malId?: number; + aniListId?: number | null; + malId?: number | null; + cbrId?: number | null; synonyms: Array; plusMediaFormat: PlusMediaFormat; siteUrl?: string; @@ -37,6 +38,11 @@ export interface ExternalSeriesDetail { summary?: string; volumeCount?: number; chapterCount?: number; + /** + * These are duplicated with volumeCount based on where it's being invoked. + */ + volumes?: number; + chapters?: number; staff: Array; tags: Array; provider: ScrobbleProvider; diff --git a/UI/Web/src/app/_models/series-detail/external-series-match.ts b/UI/Web/src/app/_models/series-detail/external-series-match.ts new file mode 100644 index 000000000..28afea18a --- /dev/null +++ b/UI/Web/src/app/_models/series-detail/external-series-match.ts @@ -0,0 +1,6 @@ +import {ExternalSeriesDetail} from "./external-series-detail"; + +export interface ExternalSeriesMatch { + series: ExternalSeriesDetail; + matchRating: number; +} diff --git a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts index f94ac569b..805a71178 100644 --- a/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts +++ b/UI/Web/src/app/_models/series-detail/hour-estimate-range.ts @@ -1,6 +1,5 @@ -export interface HourEstimateRange{ +export interface HourEstimateRange { minHours: number; maxHours: number; avgHours: number; - //hasProgress: boolean; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_models/series-detail/related-series.ts b/UI/Web/src/app/_models/series-detail/related-series.ts index f0cfc230b..aa24138bd 100644 --- a/UI/Web/src/app/_models/series-detail/related-series.ts +++ b/UI/Web/src/app/_models/series-detail/related-series.ts @@ -15,4 +15,5 @@ export interface RelatedSeries { doujinshis: Array; parent: Array; editions: Array; + annuals: Array; } diff --git a/UI/Web/src/app/_models/series-detail/relation-kind.ts b/UI/Web/src/app/_models/series-detail/relation-kind.ts index 2de8e701f..9417e61da 100644 --- a/UI/Web/src/app/_models/series-detail/relation-kind.ts +++ b/UI/Web/src/app/_models/series-detail/relation-kind.ts @@ -14,7 +14,8 @@ export enum RelationKind { * This is UI only. Backend will generate Parent series for everything but Prequel/Sequel */ Parent = 12, - Edition = 13 + Edition = 13, + Annual = 14 } const RelationKindsUnsorted = [ @@ -22,6 +23,7 @@ const RelationKindsUnsorted = [ {text: 'Sequel', value: RelationKind.Sequel}, {text: 'Spin Off', value: RelationKind.SpinOff}, {text: 'Adaptation', value: RelationKind.Adaptation}, + {text: 'Annual', value: RelationKind.Annual}, {text: 'Alternative Setting', value: RelationKind.AlternativeSetting}, {text: 'Alternative Version', value: RelationKind.AlternativeVersion}, {text: 'Side Story', value: RelationKind.SideStory}, diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index c994a3527..29d4aed7f 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -1,67 +1,82 @@ import { MangaFormat } from './manga-format'; import { Volume } from './volume'; +import {IHasCover} from "./common/i-has-cover"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasProgress} from "./common/i-has-progress"; -export interface Series { - id: number; - name: string; - /** - * This is not shown to user - */ - originalName: string; - localizedName: string; - sortName: string; - coverImageLocked: boolean; - sortNameLocked: boolean; - localizedNameLocked: boolean; - nameLocked: boolean; - volumes: Volume[]; - /** - * Total pages in series - */ - pages: number; - /** - * Total pages the logged in user has read - */ - pagesRead: number; - /** - * User's rating (0-5) - */ - userRating: number; - hasUserRated: boolean; - libraryId: number; - /** - * DateTime the entity was created - */ - created: string; - /** - * Format of the Series - */ - format: MangaFormat; - /** - * DateTime that represents last time the logged in user read this series - */ - latestReadDate: string; - /** - * DateTime representing last time a chapter was added to the Series - */ - lastChapterAdded: string; - /** - * DateTime representing last time the series folder was scanned - */ - lastFolderScanned: string; - /** - * Number of words in the series - */ - wordCount: number; - minHoursToRead: number; - maxHoursToRead: number; - avgHoursToRead: number; - /** - * Highest level folder containing this series - */ - folderPath: string; +export interface Series extends IHasCover, IHasReadingTime, IHasProgress { + id: number; + name: string; + /** + * This is not shown to user + */ + originalName: string; + localizedName: string; + sortName: string; + coverImageLocked: boolean; + sortNameLocked: boolean; + localizedNameLocked: boolean; + nameLocked: boolean; + volumes: Volume[]; + /** + * Total pages in series + */ + pages: number; + /** + * Total pages the logged in user has read + */ + pagesRead: number; + /** + * User's rating (0-5) + */ + userRating: number; + hasUserRated: boolean; + libraryId: number; + /** + * DateTime the entity was created + */ + created: string; + /** + * Format of the Series + */ + format: MangaFormat; + /** + * DateTime that represents last time the logged in user read this series + */ + latestReadDate: string; + /** + * DateTime representing last time a chapter was added to the Series + */ + lastChapterAdded: string; + /** + * DateTime representing last time the series folder was scanned + */ + lastFolderScanned: string; + /** + * Number of words in the series + */ + wordCount: number; + minHoursToRead: number; + maxHoursToRead: number; + avgHoursToRead: number; + /** + * Highest level folder containing this series + */ + folderPath: string; + lowestFolderPath: string; /** * This is currently only used on Series detail page for recommendations */ summary?: string; + coverImage?: string; + primaryColor: string; + secondaryColor: string; + /** + * Kavita+ only. Will not perform any matching from Kavita+ + */ + dontMatch: boolean; + /** + * Kavita+ only. Did this series not match and won't without manual match + */ + isBlacklisted: boolean; } diff --git a/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts b/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts index a294f9696..2d6ef4e4c 100644 --- a/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts +++ b/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts @@ -7,4 +7,5 @@ export enum SideNavStreamType { ExternalSource = 6, AllSeries = 7, WantToRead = 8, + BrowseAuthors = 9 } diff --git a/UI/Web/src/app/_models/sidenav/sidenav-stream.ts b/UI/Web/src/app/_models/sidenav/sidenav-stream.ts index 37a2c20e3..192bf73bd 100644 --- a/UI/Web/src/app/_models/sidenav/sidenav-stream.ts +++ b/UI/Web/src/app/_models/sidenav/sidenav-stream.ts @@ -1,5 +1,5 @@ import {SideNavStreamType} from "./sidenav-stream-type.enum"; -import {Library, LibraryType} from "../library/library"; +import {Library} from "../library/library"; import {CommonStream} from "../common-stream"; import {ExternalSource} from "./external-source"; diff --git a/UI/Web/src/app/_models/standalone-chapter.ts b/UI/Web/src/app/_models/standalone-chapter.ts new file mode 100644 index 000000000..9d640ad81 --- /dev/null +++ b/UI/Web/src/app/_models/standalone-chapter.ts @@ -0,0 +1,9 @@ +import {Chapter} from "./chapter"; +import {LibraryType} from "./library/library"; + +export interface StandaloneChapter extends Chapter { + seriesId: number; + libraryId: number; + libraryType: LibraryType; + volumeTitle?: string; +} diff --git a/UI/Web/src/app/_models/theme/colorscape.ts b/UI/Web/src/app/_models/theme/colorscape.ts new file mode 100644 index 000000000..1fbd436fe --- /dev/null +++ b/UI/Web/src/app/_models/theme/colorscape.ts @@ -0,0 +1,4 @@ +export interface ColorScape { + primary?: string; + secondary?: string; +} diff --git a/UI/Web/src/app/_models/theme/downloadable-site-theme.ts b/UI/Web/src/app/_models/theme/downloadable-site-theme.ts new file mode 100644 index 000000000..62885c063 --- /dev/null +++ b/UI/Web/src/app/_models/theme/downloadable-site-theme.ts @@ -0,0 +1,10 @@ +export interface DownloadableSiteTheme { + name: string; + cssUrl: string; + previewUrls: Array; + author: string; + isCompatible: boolean; + lastCompatibleVersion: string; + alreadyDownloaded: boolean; + description: string; +} diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index e5a74dbcf..c94a9485d 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -1,14 +1,16 @@ -import { AgeRestriction } from './metadata/age-restriction'; -import { Preferences } from './preferences/preferences'; +import {AgeRestriction} from './metadata/age-restriction'; +import {Preferences} from './preferences/preferences'; // This interface is only used for login and storing/retrieving JWT from local storage export interface User { - username: string; - token: string; - refreshToken: string; - roles: string[]; - preferences: Preferences; - apiKey: string; - email: string; - ageRestriction: AgeRestriction; + username: string; + token: string; + refreshToken: string; + roles: string[]; + preferences: Preferences; + apiKey: string; + email: string; + ageRestriction: AgeRestriction; + hasRunScrobbleEventGeneration: boolean; + scrobbleEventGenerationRan: string; // datetime } diff --git a/UI/Web/src/app/_models/volume.ts b/UI/Web/src/app/_models/volume.ts index e944438a3..fa4a7989b 100644 --- a/UI/Web/src/app/_models/volume.ts +++ b/UI/Web/src/app/_models/volume.ts @@ -1,7 +1,10 @@ import { Chapter } from './chapter'; import { HourEstimateRange } from './series-detail/hour-estimate-range'; +import {IHasCover} from "./common/i-has-cover"; +import {IHasReadingTime} from "./common/i-has-reading-time"; +import {IHasProgress} from "./common/i-has-progress"; -export interface Volume { +export interface Volume extends IHasCover, IHasReadingTime, IHasProgress { id: number; minNumber: number; maxNumber: number; @@ -10,6 +13,7 @@ export interface Volume { lastModifiedUtc: string; pages: number; pagesRead: number; + wordCount: number; chapters: Array; /** * This is only available on the object when fetched for SeriesDetail @@ -18,4 +22,9 @@ export interface Volume { minHoursToRead: number; maxHoursToRead: number; avgHoursToRead: number; + + coverImage?: string; + coverImageLocked: boolean; + primaryColor: string; + secondaryColor: string; } diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts new file mode 100644 index 000000000..21b669f0c --- /dev/null +++ b/UI/Web/src/app/_models/wiki.ts @@ -0,0 +1,24 @@ +export enum WikiLink { + Customize = 'https://wiki.kavitareader.com/guides/features/customization', + CustomizeExternalSource = 'https://wiki.kavitareader.com/guides/features/customization#external-source', + ReadingLists = 'https://wiki.kavitareader.com/guides/features/readinglists', + Collections = 'https://wiki.kavitareader.com/guides/features/collections', + SeriesRelationships = 'https://wiki.kavitareader.com/guides/features/relationships', + Bookmarks = 'https://wiki.kavitareader.com/guides/features/bookmarks', + DataCollection = 'https://wiki.kavitareader.com/troubleshooting/faq#q-does-kavita-collect-any-data-on-me', + MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/mediaissues/', + KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id', + KavitaPlus = 'https://wiki.kavitareader.com/kavita+', + KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq', + ReadingListCBL = 'https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl', + Donation = 'https://wiki.kavitareader.com/donating', + Updating = 'https://wiki.kavitareader.com/guides/updating', + ManagingFiles = 'https://wiki.kavitareader.com/guides/scanner/managefiles', + Scanner = 'https://wiki.kavitareader.com/guides/scanner', + ScannerExclude = 'https://wiki.kavitareader.com/guides/admin-settings/libraries#exclude-patterns', + Library = 'https://wiki.kavitareader.com/guides/admin-settings/libraries', + UpdateNative = 'https://wiki.kavitareader.com/guides/updating/updating-native', + UpdateDocker = 'https://wiki.kavitareader.com/guides/updating/updating-docker', + OpdsClients = 'https://wiki.kavitareader.com/guides/features/opds/#opds-capable-clients', + Guides = 'https://wiki.kavitareader.com/guides' +} diff --git a/UI/Web/src/app/_pipes/age-rating.pipe.ts b/UI/Web/src/app/_pipes/age-rating.pipe.ts index 44db8d8e9..f99a77f72 100644 --- a/UI/Web/src/app/_pipes/age-rating.pipe.ts +++ b/UI/Web/src/app/_pipes/age-rating.pipe.ts @@ -1,22 +1,22 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; -import { Observable, of } from 'rxjs'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRatingDto } from '../_models/metadata/age-rating-dto'; -import {TranslocoService} from "@ngneat/transloco"; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRatingDto} from '../_models/metadata/age-rating-dto'; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'ageRating', - standalone: true + standalone: true, + pure: true }) export class AgeRatingPipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); - transform(value: AgeRating | AgeRatingDto | undefined): Observable { - if (value === undefined || value === null) return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + transform(value: AgeRating | AgeRatingDto | undefined): string { + if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown'); if (value.hasOwnProperty('title')) { - return of((value as AgeRatingDto).title); + return (value as AgeRatingDto).title; } switch (value) { @@ -54,7 +54,7 @@ export class AgeRatingPipe implements PipeTransform { return this.translocoService.translate('age-rating-pipe.r18-plus'); } - return of(this.translocoService.translate('age-rating-pipe.unknown') as string); + return this.translocoService.translate('age-rating-pipe.unknown'); } } diff --git a/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts b/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts new file mode 100644 index 000000000..41758552f --- /dev/null +++ b/UI/Web/src/app/_pipes/book-page-layout-mode.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'bookPageLayoutMode', + standalone: true +}) +export class BookPageLayoutModePipe implements PipeTransform { + + transform(value: BookPageLayoutMode): string { + const v = parseInt(value + '', 10) as BookPageLayoutMode; + switch (v) { + case BookPageLayoutMode.Column1: return translate('preferences.1-column'); + case BookPageLayoutMode.Column2: return translate('preferences.2-column'); + case BookPageLayoutMode.Default: return translate('preferences.scroll'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/cbl-conflict-reason.pipe.ts b/UI/Web/src/app/_pipes/cbl-conflict-reason.pipe.ts index 47cbf1865..6f5463cf2 100644 --- a/UI/Web/src/app/_pipes/cbl-conflict-reason.pipe.ts +++ b/UI/Web/src/app/_pipes/cbl-conflict-reason.pipe.ts @@ -1,7 +1,7 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { CblBookResult } from 'src/app/_models/reading-list/cbl/cbl-book-result'; import { CblImportReason } from 'src/app/_models/reading-list/cbl/cbl-import-reason.enum'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; const failIcon = ''; const successIcon = ''; @@ -23,7 +23,7 @@ export class CblConflictReasonPipe implements PipeTransform { case CblImportReason.EmptyFile: return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.empty-file'); case CblImportReason.NameConflict: - return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.chapter-missing', {readingListName: result.readingListName}); + return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.name-conflict', {readingListName: result.readingListName}); case CblImportReason.SeriesCollision: return failIcon + this.translocoService.translate('cbl-conflict-reason-pipe.series-collision', {seriesLink: `
${result.series}`}); case CblImportReason.SeriesMissing: diff --git a/UI/Web/src/app/_pipes/cbl-import-result.pipe.ts b/UI/Web/src/app/_pipes/cbl-import-result.pipe.ts index 274dbf33b..c168ebcb2 100644 --- a/UI/Web/src/app/_pipes/cbl-import-result.pipe.ts +++ b/UI/Web/src/app/_pipes/cbl-import-result.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'cblImportResult', diff --git a/UI/Web/src/app/_pipes/confirm-translate.pipe.ts b/UI/Web/src/app/_pipes/confirm-translate.pipe.ts new file mode 100644 index 000000000..008f28849 --- /dev/null +++ b/UI/Web/src/app/_pipes/confirm-translate.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { translate } from '@jsverse/transloco'; + +@Pipe({ + name: 'confirmTranslate', + standalone: true +}) +export class ConfirmTranslatePipe implements PipeTransform { + + transform(value: string | undefined | null): string | undefined | null { + if (!value) return value; + + if (value.startsWith('confirm.')) { + return translate(value); + } + + return value; + } + +} diff --git a/UI/Web/src/app/_pipes/cover-image-size.pipe.ts b/UI/Web/src/app/_pipes/cover-image-size.pipe.ts new file mode 100644 index 000000000..8f54e269e --- /dev/null +++ b/UI/Web/src/app/_pipes/cover-image-size.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {CoverImageSize} from "../admin/_models/cover-image-size"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'coverImageSize', + standalone: true +}) +export class CoverImageSizePipe implements PipeTransform { + + transform(value: CoverImageSize): string { + switch (value) { + case CoverImageSize.Default: + return translate('cover-image-size.default'); + case CoverImageSize.Medium: + return translate('cover-image-size.medium'); + case CoverImageSize.Large: + return translate('cover-image-size.large'); + case CoverImageSize.XLarge: + return translate('cover-image-size.xlarge'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/day-of-week.pipe.ts b/UI/Web/src/app/_pipes/day-of-week.pipe.ts index f2fd9c0fe..30bd478c9 100644 --- a/UI/Web/src/app/_pipes/day-of-week.pipe.ts +++ b/UI/Web/src/app/_pipes/day-of-week.pipe.ts @@ -1,6 +1,6 @@ import {Pipe, PipeTransform} from '@angular/core'; import { DayOfWeek } from 'src/app/_services/statistics.service'; -import {translate} from "@ngneat/transloco"; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'dayOfWeek', diff --git a/UI/Web/src/app/_pipes/default-date.pipe.ts b/UI/Web/src/app/_pipes/default-date.pipe.ts index 8f29574e4..7cd541e0b 100644 --- a/UI/Web/src/app/_pipes/default-date.pipe.ts +++ b/UI/Web/src/app/_pipes/default-date.pipe.ts @@ -1,5 +1,5 @@ import { Pipe, PipeTransform } from '@angular/core'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'defaultDate', @@ -8,7 +8,6 @@ import {TranslocoService} from "@ngneat/transloco"; }) export class DefaultDatePipe implements PipeTransform { - // TODO: Figure out how to translate Never constructor(private translocoService: TranslocoService) { } transform(value: any, replacementString = 'default-date-pipe.never'): string { diff --git a/UI/Web/src/app/_pipes/device-platform.pipe.ts b/UI/Web/src/app/_pipes/device-platform.pipe.ts index e95d43788..4f2090329 100644 --- a/UI/Web/src/app/_pipes/device-platform.pipe.ts +++ b/UI/Web/src/app/_pipes/device-platform.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { DevicePlatform } from 'src/app/_models/device/device-platform'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'devicePlatform', diff --git a/UI/Web/src/app/_pipes/encode-format.pipe.ts b/UI/Web/src/app/_pipes/encode-format.pipe.ts new file mode 100644 index 000000000..f082c0495 --- /dev/null +++ b/UI/Web/src/app/_pipes/encode-format.pipe.ts @@ -0,0 +1,21 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {EncodeFormat} from "../admin/_models/encode-format"; + +@Pipe({ + name: 'encodeFormat', + standalone: true +}) +export class EncodeFormatPipe implements PipeTransform { + + transform(value: EncodeFormat): string { + switch (value) { + case EncodeFormat.PNG: + return 'PNG'; + case EncodeFormat.WebP: + return 'WebP'; + case EncodeFormat.AVIF: + return 'AVIF'; + } + } + +} diff --git a/UI/Web/src/app/_pipes/file-type-group.pipe.ts b/UI/Web/src/app/_pipes/file-type-group.pipe.ts index e996eafde..88d595420 100644 --- a/UI/Web/src/app/_pipes/file-type-group.pipe.ts +++ b/UI/Web/src/app/_pipes/file-type-group.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; import {FileTypeGroup} from "../_models/library/file-type-group.enum"; -import {translate} from "@ngneat/transloco"; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'fileTypeGroup', diff --git a/UI/Web/src/app/_pipes/filter-comparison.pipe.ts b/UI/Web/src/app/_pipes/filter-comparison.pipe.ts index 33a7c960e..6af542d80 100644 --- a/UI/Web/src/app/_pipes/filter-comparison.pipe.ts +++ b/UI/Web/src/app/_pipes/filter-comparison.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; import { FilterComparison } from 'src/app/_models/metadata/v2/filter-comparison'; -import {translate} from "@ngneat/transloco"; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'filterComparison', @@ -42,6 +42,8 @@ export class FilterComparisonPipe implements PipeTransform { return translate('filter-comparison-pipe.is-not-in-last'); case FilterComparison.MustContains: return translate('filter-comparison-pipe.must-contains'); + case FilterComparison.IsEmpty: + return translate('filter-comparison-pipe.is-empty'); default: throw new Error(`Invalid FilterComparison value: ${value}`); } diff --git a/UI/Web/src/app/_pipes/filter-field.pipe.ts b/UI/Web/src/app/_pipes/filter-field.pipe.ts index d94960dce..056d99f53 100644 --- a/UI/Web/src/app/_pipes/filter-field.pipe.ts +++ b/UI/Web/src/app/_pipes/filter-field.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; import { FilterField } from 'src/app/_models/metadata/v2/filter-field'; -import {translate} from "@ngneat/transloco"; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'filterField', @@ -28,6 +28,12 @@ export class FilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.genres'); case FilterField.Inker: return translate('filter-field-pipe.inker'); + case FilterField.Imprint: + return translate('filter-field-pipe.imprint'); + case FilterField.Team: + return translate('filter-field-pipe.team'); + case FilterField.Location: + return translate('filter-field-pipe.location'); case FilterField.Languages: return translate('filter-field-pipe.languages'); case FilterField.Libraries: @@ -66,6 +72,8 @@ export class FilterFieldPipe implements PipeTransform { return translate('filter-field-pipe.want-to-read'); case FilterField.ReadingDate: return translate('filter-field-pipe.read-date'); + case FilterField.ReadLast: + return translate('filter-field-pipe.read-last'); case FilterField.AverageRating: return translate('filter-field-pipe.average-rating'); default: diff --git a/UI/Web/src/app/_pipes/filter.pipe.ts b/UI/Web/src/app/_pipes/filter.pipe.ts index 61890e6b2..d1fbaf239 100644 --- a/UI/Web/src/app/_pipes/filter.pipe.ts +++ b/UI/Web/src/app/_pipes/filter.pipe.ts @@ -14,6 +14,6 @@ export class FilterPipe implements PipeTransform { const ret = items.filter(item => callback(item)); if (ret.length === items.length) return items; // This will prevent a re-render return ret; -} + } } diff --git a/UI/Web/src/app/_pipes/language-name.pipe.ts b/UI/Web/src/app/_pipes/language-name.pipe.ts index be6d954b0..697554bd3 100644 --- a/UI/Web/src/app/_pipes/language-name.pipe.ts +++ b/UI/Web/src/app/_pipes/language-name.pipe.ts @@ -9,16 +9,10 @@ import {shareReplay} from "rxjs/operators"; }) export class LanguageNamePipe implements PipeTransform { - constructor(private metadataService: MetadataService) { - } + constructor(private metadataService: MetadataService) {} transform(isoCode: string): Observable { - // TODO: See if we can speed this up. It rarely changes and is quite heavy to download on each page - return this.metadataService.getAllValidLanguages().pipe(map(lang => { - const l = lang.filter(l => l.isoCode === isoCode); - if (l.length > 0) return l[0].title; - return ''; - }), shareReplay()); + return this.metadataService.getLanguageNameForCode(isoCode).pipe(shareReplay()); } } diff --git a/UI/Web/src/app/_pipes/layout-mode.pipe.ts b/UI/Web/src/app/_pipes/layout-mode.pipe.ts new file mode 100644 index 000000000..ab598a7f4 --- /dev/null +++ b/UI/Web/src/app/_pipes/layout-mode.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {LayoutMode} from "../manga-reader/_models/layout-mode"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'layoutMode', + standalone: true +}) +export class LayoutModePipe implements PipeTransform { + + transform(value: LayoutMode): string { + const v = parseInt(value + '', 10) as LayoutMode; + switch (v) { + case LayoutMode.Single: return translate('preferences.single'); + case LayoutMode.Double: return translate('preferences.double'); + case LayoutMode.DoubleReversed: return translate('preferences.double-manga'); + case LayoutMode.DoubleNoCover: return translate('preferences.double-no-cover'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/library-name.pipe.ts b/UI/Web/src/app/_pipes/library-name.pipe.ts new file mode 100644 index 000000000..c34f50166 --- /dev/null +++ b/UI/Web/src/app/_pipes/library-name.pipe.ts @@ -0,0 +1,16 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {LibraryService} from "../_services/library.service"; +import {Observable} from "rxjs"; + +@Pipe({ + name: 'libraryName', + standalone: true +}) +export class LibraryNamePipe implements PipeTransform { + private readonly libraryService = inject(LibraryService); + + transform(libraryId: number): Observable { + return this.libraryService.getLibraryName(libraryId); + } + +} diff --git a/UI/Web/src/app/_pipes/library-type.pipe.ts b/UI/Web/src/app/_pipes/library-type.pipe.ts index f30b2a983..784089e6e 100644 --- a/UI/Web/src/app/_pipes/library-type.pipe.ts +++ b/UI/Web/src/app/_pipes/library-type.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { LibraryType } from '../_models/library/library'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; /** * Returns the name of the LibraryType @@ -11,15 +11,21 @@ import {TranslocoService} from "@ngneat/transloco"; }) export class LibraryTypePipe implements PipeTransform { - translocoService = inject(TranslocoService); + private readonly translocoService = inject(TranslocoService); transform(libraryType: LibraryType): string { switch (libraryType) { case LibraryType.Book: return this.translocoService.translate('library-type-pipe.book'); case LibraryType.Comic: return this.translocoService.translate('library-type-pipe.comic'); + case LibraryType.ComicVine: + return this.translocoService.translate('library-type-pipe.comicVine'); + case LibraryType.Images: + return this.translocoService.translate('library-type-pipe.image'); case LibraryType.Manga: return this.translocoService.translate('library-type-pipe.manga'); + case LibraryType.LightNovel: + return this.translocoService.translate('library-type-pipe.lightNovel'); case LibraryType.Magazine: return this.translocoService.translate('library-type-pipe.magazine'); default: diff --git a/UI/Web/src/app/_pipes/log-level.pipe.ts b/UI/Web/src/app/_pipes/log-level.pipe.ts new file mode 100644 index 000000000..1a1c7c19a --- /dev/null +++ b/UI/Web/src/app/_pipes/log-level.pipe.ts @@ -0,0 +1,17 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {translate} from "@jsverse/transloco"; + +/** + * Transforms the log level string into a localized string + */ +@Pipe({ + name: 'logLevel', + standalone: true, + pure: true +}) +export class LogLevelPipe implements PipeTransform { + transform(value: string): string { + return translate('log-level-pipe.' + value.toLowerCase()); + } + +} diff --git a/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts b/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts index 73580252a..ba63e7df6 100644 --- a/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts +++ b/UI/Web/src/app/_pipes/manga-format-icon.pipe.ts @@ -13,15 +13,15 @@ export class MangaFormatIconPipe implements PipeTransform { transform(format: MangaFormat): string { switch (format) { case MangaFormat.EPUB: - return 'fa-book'; + return 'fa fa-book'; case MangaFormat.ARCHIVE: - return 'fa-file-archive'; + return 'fa-solid fa-file-zipper'; case MangaFormat.IMAGE: - return 'fa-image'; + return 'fa-solid fa-file-image'; case MangaFormat.PDF: - return 'fa-file-pdf'; + return 'fa-solid fa-file-pdf'; case MangaFormat.UNKNOWN: - return 'fa-question'; + return 'fa-solid fa-file-circle-question'; } } diff --git a/UI/Web/src/app/_pipes/manga-format.pipe.ts b/UI/Web/src/app/_pipes/manga-format.pipe.ts index 9b526ae10..60672271b 100644 --- a/UI/Web/src/app/_pipes/manga-format.pipe.ts +++ b/UI/Web/src/app/_pipes/manga-format.pipe.ts @@ -1,6 +1,6 @@ import {Pipe, PipeTransform} from '@angular/core'; import { MangaFormat } from '../_models/manga-format'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; /** * Returns the string name for the format diff --git a/UI/Web/src/app/_pipes/match-state.pipe.ts b/UI/Web/src/app/_pipes/match-state.pipe.ts new file mode 100644 index 000000000..9f0cb00ae --- /dev/null +++ b/UI/Web/src/app/_pipes/match-state.pipe.ts @@ -0,0 +1,27 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {MatchStateOption} from "../_models/kavitaplus/match-state-option"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'matchStateOption', + standalone: true +}) +export class MatchStateOptionPipe implements PipeTransform { + + transform(value: MatchStateOption): string { + switch (value) { + case MatchStateOption.DontMatch: + return translate('manage-matched-metadata.dont-match-label'); + case MatchStateOption.All: + return translate('manage-matched-metadata.all-status-label'); + case MatchStateOption.Matched: + return translate('manage-matched-metadata.matched-status-label'); + case MatchStateOption.NotMatched: + return translate('manage-matched-metadata.unmatched-status-label'); + case MatchStateOption.Error: + return translate('manage-matched-metadata.blacklist-status-label'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts new file mode 100644 index 000000000..dcaed4f69 --- /dev/null +++ b/UI/Web/src/app/_pipes/metadata-setting-filed.pipe.ts @@ -0,0 +1,45 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {MetadataSettingField} from "../admin/_models/metadata-setting-field"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'metadataSettingFiled', + standalone: true +}) +export class MetadataSettingFiledPipe implements PipeTransform { + + transform(value: MetadataSettingField): string { + switch (value) { + case MetadataSettingField.ChapterTitle: + return translate('metadata-setting-field-pipe.chapter-title'); + case MetadataSettingField.ChapterSummary: + return translate('metadata-setting-field-pipe.chapter-summary'); + case MetadataSettingField.ChapterReleaseDate: + return translate('metadata-setting-field-pipe.chapter-release-date'); + case MetadataSettingField.ChapterPublisher: + return translate('metadata-setting-field-pipe.chapter-publisher'); + case MetadataSettingField.ChapterCovers: + return translate('metadata-setting-field-pipe.chapter-covers'); + case MetadataSettingField.AgeRating: + return translate('metadata-setting-field-pipe.age-rating'); + case MetadataSettingField.People: + return translate('metadata-setting-field-pipe.people'); + case MetadataSettingField.Covers: + return translate('metadata-setting-field-pipe.covers'); + case MetadataSettingField.Summary: + return translate('metadata-setting-field-pipe.summary'); + case MetadataSettingField.PublicationStatus: + return translate('metadata-setting-field-pipe.publication-status'); + case MetadataSettingField.StartDate: + return translate('metadata-setting-field-pipe.start-date'); + case MetadataSettingField.Genres: + return translate('metadata-setting-field-pipe.genres'); + case MetadataSettingField.Tags: + return translate('metadata-setting-field-pipe.tags'); + case MetadataSettingField.LocalizedName: + return translate('metadata-setting-field-pipe.localized-name'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/page-layout-mode.pipe.ts b/UI/Web/src/app/_pipes/page-layout-mode.pipe.ts new file mode 100644 index 000000000..45928d66b --- /dev/null +++ b/UI/Web/src/app/_pipes/page-layout-mode.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {PageLayoutMode} from "../_models/page-layout-mode"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'pageLayoutMode', + standalone: true +}) +export class PageLayoutModePipe implements PipeTransform { + + transform(value: PageLayoutMode): string { + switch (value) { + case PageLayoutMode.Cards: return translate('preferences.cards'); + case PageLayoutMode.List: return translate('preferences.list'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/page-split-option.pipe.ts b/UI/Web/src/app/_pipes/page-split-option.pipe.ts new file mode 100644 index 000000000..da6251f72 --- /dev/null +++ b/UI/Web/src/app/_pipes/page-split-option.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {PageSplitOption} from "../_models/preferences/page-split-option"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'pageSplitOption', + standalone: true +}) +export class PageSplitOptionPipe implements PipeTransform { + + transform(value: PageSplitOption): string { + const v = parseInt(value + '', 10) as PageSplitOption; + switch (v) { + case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen'); + case PageSplitOption.NoSplit: return translate('preferences.no-split'); + case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right'); + case PageSplitOption.SplitRightToLeft: return translate('preferences.split-right-to-left'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts b/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts new file mode 100644 index 000000000..d395911d6 --- /dev/null +++ b/UI/Web/src/app/_pipes/pdf-scroll-mode.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {PdfScrollMode} from "../_models/preferences/pdf-scroll-mode"; + +@Pipe({ + name: 'pdfScrollMode', + standalone: true +}) +export class PdfScrollModePipe implements PipeTransform { + + transform(value: PdfScrollMode): string { + const v = parseInt(value + '', 10) as PdfScrollMode; + switch (v) { + case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple'); + case PdfScrollMode.Page: return translate('preferences.pdf-page'); + case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal'); + case PdfScrollMode.Vertical: return translate('preferences.pdf-vertical'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts b/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts new file mode 100644 index 000000000..2f02363a3 --- /dev/null +++ b/UI/Web/src/app/_pipes/pdf-spread-mode.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode"; +import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'pdfSpreadMode', + standalone: true +}) +export class PdfSpreadModePipe implements PipeTransform { + + transform(value: PdfSpreadMode): string { + const v = parseInt(value + '', 10) as PdfSpreadMode; + switch (v) { + case PdfSpreadMode.None: return translate('preferences.pdf-none'); + case PdfSpreadMode.Odd: return translate('preferences.pdf-odd'); + case PdfSpreadMode.Even: return translate('preferences.pdf-even'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/pdf-theme.pipe.ts b/UI/Web/src/app/_pipes/pdf-theme.pipe.ts new file mode 100644 index 000000000..4e64d85e8 --- /dev/null +++ b/UI/Web/src/app/_pipes/pdf-theme.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {PdfTheme} from "../_models/preferences/pdf-theme"; +import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'pdfTheme', + standalone: true +}) +export class PdfThemePipe implements PipeTransform { + + transform(value: PdfTheme): string { + const v = parseInt(value + '', 10) as PdfTheme; + switch (v) { + case PdfTheme.Dark: return translate('preferences.pdf-dark'); + case PdfTheme.Light: return translate('preferences.pdf-light'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/person-role.pipe.ts b/UI/Web/src/app/_pipes/person-role.pipe.ts index 1e7f6ebac..c1395ae5b 100644 --- a/UI/Web/src/app/_pipes/person-role.pipe.ts +++ b/UI/Web/src/app/_pipes/person-role.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { PersonRole } from '../_models/metadata/person'; -import {TranslocoService} from "@ngneat/transloco"; +import {translate, TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'personRole', @@ -8,31 +8,38 @@ import {TranslocoService} from "@ngneat/transloco"; }) export class PersonRolePipe implements PipeTransform { - translocoService = inject(TranslocoService); transform(value: PersonRole): string { switch (value) { case PersonRole.Artist: - return this.translocoService.translate('person-role-pipe.artist'); + return translate('person-role-pipe.artist'); case PersonRole.Character: - return this.translocoService.translate('person-role-pipe.character'); + return translate('person-role-pipe.character'); case PersonRole.Colorist: - return this.translocoService.translate('person-role-pipe.colorist'); + return translate('person-role-pipe.colorist'); case PersonRole.CoverArtist: - return this.translocoService.translate('person-role-pipe.cover-artist'); + return translate('person-role-pipe.artist'); case PersonRole.Editor: - return this.translocoService.translate('person-role-pipe.editor'); + return translate('person-role-pipe.editor'); case PersonRole.Inker: - return this.translocoService.translate('person-role-pipe.inker'); + return translate('person-role-pipe.inker'); case PersonRole.Letterer: - return this.translocoService.translate('person-role-pipe.letterer'); + return translate('person-role-pipe.letterer'); case PersonRole.Penciller: - return this.translocoService.translate('person-role-pipe.penciller'); + return translate('person-role-pipe.penciller'); case PersonRole.Publisher: - return this.translocoService.translate('person-role-pipe.publisher'); + return translate('person-role-pipe.publisher'); + case PersonRole.Imprint: + return translate('person-role-pipe.imprint'); case PersonRole.Writer: - return this.translocoService.translate('person-role-pipe.writer'); + return translate('person-role-pipe.writer'); + case PersonRole.Team: + return translate('person-role-pipe.team'); + case PersonRole.Location: + return translate('person-role-pipe.location'); + case PersonRole.Translator: + return translate('person-role-pipe.translator'); case PersonRole.Other: - return this.translocoService.translate('person-role-pipe.other'); + return translate('person-role-pipe.other'); default: return ''; } diff --git a/UI/Web/src/app/_pipes/plus-media-format.pipe.ts b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts new file mode 100644 index 000000000..b72822e33 --- /dev/null +++ b/UI/Web/src/app/_pipes/plus-media-format.pipe.ts @@ -0,0 +1,25 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {PlusMediaFormat} from "../_models/series-detail/external-series-detail"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'plusMediaFormat', + standalone: true +}) +export class PlusMediaFormatPipe implements PipeTransform { + + transform(value: PlusMediaFormat): string { + switch (value) { + case PlusMediaFormat.Manga: + return translate('library-type-pipe.manga'); + case PlusMediaFormat.Comic: + return translate('library-type-pipe.comicVine'); + case PlusMediaFormat.LightNovel: + return translate('library-type-pipe.lightNovel'); + case PlusMediaFormat.Book: + return translate('library-type-pipe.book'); + + } + } + +} diff --git a/UI/Web/src/app/_pipes/provider-image.pipe.ts b/UI/Web/src/app/_pipes/provider-image.pipe.ts index 75e656651..5d845a672 100644 --- a/UI/Web/src/app/_pipes/provider-image.pipe.ts +++ b/UI/Web/src/app/_pipes/provider-image.pipe.ts @@ -7,16 +7,18 @@ import {ScrobbleProvider} from "../_services/scrobbling.service"; }) export class ProviderImagePipe implements PipeTransform { - transform(value: ScrobbleProvider): string { + transform(value: ScrobbleProvider, large: boolean = false): string { switch (value) { case ScrobbleProvider.AniList: - return 'assets/images/ExternalServices/AniList.png'; + return `assets/images/ExternalServices/AniList${large ? '-lg' : ''}.png`; case ScrobbleProvider.Mal: - return 'assets/images/ExternalServices/MAL.png'; + return `assets/images/ExternalServices/MAL${large ? '-lg' : ''}.png`; case ScrobbleProvider.GoogleBooks: - return 'assets/images/ExternalServices/GoogleBooks.png'; + return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`; case ScrobbleProvider.Kavita: - return 'assets/images/logo-32.png'; + return `assets/images/logo-${large ? '64' : '32'}.png`; + case ScrobbleProvider.Cbr: + return `assets/images/ExternalServices/ComicBookRoundup.png`; } } diff --git a/UI/Web/src/app/_pipes/provider-name.pipe.ts b/UI/Web/src/app/_pipes/provider-name.pipe.ts deleted file mode 100644 index 1947b3f5f..000000000 --- a/UI/Web/src/app/_pipes/provider-name.pipe.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import {ScrobbleProvider} from "../_services/scrobbling.service"; - -@Pipe({ - name: 'providerName', - standalone: true -}) -export class ProviderNamePipe implements PipeTransform { - - transform(value: ScrobbleProvider): string { - switch (value) { - case ScrobbleProvider.AniList: - return 'AniList'; - case ScrobbleProvider.Mal: - return 'MAL'; - case ScrobbleProvider.Kavita: - return 'Kavita'; - case ScrobbleProvider.GoogleBooks: - return 'Google Books'; - } - } - -} diff --git a/UI/Web/src/app/_pipes/publication-status.pipe.ts b/UI/Web/src/app/_pipes/publication-status.pipe.ts index f4d5a621d..98a62a2b6 100644 --- a/UI/Web/src/app/_pipes/publication-status.pipe.ts +++ b/UI/Web/src/app/_pipes/publication-status.pipe.ts @@ -1,6 +1,6 @@ import {Pipe, PipeTransform} from '@angular/core'; import { PublicationStatus } from '../_models/metadata/publication-status'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'publicationStatus', diff --git a/UI/Web/src/app/_pipes/read-time-left.pipe.ts b/UI/Web/src/app/_pipes/read-time-left.pipe.ts new file mode 100644 index 000000000..43ac41c86 --- /dev/null +++ b/UI/Web/src/app/_pipes/read-time-left.pipe.ts @@ -0,0 +1,39 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {TranslocoService} from "@jsverse/transloco"; +import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range"; +import {DecimalPipe} from "@angular/common"; + +@Pipe({ + name: 'readTimeLeft', + standalone: true +}) +export class ReadTimeLeftPipe implements PipeTransform { + + constructor(private readonly translocoService: TranslocoService) {} + + transform(readingTimeLeft: HourEstimateRange): string { + const hoursLabel = readingTimeLeft.avgHours > 1 + ? this.translocoService.translate('read-time-pipe.hours') + : this.translocoService.translate('read-time-pipe.hour'); + + const formattedHours = this.customRound(readingTimeLeft.avgHours); + + return `~${formattedHours} ${hoursLabel}`; + } + + private customRound(value: number): string { + const integerPart = Math.floor(value); + const decimalPart = value - integerPart; + + if (decimalPart < 0.5) { + // Round down to the nearest whole number + return integerPart.toString(); + } else if (decimalPart >= 0.5 && decimalPart < 0.9) { + // Return with 1 decimal place + return value.toFixed(1); + } else { + // Round up to the nearest whole number + return Math.ceil(value).toString(); + } + } +} diff --git a/UI/Web/src/app/_pipes/read-time.pipe.ts b/UI/Web/src/app/_pipes/read-time.pipe.ts new file mode 100644 index 000000000..1970b2812 --- /dev/null +++ b/UI/Web/src/app/_pipes/read-time.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {IHasReadingTime} from "../_models/common/i-has-reading-time"; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'readTime', + standalone: true +}) +export class ReadTimePipe implements PipeTransform { + constructor(private translocoService: TranslocoService) {} + + transform(readingTime: IHasReadingTime): string { + if (readingTime.maxHoursToRead === 0 || readingTime.minHoursToRead === 0) { + return this.translocoService.translate('read-time-pipe.less-than-hour'); + } else { + return `${readingTime.minHoursToRead}${readingTime.maxHoursToRead !== readingTime.minHoursToRead ? ('-' + readingTime.maxHoursToRead) : ''}` + + ` ${readingTime.minHoursToRead > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`; + } + } + +} diff --git a/UI/Web/src/app/_pipes/reading-direction.pipe.ts b/UI/Web/src/app/_pipes/reading-direction.pipe.ts new file mode 100644 index 000000000..d9a4097b0 --- /dev/null +++ b/UI/Web/src/app/_pipes/reading-direction.pipe.ts @@ -0,0 +1,19 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {ReadingDirection} from "../_models/preferences/reading-direction"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'readingDirection', + standalone: true +}) +export class ReadingDirectionPipe implements PipeTransform { + + transform(value: ReadingDirection): string { + const v = parseInt(value + '', 10) as ReadingDirection; + switch (v) { + case ReadingDirection.LeftToRight: return translate('preferences.left-to-right'); + case ReadingDirection.RightToLeft: return translate('preferences.right-to-left'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/reading-mode.pipe.ts b/UI/Web/src/app/_pipes/reading-mode.pipe.ts new file mode 100644 index 000000000..0404e0ffb --- /dev/null +++ b/UI/Web/src/app/_pipes/reading-mode.pipe.ts @@ -0,0 +1,21 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {ReaderMode} from "../_models/preferences/reader-mode"; +import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'readerMode', + standalone: true +}) +export class ReaderModePipe implements PipeTransform { + + transform(value: ReaderMode): string { + const v = parseInt(value + '', 10) as ReaderMode; + switch (v) { + case ReaderMode.UpDown: return translate('preferences.up-to-down'); + case ReaderMode.Webtoon: return translate('preferences.webtoon'); + case ReaderMode.LeftRight: return translate('preferences.left-to-right'); + } + } + +} diff --git a/UI/Web/src/app/_pipes/relationship.pipe.ts b/UI/Web/src/app/_pipes/relationship.pipe.ts index b73506ece..2764cbb56 100644 --- a/UI/Web/src/app/_pipes/relationship.pipe.ts +++ b/UI/Web/src/app/_pipes/relationship.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { RelationKind } from '../_models/series-detail/relation-kind'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'relationship', @@ -39,6 +39,8 @@ export class RelationshipPipe implements PipeTransform { return this.translocoService.translate('relationship-pipe.parent'); case RelationKind.Edition: return this.translocoService.translate('relationship-pipe.edition'); + case RelationKind.Annual: + return this.translocoService.translate('relationship-pipe.annual'); default: return ''; } diff --git a/UI/Web/src/app/_pipes/role-localized.pipe.ts b/UI/Web/src/app/_pipes/role-localized.pipe.ts new file mode 100644 index 000000000..1890962dd --- /dev/null +++ b/UI/Web/src/app/_pipes/role-localized.pipe.ts @@ -0,0 +1,15 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {Role} from "../_services/account.service"; +import {translate} from "@jsverse/transloco"; + +@Pipe({ + name: 'roleLocalized' +}) +export class RoleLocalizedPipe implements PipeTransform { + + transform(value: Role | string): string { + const key = (value + '').toLowerCase().replace(' ', '-'); + return translate(`role-localized-pipe.${key}`); + } + +} diff --git a/UI/Web/src/app/_pipes/safe-url.pipe.ts b/UI/Web/src/app/_pipes/safe-url.pipe.ts new file mode 100644 index 000000000..fed4ae2d8 --- /dev/null +++ b/UI/Web/src/app/_pipes/safe-url.pipe.ts @@ -0,0 +1,19 @@ +import { inject } from '@angular/core'; +import { Pipe, PipeTransform, SecurityContext } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; + +@Pipe({ + name: 'safeUrl', + pure: true, + standalone: true +}) +export class SafeUrlPipe implements PipeTransform { + private readonly dom: DomSanitizer = inject(DomSanitizer); + constructor() {} + + transform(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + return this.dom.sanitize(SecurityContext.URL, value); + } + +} diff --git a/UI/Web/src/app/_pipes/scaling-option.pipe.ts b/UI/Web/src/app/_pipes/scaling-option.pipe.ts new file mode 100644 index 000000000..d2124be7f --- /dev/null +++ b/UI/Web/src/app/_pipes/scaling-option.pipe.ts @@ -0,0 +1,22 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {ScalingOption} from "../_models/preferences/scaling-option"; +import {ReadingDirection} from "../_models/preferences/reading-direction"; + +@Pipe({ + name: 'scalingOption', + standalone: true +}) +export class ScalingOptionPipe implements PipeTransform { + + transform(value: ScalingOption): string { + const v = parseInt(value + '', 10) as ScalingOption; + switch (v) { + case ScalingOption.Automatic: return translate('preferences.automatic'); + case ScalingOption.FitToHeight: return translate('preferences.fit-to-height'); + case ScalingOption.FitToWidth: return translate('preferences.fit-to-width'); + case ScalingOption.Original: return translate('preferences.original'); + } + } + +} diff --git a/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts b/UI/Web/src/app/_pipes/scrobble-event-type.pipe.ts similarity index 95% rename from UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts rename to UI/Web/src/app/_pipes/scrobble-event-type.pipe.ts index 08e0b2996..7597b7f38 100644 --- a/UI/Web/src/app/_single-module/scrobble-event-type.pipe.ts +++ b/UI/Web/src/app/_pipes/scrobble-event-type.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import {ScrobbleEventType} from "../_models/scrobbling/scrobble-event"; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'scrobbleEventType', diff --git a/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts new file mode 100644 index 000000000..cc6e01449 --- /dev/null +++ b/UI/Web/src/app/_pipes/scrobble-provider-name.pipe.ts @@ -0,0 +1,20 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {ScrobbleProvider} from "../_services/scrobbling.service"; + +@Pipe({ + name: 'scrobbleProviderName', + standalone: true +}) +export class ScrobbleProviderNamePipe implements PipeTransform { + + transform(value: ScrobbleProvider): string { + switch (value) { + case ScrobbleProvider.AniList: return 'AniList'; + case ScrobbleProvider.Mal: return 'MAL'; + case ScrobbleProvider.Kavita: return 'Kavita'; + case ScrobbleProvider.Cbr: return 'Comicbook Roundup'; + case ScrobbleProvider.GoogleBooks: return 'Google Books'; + } + } + +} diff --git a/UI/Web/src/app/_pipes/setting-fragment.pipe.ts b/UI/Web/src/app/_pipes/setting-fragment.pipe.ts new file mode 100644 index 000000000..14425d21c --- /dev/null +++ b/UI/Web/src/app/_pipes/setting-fragment.pipe.ts @@ -0,0 +1,17 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; +import {translate} from "@jsverse/transloco"; + +/** + * Translates the fragment for Settings to a User title + */ +@Pipe({ + name: 'settingFragment', + standalone: true +}) +export class SettingFragmentPipe implements PipeTransform { + + transform(tabID: SettingsTabId | string): string { + return translate('settings.' + tabID); + } +} diff --git a/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts b/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts index e7af63752..6899d8d51 100644 --- a/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts +++ b/UI/Web/src/app/_pipes/site-theme-provider.pipe.ts @@ -1,6 +1,6 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; import { ThemeProvider } from 'src/app/_models/preferences/site-theme'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ @@ -16,8 +16,8 @@ export class SiteThemeProviderPipe implements PipeTransform { switch(provider) { case ThemeProvider.System: return this.translocoService.translate('site-theme-provider-pipe.system'); - case ThemeProvider.User: - return this.translocoService.translate('site-theme-provider-pipe.user'); + case ThemeProvider.Custom: + return this.translocoService.translate('site-theme-provider-pipe.custom'); default: return ''; } diff --git a/UI/Web/src/app/_pipes/sort-field.pipe.ts b/UI/Web/src/app/_pipes/sort-field.pipe.ts index ea54d124d..13ff4f758 100644 --- a/UI/Web/src/app/_pipes/sort-field.pipe.ts +++ b/UI/Web/src/app/_pipes/sort-field.pipe.ts @@ -1,6 +1,6 @@ import { Pipe, PipeTransform } from '@angular/core'; import {SortField} from "../_models/metadata/series-filter"; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; @Pipe({ name: 'sortField', @@ -29,6 +29,8 @@ export class SortFieldPipe implements PipeTransform { return this.translocoService.translate('sort-field-pipe.read-progress'); case SortField.AverageRating: return this.translocoService.translate('sort-field-pipe.average-rating'); + case SortField.Random: + return this.translocoService.translate('sort-field-pipe.random'); } } diff --git a/UI/Web/src/app/_pipes/stream-name.pipe.ts b/UI/Web/src/app/_pipes/stream-name.pipe.ts index 632beb0e7..c15974b6b 100644 --- a/UI/Web/src/app/_pipes/stream-name.pipe.ts +++ b/UI/Web/src/app/_pipes/stream-name.pipe.ts @@ -1,5 +1,5 @@ import { Pipe, PipeTransform } from '@angular/core'; -import {translate} from "@ngneat/transloco"; +import {translate} from "@jsverse/transloco"; @Pipe({ name: 'streamName', diff --git a/UI/Web/src/app/_pipes/time-ago.pipe.ts b/UI/Web/src/app/_pipes/time-ago.pipe.ts index a66c255f5..9940d4bb7 100644 --- a/UI/Web/src/app/_pipes/time-ago.pipe.ts +++ b/UI/Web/src/app/_pipes/time-ago.pipe.ts @@ -1,5 +1,5 @@ import {ChangeDetectorRef, NgZone, OnDestroy, Pipe, PipeTransform} from '@angular/core'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; /** * MIT License @@ -39,9 +39,8 @@ export class TimeAgoPipe implements PipeTransform, OnDestroy { constructor(private readonly changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private translocoService: TranslocoService) {} - transform(value: string) { - - if (value === '' || value === null || value === undefined || value.split('T')[0] === '0001-01-01') { + transform(value: string | Date | null) { + if (value === '' || value === null || value === undefined || (typeof value === 'string' && value.split('T')[0] === '0001-01-01')) { return this.translocoService.translate('time-ago-pipe.never'); } diff --git a/UI/Web/src/app/_pipes/time-duration.pipe.ts b/UI/Web/src/app/_pipes/time-duration.pipe.ts index c608ff51a..1d23bae4a 100644 --- a/UI/Web/src/app/_pipes/time-duration.pipe.ts +++ b/UI/Web/src/app/_pipes/time-duration.pipe.ts @@ -1,5 +1,5 @@ import {inject, Pipe, PipeTransform} from '@angular/core'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; /** * Converts hours -> days, months, years, etc diff --git a/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts b/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts index 22c8c4639..42bac615c 100644 --- a/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts +++ b/UI/Web/src/app/_pipes/utc-to-local-time.pipe.ts @@ -16,7 +16,10 @@ type UtcToLocalTimeFormat = 'full' | 'short' | 'shortDate' | 'shortTime'; export class UtcToLocalTimePipe implements PipeTransform { transform(utcDate: string | undefined | null, format: UtcToLocalTimeFormat = 'short'): string { - if (utcDate === undefined || utcDate === null) return ''; + if (utcDate === '' || utcDate === null || utcDate === undefined || utcDate.split('T')[0] === '0001-01-01') { + return ''; + } + const browserLanguage = navigator.language; const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage); diff --git a/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts new file mode 100644 index 000000000..0a25eefdc --- /dev/null +++ b/UI/Web/src/app/_pipes/utc-to-locale-date.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {DateTime} from "luxon"; + +@Pipe({ + name: 'utcToLocaleDate', + standalone: true +}) +/** + * This is the same as the UtcToLocalTimePipe but returning a timezone aware DateTime object rather than a string. + * Use this when the next operation needs a Date object (like the TimeAgoPipe) + */ +export class UtcToLocaleDatePipe implements PipeTransform { + + transform(utcDate: string | undefined | null): Date | null { + if (utcDate === '' || utcDate === null || utcDate === undefined || utcDate.split('T')[0] === '0001-01-01') { + return null; + } + + const browserLanguage = navigator.language; + const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage); + return dateTime.toJSDate() + } + +} diff --git a/UI/Web/src/app/_pipes/writing-style.pipe.ts b/UI/Web/src/app/_pipes/writing-style.pipe.ts new file mode 100644 index 000000000..140978950 --- /dev/null +++ b/UI/Web/src/app/_pipes/writing-style.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import {translate} from "@jsverse/transloco"; +import {WritingStyle} from "../_models/preferences/writing-style"; +import {ScalingOption} from "../_models/preferences/scaling-option"; + +@Pipe({ + name: 'writingStyle', + standalone: true +}) +export class WritingStylePipe implements PipeTransform { + + transform(value: WritingStyle): string { + const v = parseInt(value + '', 10) as WritingStyle; + switch (v) { + case WritingStyle.Horizontal: return translate('preferences.horizontal'); + case WritingStyle.Vertical: return translate('preferences.vertical'); + } + } + +} diff --git a/UI/Web/src/app/_routes/admin-routing.module.ts b/UI/Web/src/app/_routes/admin-routing.module.ts deleted file mode 100644 index 83918ccf2..000000000 --- a/UI/Web/src/app/_routes/admin-routing.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Routes } from '@angular/router'; -import { AdminGuard } from '../_guards/admin.guard'; -import { DashboardComponent } from '../admin/dashboard/dashboard.component'; - -export const routes: Routes = [ - {path: '**', component: DashboardComponent, pathMatch: 'full', canActivate: [AdminGuard]}, - { - path: '', - runGuardsAndResolvers: 'always', - canActivate: [AdminGuard], - children: [ - {path: 'dashboard', component: DashboardComponent}, - ] - } -]; - diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts new file mode 100644 index 000000000..e7aab1b57 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-authors-routing.module.ts @@ -0,0 +1,8 @@ +import { Routes } from "@angular/router"; +import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; + + +export const routes: Routes = [ + {path: '', component: BrowseAuthorsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/person-detail-routing.module.ts b/UI/Web/src/app/_routes/person-detail-routing.module.ts new file mode 100644 index 000000000..95b610cea --- /dev/null +++ b/UI/Web/src/app/_routes/person-detail-routing.module.ts @@ -0,0 +1,19 @@ +import { Routes } from '@angular/router'; +import { AuthGuard } from '../_guards/auth.guard'; +import {PersonDetailComponent} from "../person-detail/person-detail.component"; + + +export const routes: Routes = [ + { + path: ':name', + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + component: PersonDetailComponent + }, + { + path: '', + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + component: PersonDetailComponent + } +]; diff --git a/UI/Web/src/app/_routes/settings-routing.module.ts b/UI/Web/src/app/_routes/settings-routing.module.ts new file mode 100644 index 000000000..83cb040ef --- /dev/null +++ b/UI/Web/src/app/_routes/settings-routing.module.ts @@ -0,0 +1,6 @@ +import { Routes } from '@angular/router'; +import {SettingsComponent} from "../settings/_components/settings/settings.component"; + +export const routes: Routes = [ + {path: '', component: SettingsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/user-settings-routing.module.ts b/UI/Web/src/app/_routes/user-settings-routing.module.ts deleted file mode 100644 index a099acec7..000000000 --- a/UI/Web/src/app/_routes/user-settings-routing.module.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Routes } from '@angular/router'; -import { UserPreferencesComponent } from '../user-settings/user-preferences/user-preferences.component'; - -export const routes: Routes = [ - {path: '', component: UserPreferencesComponent, pathMatch: 'full'}, -]; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index a0ed0e20a..6b8cdc243 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -1,19 +1,22 @@ -import { HttpClient } from '@angular/common/http'; -import {DestroyRef, inject, Injectable } from '@angular/core'; -import {catchError, of, ReplaySubject, throwError} from 'rxjs'; +import {HttpClient} from '@angular/common/http'; +import {DestroyRef, inject, Injectable} from '@angular/core'; +import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { Preferences } from '../_models/preferences/preferences'; -import { User } from '../_models/user'; -import { Router } from '@angular/router'; -import { EVENTS, MessageHubService } from './message-hub.service'; -import { ThemeService } from './theme.service'; -import { InviteUserResponse } from '../_models/auth/invite-user-response'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { AgeRating } from '../_models/metadata/age-rating'; -import { AgeRestriction } from '../_models/metadata/age-restriction'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {Preferences} from '../_models/preferences/preferences'; +import {User} from '../_models/user'; +import {Router} from '@angular/router'; +import {EVENTS, MessageHubService} from './message-hub.service'; +import {ThemeService} from './theme.service'; +import {InviteUserResponse} from '../_models/auth/invite-user-response'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {AgeRating} from '../_models/metadata/age-rating'; +import {AgeRestriction} from '../_models/metadata/age-restriction'; +import {TextResonse} from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {Action} from "./action-factory.service"; +import {LicenseService} from "./license.service"; +import {LocalizationService} from "./localization.service"; export enum Role { Admin = 'Admin', @@ -21,15 +24,30 @@ export enum Role { Bookmark = 'Bookmark', Download = 'Download', ChangeRestriction = 'Change Restriction', - ReadOnly = 'Read Only' + ReadOnly = 'Read Only', + Login = 'Login', + Promote = 'Promote', } +export const allRoles = [ + Role.Admin, + Role.ChangePassword, + Role.Bookmark, + Role.Download, + Role.ChangeRestriction, + Role.ReadOnly, + Role.Login, + Role.Promote, +] + @Injectable({ providedIn: 'root' }) export class AccountService { private readonly destroyRef = inject(DestroyRef); + private readonly licenseService = inject(LicenseService); + private readonly localizationService = inject(LocalizationService); baseUrl = environment.apiUrl; userKey = 'kavita-user'; @@ -39,19 +57,21 @@ export class AccountService { // Stores values, when someone subscribes gives (1) of last values seen. private currentUserSource = new ReplaySubject(1); - public currentUser$ = this.currentUserSource.asObservable(); + public currentUser$ = this.currentUserSource.asObservable().pipe(takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true})); + public isAdmin$: Observable = this.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => { + if (!u) return false; + return this.hasAdminRole(u); + }), shareReplay({bufferSize: 1, refCount: true})); + - private hasValidLicenseSource = new ReplaySubject(1); - /** - * Does the user have an active license - */ - public hasValidLicense$ = this.hasValidLicenseSource.asObservable(); /** * SetTimeout handler for keeping track of refresh token call */ private refreshTokenTimeout: ReturnType | undefined; + private isOnline: boolean = true; + constructor(private httpClient: HttpClient, private router: Router, private messageHub: MessageHubService, private themeService: ThemeService) { messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), @@ -59,6 +79,46 @@ export class AccountService { filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), switchMap(() => this.refreshAccount())) .subscribe(() => {}); + + window.addEventListener("offline", (e) => { + this.isOnline = false; + }); + + window.addEventListener("online", (e) => { + this.isOnline = true; + this.refreshToken().subscribe(); + }); + } + + canInvokeAction(user: User, action: Action) { + const isAdmin = this.hasAdminRole(user); + const canDownload = this.hasDownloadRole(user); + const canPromote = this.hasPromoteRole(user); + + if (isAdmin) return true; + if (action === Action.Download) return canDownload; + if (action === Action.Promote || action === Action.UnPromote) return canPromote; + if (action === Action.Delete) return isAdmin; + return true; + } + + hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { + if (!user || !user.roles) { + return false; + } + + // If restricted roles are provided and the user has any of them, deny access + if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { + return false; + } + + // If roles are empty, allow access (no restrictions by roles) + if (roles.length === 0) { + return true; + } + + // Allow access if the user has any of the allowed roles + return roles.some(role => user.roles.includes(role)); } hasAdminRole(user: User) { @@ -70,7 +130,7 @@ export class AccountService { } hasChangeAgeRestrictionRole(user: User) { - return user && user.roles.includes(Role.ChangeRestriction); + return user && !user.roles.includes(Role.Admin) && user.roles.includes(Role.ChangeRestriction); } hasDownloadRole(user: User) { @@ -85,47 +145,19 @@ export class AccountService { return user && user.roles.includes(Role.ReadOnly); } + hasPromoteRole(user: User) { + return user && user.roles.includes(Role.Promote) || user.roles.includes(Role.Admin); + } + getRoles() { return this.httpClient.get(this.baseUrl + 'account/roles'); } - deleteLicense() { - return this.httpClient.delete(this.baseUrl + 'license', TextResonse); - } - resetLicense(license: string, email: string) { - return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); - } - - hasValidLicense(forceCheck: boolean = false) { - return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) - .pipe( - map(res => res === "true"), - tap(res => { - this.hasValidLicenseSource.next(res) - }), - catchError(error => { - this.hasValidLicenseSource.next(false); - return throwError(error); // Rethrow the error to propagate it further - }) - ); - } - - hasAnyLicense() { - return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) - .pipe( - map(res => res === "true"), - ); - } - - updateUserLicense(license: string, email: string, discordId?: string) { - return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) - .pipe(map(res => res === "true")); - } login(model: {username: string, password: string, apiKey?: string}) { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( - map((response: User) => { + tap((response: User) => { const user = response; if (user) { this.setCurrentUser(user); @@ -135,7 +167,9 @@ export class AccountService { ); } - setCurrentUser(user?: User) { + setCurrentUser(user?: User, refreshConnections = true) { + + const isSameUser = this.currentUser === user; if (user) { user.roles = []; const roles = this.getDecodedToken(user.token).role; @@ -143,6 +177,7 @@ export class AccountService { localStorage.setItem(this.userKey, JSON.stringify(user)); localStorage.setItem(AccountService.lastLoginKey, user.username); + if (user.preferences && user.preferences.theme) { this.themeService.setTheme(user.preferences.theme.name); } else { @@ -155,12 +190,18 @@ export class AccountService { this.currentUser = user; this.currentUserSource.next(user); + if (!refreshConnections) return; + this.stopRefreshTokenTimer(); if (this.currentUser) { - this.messageHub.stopHubConnection(); - this.messageHub.createHubConnection(this.currentUser); - this.hasValidLicense().subscribe(); + // BUG: StopHubConnection has a promise in it, this needs to be async + // But that really messes everything up + if (!isSameUser) { + this.messageHub.stopHubConnection(); + this.messageHub.createHubConnection(this.currentUser); + this.licenseService.hasValidLicense().subscribe(); + } this.startRefreshTokenTimer(); } } @@ -275,10 +316,12 @@ export class AccountService { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { if (this.currentUser !== undefined && this.currentUser !== null) { this.currentUser.preferences = settings; - this.setCurrentUser(this.currentUser); + this.setCurrentUser(this.currentUser, false); // Update the locale on disk (for logout and compact-number pipe) localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); + this.localizationService.refreshTranslations(this.currentUser.preferences.locale); + } return settings; }), takeUntilDestroyed(this.destroyRef)); @@ -329,7 +372,7 @@ export class AccountService { private refreshToken() { - if (this.currentUser === null || this.currentUser === undefined) return of(); + if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of(); return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { if (this.currentUser) { diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 645fd250c..6d2f7053e 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -1,15 +1,18 @@ -import { Injectable } from '@angular/core'; -import { map, Observable, shareReplay } from 'rxjs'; -import { Chapter } from '../_models/chapter'; -import { CollectionTag } from '../_models/collection-tag'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { AccountService } from './account.service'; -import { DeviceService } from './device.service'; +import {Injectable} from '@angular/core'; +import {map, Observable, shareReplay} from 'rxjs'; +import {Chapter} from '../_models/chapter'; +import {UserCollection} from '../_models/collection-tag'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {AccountService} from './account.service'; +import {DeviceService} from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; +import {SmartFilter} from "../_models/metadata/v2/smart-filter"; +import {translate} from "@jsverse/transloco"; +import {Person} from "../_models/metadata/person"; export enum Action { Submenu = -1, @@ -97,12 +100,36 @@ export enum Action { RemoveRuleGroup = 21, MarkAsVisible = 22, MarkAsInvisible = 23, + /** + * Promotes the underlying item (Reading List, Collection) + */ + Promote = 24, + UnPromote = 25, + /** + * Invoke a refresh covers as false to generate colorscapes + */ + GenerateColorScape = 26, + /** + * Copy settings from one entity to another + */ + CopySettings = 27, + /** + * Match an entity with an upstream system + */ + Match = 28 } +/** + * Callback for an action + */ +export type ActionCallback = (action: ActionItem, data: T) => void; +export type ActionAllowedCallback = (action: ActionItem) => boolean; + export interface ActionItem { title: string; + description: string; action: Action; - callback: (action: ActionItem, data: T) => void; + callback: ActionCallback; requiresAdmin: boolean; children: Array>; /** @@ -132,22 +159,26 @@ export class ActionFactoryService { chapterActions: Array> = []; - collectionTagActions: Array> = []; + collectionTagActions: Array> = []; readingListActions: Array> = []; bookmarkActions: Array> = []; + private personActions: Array> = []; + sideNavStreamActions: Array> = []; + smartFilterActions: Array> = []; + + sideNavHomeActions: Array> = []; isAdmin = false; - hasDownloadRole = false; + constructor(private accountService: AccountService, private deviceService: DeviceService) { this.accountService.currentUser$.subscribe((user) => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); - this.hasDownloadRole = this.accountService.hasDownloadRole(user); } else { this._resetActions(); return; // If user is logged out, we don't need to do anything @@ -157,45 +188,48 @@ export class ActionFactoryService { }); } - getLibraryActions(callback: (action: ActionItem, library: Library) => void) { - return this.applyCallbackToList(this.libraryActions, callback); + getLibraryActions(callback: ActionCallback) { + return this.applyCallbackToList(this.libraryActions, callback); } - getSeriesActions(callback: (action: ActionItem, series: Series) => void) { - return this.applyCallbackToList(this.seriesActions, callback); + getSeriesActions(callback: ActionCallback) { + return this.applyCallbackToList(this.seriesActions, callback); } - getSideNavStreamActions(callback: (action: ActionItem, series: SideNavStream) => void) { + getSideNavStreamActions(callback: ActionCallback) { return this.applyCallbackToList(this.sideNavStreamActions, callback); } - getVolumeActions(callback: (action: ActionItem, volume: Volume) => void) { - return this.applyCallbackToList(this.volumeActions, callback); + getSmartFilterActions(callback: ActionCallback) { + return this.applyCallbackToList(this.smartFilterActions, callback); } - getChapterActions(callback: (action: ActionItem, chapter: Chapter) => void) { + getVolumeActions(callback: ActionCallback) { + return this.applyCallbackToList(this.volumeActions, callback); + } + + getChapterActions(callback: ActionCallback) { return this.applyCallbackToList(this.chapterActions, callback); } - getCollectionTagActions(callback: (action: ActionItem, collectionTag: CollectionTag) => void) { - return this.applyCallbackToList(this.collectionTagActions, callback); + getCollectionTagActions(callback: ActionCallback) { + return this.applyCallbackToList(this.collectionTagActions, callback); } - getReadingListActions(callback: (action: ActionItem, readingList: ReadingList) => void) { + getReadingListActions(callback: ActionCallback) { return this.applyCallbackToList(this.readingListActions, callback); } - getBookmarkActions(callback: (action: ActionItem, series: Series) => void) { + getBookmarkActions(callback: ActionCallback) { return this.applyCallbackToList(this.bookmarkActions, callback); } - getMetadataFilterActions(callback: (action: ActionItem, data: any) => void) { - const actions = [ - {title: 'add-rule-group-and', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, - {title: 'add-rule-group-or', action: Action.AddRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, - {title: 'remove-rule-group', action: Action.RemoveRuleGroup, requiresAdmin: false, children: [], callback: this.dummyCallback}, - ]; - return this.applyCallbackToList(actions, callback); + getPersonActions(callback: ActionCallback) { + return this.applyCallbackToList(this.personActions, callback); + } + + getSideNavHomeActions(callback: ActionCallback) { + return this.applyCallbackToList(this.sideNavHomeActions, callback); } dummyCallback(action: ActionItem, data: any) {} @@ -208,11 +242,82 @@ export class ActionFactoryService { return actions; } + getActionablesForSettingsPage(actions: Array>, blacklist: Array = []) { + const tasks = []; + + let actionItem; + for (let parent of actions) { + if (parent.action === Action.SendTo) continue; + + if (parent.children.length === 0) { + actionItem = {...parent}; + actionItem.title = translate('actionable.' + actionItem.title); + if (actionItem.description !== '') { + actionItem.description = translate('actionable.' + actionItem.description); + } + + tasks.push(actionItem); + continue; + } + + for (let child of parent.children) { + if (child.action === Action.SendTo) continue; + actionItem = {...child}; + actionItem.title = translate('actionable.' + actionItem.title); + if (actionItem.description !== '') { + actionItem.description = translate('actionable.' + actionItem.description); + } + tasks.push(actionItem); + } + } + + // Filter out tasks that don't make sense + return tasks.filter(t => !blacklist.includes(t.action)); + } + + getBulkLibraryActions(callback: ActionCallback) { + + // Scan is currently not supported due to the backend not being able to handle it yet + const actions = this.flattenActions(this.libraryActions).filter(a => { + return [Action.Delete, Action.GenerateColorScape, Action.AnalyzeFiles, Action.RefreshMetadata, Action.CopySettings].includes(a.action); + }); + + actions.push({ + _extra: undefined, + class: undefined, + description: '', + dynamicList: undefined, + action: Action.CopySettings, + callback: this.dummyCallback, + children: [], + requiresAdmin: true, + title: 'copy-settings' + }) + return this.applyCallbackToList(actions, callback); + } + + flattenActions(actions: Array>): Array> { + return actions.reduce>>((flatArray, action) => { + if (action.action !== Action.Submenu) { + flatArray.push(action); + } + + // Recursively flatten the children, if any + if (action.children && action.children.length > 0) { + flatArray.push(...this.flattenActions(action.children)); + } + + return flatArray; + }, [] as Array>); // Explicitly defining the type of flatArray + } + + private _resetActions() { this.libraryActions = [ { action: Action.Scan, title: 'scan-library', + description: 'scan-library-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -220,12 +325,22 @@ export class ActionFactoryService { { action: Action.Submenu, title: 'others', + description: '', callback: this.dummyCallback, requiresAdmin: true, children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', + description: 'refresh-covers-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, + { + action: Action.GenerateColorScape, + title: 'generate-colorscape', + description: 'generate-colorscape-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -233,6 +348,7 @@ export class ActionFactoryService { { action: Action.AnalyzeFiles, title: 'analyze-files', + description: 'analyze-files-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -240,6 +356,7 @@ export class ActionFactoryService { { action: Action.Delete, title: 'delete', + description: 'delete-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -249,6 +366,7 @@ export class ActionFactoryService { { action: Action.Edit, title: 'settings', + description: 'settings-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -259,24 +377,43 @@ export class ActionFactoryService { { action: Action.Edit, title: 'edit', + description: 'edit-tooltip', callback: this.dummyCallback, - requiresAdmin: true, + requiresAdmin: false, children: [], }, { action: Action.Delete, title: 'delete', + description: 'delete-tooltip', callback: this.dummyCallback, requiresAdmin: false, class: 'danger', children: [], }, + { + action: Action.Promote, + title: 'promote', + description: 'promote-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.UnPromote, + title: 'unpromote', + description: 'unpromote-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, ]; this.seriesActions = [ { action: Action.MarkAsRead, title: 'mark-as-read', + description: 'mark-as-read-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -284,6 +421,7 @@ export class ActionFactoryService { { action: Action.MarkAsUnread, title: 'mark-as-unread', + description: 'mark-as-unread-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -291,6 +429,7 @@ export class ActionFactoryService { { action: Action.Scan, title: 'scan-series', + description: 'scan-series-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -298,12 +437,14 @@ export class ActionFactoryService { { action: Action.Submenu, title: 'add-to', + description: '', callback: this.dummyCallback, requiresAdmin: false, children: [ - { + { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', + description: 'add-to-want-to-read-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -311,6 +452,7 @@ export class ActionFactoryService { { action: Action.RemoveFromWantToReadList, title: 'remove-from-want-to-read', + description: 'remove-to-want-to-read-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -318,6 +460,7 @@ export class ActionFactoryService { { action: Action.AddToReadingList, title: 'add-to-reading-list', + description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -325,21 +468,41 @@ export class ActionFactoryService { { action: Action.AddToCollection, title: 'add-to-collection', + description: 'add-to-collection-tooltip', callback: this.dummyCallback, - requiresAdmin: true, + requiresAdmin: false, children: [], }, + + // { + // action: Action.AddToScrobbleHold, + // title: 'add-to-scrobble-hold', + // description: 'add-to-scrobble-hold-tooltip', + // callback: this.dummyCallback, + // requiresAdmin: true, + // children: [], + // }, + // { + // action: Action.RemoveFromScrobbleHold, + // title: 'remove-from-scrobble-hold', + // description: 'remove-from-scrobble-hold-tooltip', + // callback: this.dummyCallback, + // requiresAdmin: true, + // children: [], + // }, ], }, { action: Action.Submenu, title: 'send-to', + description: 'send-to-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [ { action: Action.SendTo, title: '', + description: '', callback: this.dummyCallback, requiresAdmin: false, dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { @@ -352,12 +515,22 @@ export class ActionFactoryService { { action: Action.Submenu, title: 'others', + description: '', callback: this.dummyCallback, requiresAdmin: true, children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', + description: 'refresh-covers-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, + { + action: Action.GenerateColorScape, + title: 'generate-colorscape', + description: 'generate-colorscape-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -365,6 +538,7 @@ export class ActionFactoryService { { action: Action.AnalyzeFiles, title: 'analyze-files', + description: 'analyze-files-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -372,6 +546,7 @@ export class ActionFactoryService { { action: Action.Delete, title: 'delete', + description: 'delete-tooltip', callback: this.dummyCallback, requiresAdmin: true, class: 'danger', @@ -379,9 +554,18 @@ export class ActionFactoryService { }, ], }, + { + action: Action.Match, + title: 'match', + description: 'match-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, { action: Action.Download, title: 'download', + description: 'download-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -389,6 +573,7 @@ export class ActionFactoryService { { action: Action.Edit, title: 'edit', + description: 'edit-tooltip', callback: this.dummyCallback, requiresAdmin: true, children: [], @@ -399,6 +584,7 @@ export class ActionFactoryService { { action: Action.IncognitoRead, title: 'read-incognito', + description: 'read-incognito-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -406,6 +592,7 @@ export class ActionFactoryService { { action: Action.MarkAsRead, title: 'mark-as-read', + description: 'mark-as-read-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -413,34 +600,39 @@ export class ActionFactoryService { { action: Action.MarkAsUnread, title: 'mark-as-unread', + description: 'mark-as-unread-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], }, - { - action: Action.Submenu, - title: 'add-to', - callback: this.dummyCallback, - requiresAdmin: false, - children: [ - { - action: Action.AddToReadingList, - title: 'add-to-reading-list', - callback: this.dummyCallback, - requiresAdmin: false, - children: [], - } - ] - }, + { + action: Action.Submenu, + title: 'add-to', + description: '=', + callback: this.dummyCallback, + requiresAdmin: false, + children: [ + { + action: Action.AddToReadingList, + title: 'add-to-reading-list', + description: 'add-to-reading-list-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + } + ] + }, { action: Action.Submenu, title: 'send-to', + description: 'send-to-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [ { action: Action.SendTo, title: '', + description: '', callback: this.dummyCallback, requiresAdmin: false, dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { @@ -451,15 +643,34 @@ export class ActionFactoryService { ], }, { - action: Action.Download, - title: 'download', + action: Action.Submenu, + title: 'others', + description: '', callback: this.dummyCallback, requiresAdmin: false, - children: [], + children: [ + { + action: Action.Delete, + title: 'delete', + description: 'delete-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, + { + action: Action.Download, + title: 'download', + description: 'download-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ] }, { action: Action.Edit, title: 'details', + description: 'edit-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -470,6 +681,7 @@ export class ActionFactoryService { { action: Action.IncognitoRead, title: 'read-incognito', + description: 'read-incognito-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -477,6 +689,7 @@ export class ActionFactoryService { { action: Action.MarkAsRead, title: 'mark-as-read', + description: 'mark-as-read-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -484,34 +697,39 @@ export class ActionFactoryService { { action: Action.MarkAsUnread, title: 'mark-as-unread', + description: 'mark-as-unread-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], }, - { - action: Action.Submenu, - title: 'add-to', - callback: this.dummyCallback, - requiresAdmin: false, - children: [ - { - action: Action.AddToReadingList, - title: 'add-to-reading-list', - callback: this.dummyCallback, - requiresAdmin: false, - children: [], - } - ] - }, + { + action: Action.Submenu, + title: 'add-to', + description: '', + callback: this.dummyCallback, + requiresAdmin: false, + children: [ + { + action: Action.AddToReadingList, + title: 'add-to-reading-list', + description: 'add-to-reading-list-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + } + ] + }, { action: Action.Submenu, title: 'send-to', + description: 'send-to-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [ { action: Action.SendTo, title: '', + description: '', callback: this.dummyCallback, requiresAdmin: false, dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { @@ -521,17 +739,36 @@ export class ActionFactoryService { } ], }, - // RBS will handle rendering this, so non-admins with download are appicable + // RBS will handle rendering this, so non-admins with download are applicable { - action: Action.Download, - title: 'download', + action: Action.Submenu, + title: 'others', + description: '', callback: this.dummyCallback, requiresAdmin: false, - children: [], + children: [ + { + action: Action.Delete, + title: 'delete', + description: 'delete-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + }, + { + action: Action.Download, + title: 'download', + description: 'download-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ] }, { action: Action.Edit, - title: 'details', + title: 'edit', + description: 'edit-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -542,6 +779,7 @@ export class ActionFactoryService { { action: Action.Edit, title: 'edit', + description: 'edit-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -549,17 +787,46 @@ export class ActionFactoryService { { action: Action.Delete, title: 'delete', + description: 'delete-tooltip', callback: this.dummyCallback, requiresAdmin: false, class: 'danger', children: [], }, + { + action: Action.Promote, + title: 'promote', + description: 'promote-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.UnPromote, + title: 'unpromote', + description: 'unpromote-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ]; + + this.personActions = [ + { + action: Action.Edit, + title: 'edit', + description: 'edit-person-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + } ]; this.bookmarkActions = [ { action: Action.ViewSeries, title: 'view-series', + description: 'view-series-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -567,6 +834,7 @@ export class ActionFactoryService { { action: Action.DownloadBookmark, title: 'download', + description: 'download-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -574,6 +842,7 @@ export class ActionFactoryService { { action: Action.Delete, title: 'clear', + description: 'delete-tooltip', callback: this.dummyCallback, class: 'danger', requiresAdmin: false, @@ -585,6 +854,7 @@ export class ActionFactoryService { { action: Action.MarkAsVisible, title: 'mark-visible', + description: 'mark-visible-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], @@ -592,11 +862,44 @@ export class ActionFactoryService { { action: Action.MarkAsInvisible, title: 'mark-invisible', + description: 'mark-invisible-tooltip', callback: this.dummyCallback, requiresAdmin: false, children: [], }, ]; + + this.smartFilterActions = [ + { + action: Action.Edit, + title: 'rename', + description: 'rename-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + { + action: Action.Delete, + title: 'delete', + description: 'delete-tooltip', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, + ]; + + this.sideNavHomeActions = [ + { + action: Action.Edit, + title: 'reorder', + description: '', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + } + ] + + } private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { @@ -609,13 +912,13 @@ export class ActionFactoryService { }); } - public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { - const actions = list.map((a) => { - return { ...a }; - }); - actions.forEach((action) => this.applyCallback(action, callback)); - return actions; - } + public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { + const actions = list.map((a) => { + return { ...a }; + }); + actions.forEach((action) => this.applyCallback(action, callback)); + return actions; + } // Checks the whole tree for the action and returns true if it exists public hasAction(actions: Array>, action: Action) { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 6e0e6448e..1cf4e448e 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -1,25 +1,37 @@ -import { Injectable, OnDestroy } from '@angular/core'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { ToastrService } from 'ngx-toastr'; -import { Subject } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; -import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; -import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; -import { ConfirmService } from '../shared/confirm.service'; -import { LibrarySettingsModalComponent } from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; -import { Chapter } from '../_models/chapter'; -import { Device } from '../_models/device/device'; -import { Library } from '../_models/library/library'; -import { ReadingList } from '../_models/reading-list'; -import { Series } from '../_models/series'; -import { Volume } from '../_models/volume'; -import { DeviceService } from './device.service'; -import { LibraryService } from './library.service'; -import { MemberService } from './member.service'; -import { ReaderService } from './reader.service'; -import { SeriesService } from './series.service'; -import {translate, TranslocoService} from "@ngneat/transloco"; +import {inject, Injectable} from '@angular/core'; +import {NgbModal, NgbModalRef} from '@ng-bootstrap/ng-bootstrap'; +import {ToastrService} from 'ngx-toastr'; +import {take} from 'rxjs/operators'; +import {BulkAddToCollectionComponent} from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component'; +import {ADD_FLOW, AddToListModalComponent} from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; +import { + EditReadingListModalComponent +} from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; +import {ConfirmService} from '../shared/confirm.service'; +import { + LibrarySettingsModalComponent +} from '../sidenav/_modals/library-settings-modal/library-settings-modal.component'; +import {Chapter} from '../_models/chapter'; +import {Device} from '../_models/device/device'; +import {Library} from '../_models/library/library'; +import {ReadingList} from '../_models/reading-list'; +import {Series} from '../_models/series'; +import {Volume} from '../_models/volume'; +import {DeviceService} from './device.service'; +import {LibraryService} from './library.service'; +import {MemberService} from './member.service'; +import {ReaderService} from './reader.service'; +import {SeriesService} from './series.service'; +import {translate} from "@jsverse/transloco"; +import {UserCollection} from "../_models/collection-tag"; +import {CollectionTagService} from "./collection-tag.service"; +import {FilterService} from "./filter.service"; +import {ReadingListService} from "./reading-list.service"; +import {ChapterService} from "./chapter.service"; +import {VolumeService} from "./volume.service"; +import {DefaultModalOptions} from "../_models/default-modal-options"; +import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component"; + export type LibraryActionCallback = (library: Partial) => void; export type SeriesActionCallback = (series: Series) => void; @@ -35,20 +47,26 @@ export type BooleanActionCallback = (result: boolean) => void; @Injectable({ providedIn: 'root' }) -export class ActionService implements OnDestroy { +export class ActionService { + + private readonly chapterService = inject(ChapterService); + private readonly volumeService = inject(VolumeService); + private readonly libraryService = inject(LibraryService); + private readonly seriesService = inject(SeriesService); + private readonly readerService = inject(ReaderService); + private readonly toastr = inject(ToastrService); + private readonly modalService = inject(NgbModal); + private readonly confirmService = inject(ConfirmService); + private readonly memberService = inject(MemberService); + private readonly deviceService = inject(DeviceService); + private readonly collectionTagService = inject(CollectionTagService); + private readonly filterService = inject(FilterService); + private readonly readingListService = inject(ReadingListService); + - private readonly onDestroy = new Subject(); private readingListModalRef: NgbModalRef | null = null; private collectionModalRef: NgbModalRef | null = null; - constructor(private libraryService: LibraryService, private seriesService: SeriesService, - private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, - private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService) { } - - ngOnDestroy() { - this.onDestroy.next(); - this.onDestroy.complete(); - } /** * Request a file scan for a given Library @@ -77,24 +95,30 @@ export class ActionService implements OnDestroy { * Request a refresh of Metadata for a given Library * @param library Partial Library, must have id and name populated * @param callback Optional callback to perform actions after API completes + * @param forceUpdate Optional Should we force + * @param forceColorscape Optional Should we force colorscape gen * @returns */ - async refreshMetadata(library: Partial, callback?: LibraryActionCallback) { + async refreshLibraryMetadata(library: Partial, callback?: LibraryActionCallback, forceUpdate: boolean = true, forceColorscape: boolean = false) { if (!library.hasOwnProperty('id') || library.id === undefined) { return; } - if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { - if (callback) { - callback(library); + // Prompt the user if we are doing a forced call + if (forceUpdate) { + if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { + if (callback) { + callback(library); + } + return; } - return; } - const forceUpdate = true; //await this.promptIfForce(); + const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued'; + + this.libraryService.refreshMetadata(library?.id, forceUpdate, forceColorscape).subscribe((res: any) => { + this.toastr.info(translate(message, {name: library.name})); - this.libraryService.refreshMetadata(library?.id, forceUpdate).pipe(take(1)).subscribe((res: any) => { - this.toastr.info(translate('toasts.scan-queued', {name: library.name})); if (callback) { callback(library); } @@ -102,7 +126,7 @@ export class ActionService implements OnDestroy { } editLibrary(library: Partial, callback?: LibraryActionCallback) { - const modalRef = this.modalService.open(LibrarySettingsModalComponent, {size: 'xl', fullscreen: 'md'}); + const modalRef = this.modalService.open(LibrarySettingsModalComponent, DefaultModalOptions); modalRef.componentInstance.library = library; modalRef.closed.subscribe((closeResult: {success: boolean, library: Library, coverImageUpdate: boolean}) => { if (callback) callback(library) @@ -217,17 +241,25 @@ export class ActionService implements OnDestroy { * Start a metadata refresh for a Series * @param series Series, must have libraryId, id and name populated * @param callback Optional callback to perform actions after API completes + * @param forceUpdate If cache should be checked or not + * @param forceColorscape If cache should be checked or not */ - async refreshMetdata(series: Series, callback?: SeriesActionCallback) { - if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { - if (callback) { - callback(series); + async refreshSeriesMetadata(series: Series, callback?: SeriesActionCallback, forceUpdate: boolean = true, forceColorscape: boolean = false) { + + // Prompt the user if we are doing a forced call + if (forceUpdate) { + if (!await this.confirmService.confirm(translate('toasts.confirm-regen-covers'))) { + if (callback) { + callback(series); + } + return; } - return; } - this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => { - this.toastr.info(translate('toasts.refresh-covers-queued', {name: series.name})); + const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued'; + + this.seriesService.refreshMetadata(series, forceUpdate, forceColorscape).pipe(take(1)).subscribe((res: any) => { + this.toastr.info(translate(message, {name: series.name})); if (callback) { callback(series); } @@ -271,6 +303,7 @@ export class ActionService implements OnDestroy { /** * Mark a chapter as read + * @param libraryId Library Id * @param seriesId Series Id * @param chapter Chapter, should have id, pages, volumeId populated * @param callback Optional callback to perform actions after API completes @@ -287,6 +320,7 @@ export class ActionService implements OnDestroy { /** * Mark a chapter as unread + * @param libraryId Library Id * @param seriesId Series Id * @param chapter Chapter, should have id, pages, volumeId populated * @param callback Optional callback to perform actions after API completes @@ -305,7 +339,7 @@ export class ActionService implements OnDestroy { * Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated - * @param chapters? Chapters, should have id + * @param chapters Optional Chapters, should have id * @param callback Optional callback to perform actions after API completes */ markMultipleAsRead(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { @@ -327,6 +361,7 @@ export class ActionService implements OnDestroy { * Mark all chapters and the volumes as Unread. All volumes must belong to a series * @param seriesId Series Id * @param volumes Volumes, should have id, chapters and pagesRead populated + * @param chapters Optional Chapters, should have id * @param callback Optional callback to perform actions after API completes */ markMultipleAsUnread(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { @@ -380,13 +415,108 @@ export class ActionService implements OnDestroy { }); } + /** + * Mark all collections as promoted/unpromoted. + * @param collections UserCollection, should have id, pagesRead populated + * @param promoted boolean, promoted state + * @param callback Optional callback to perform actions after API completes + */ + promoteMultipleCollections(collections: Array, promoted: boolean, callback?: BooleanActionCallback) { + this.collectionTagService.promoteMultipleCollections(collections.map(v => v.id), promoted).pipe(take(1)).subscribe(() => { + if (promoted) { + this.toastr.success(translate('toasts.collections-promoted')); + } else { + this.toastr.success(translate('toasts.collections-unpromoted')); + } + + if (callback) { + callback(true); + } + }); + } + + /** + * Deletes multiple collections + * @param collections UserCollection, should have id, pagesRead populated + * @param callback Optional callback to perform actions after API completes + */ + async deleteMultipleCollections(collections: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-collections'))) return; + + this.collectionTagService.deleteMultipleCollections(collections.map(v => v.id)).pipe(take(1)).subscribe(() => { + this.toastr.success(translate('toasts.collections-deleted')); + + if (callback) { + callback(true); + } + }); + } + + /** + * Mark all reading lists as promoted/unpromoted. + * @param readingLists UserCollection, should have id, pagesRead populated + * @param promoted boolean, promoted state + * @param callback Optional callback to perform actions after API completes + */ + promoteMultipleReadingLists(readingLists: Array, promoted: boolean, callback?: BooleanActionCallback) { + this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).pipe(take(1)).subscribe(() => { + if (promoted) { + this.toastr.success(translate('toasts.reading-list-promoted')); + } else { + this.toastr.success(translate('toasts.reading-list-unpromoted')); + } + + if (callback) { + callback(true); + } + }); + } + + async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { + // TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes" + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return; + + this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { + if (callback) { + callback(success); + } + }) + } + + async deleteMultipleChapters(seriesId: number, chapterIds: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: chapterIds.length}))) return; + + this.chapterService.deleteMultipleChapters(seriesId, chapterIds.map(c => c.id)).subscribe(() => { + if (callback) { + callback(true); + } + }); + } + + /** + * Deletes multiple collections + * @param readingLists ReadingList, should have id + * @param callback Optional callback to perform actions after API completes + */ + async deleteMultipleReadingLists(readingLists: Array, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return; + + this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).pipe(take(1)).subscribe(() => { + this.toastr.success(translate('toasts.reading-lists-deleted')); + + if (callback) { + callback(true); + } + }); + } + addMultipleToReadingList(seriesId: number, volumes: Array, chapters?: Array, callback?: BooleanActionCallback) { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); this.readingListModalRef.componentInstance.seriesId = seriesId; this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id); this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id); - this.readingListModalRef.componentInstance.title = 'Multiple Selections'; + this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple; @@ -426,7 +556,7 @@ export class ActionService implements OnDestroy { if (this.readingListModalRef != null) { return; } this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' }); this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.readingListModalRef.componentInstance.title = 'Multiple Selections'; + this.readingListModalRef.componentInstance.title = translate('actionable.multiple-selections'); this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series; @@ -454,7 +584,7 @@ export class ActionService implements OnDestroy { if (this.collectionModalRef != null) { return; } this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' }); this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id); - this.collectionModalRef.componentInstance.title = 'New Collection'; + this.collectionModalRef.componentInstance.title = translate('actionable.new-collection'); this.collectionModalRef.closed.pipe(take(1)).subscribe(() => { this.collectionModalRef = null; @@ -537,7 +667,7 @@ export class ActionService implements OnDestroy { } editReadingList(readingList: ReadingList, callback?: ReadingListActionCallback) { - const readingListModalRef = this.modalService.open(EditReadingListModalComponent, { scrollable: true, size: 'lg', fullscreen: 'md' }); + const readingListModalRef = this.modalService.open(EditReadingListModalComponent, DefaultModalOptions); readingListModalRef.componentInstance.readingList = readingList; readingListModalRef.closed.pipe(take(1)).subscribe((list) => { if (callback && list !== undefined) { @@ -597,6 +727,48 @@ export class ActionService implements OnDestroy { }); } + async deleteChapter(chapterId: number, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))) { + if (callback) { + callback(false); + } + return; + } + + this.chapterService.deleteChapter(chapterId).subscribe((res: boolean) => { + if (callback) { + if (res) { + this.toastr.success(translate('toasts.chapter-deleted')); + } else { + this.toastr.error(translate('errors.generic')); + } + + callback(res); + } + }); + } + + async deleteVolume(volumeId: number, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-volume'))) { + if (callback) { + callback(false); + } + return; + } + + this.volumeService.deleteVolume(volumeId).subscribe((res: boolean) => { + if (callback) { + if (res) { + this.toastr.success(translate('toasts.volume-deleted')); + } else { + this.toastr.error(translate('errors.generic')); + } + + callback(res); + } + }); + } + sendToDevice(chapterIds: Array, device: Device, callback?: VoidActionCallback) { this.deviceService.sendTo(chapterIds, device.id).subscribe(() => { this.toastr.success(translate('toasts.file-send-to', {name: device.name})); @@ -615,4 +787,31 @@ export class ActionService implements OnDestroy { }); } + matchSeries(series: Series, callback?: BooleanActionCallback) { + const ref = this.modalService.open(MatchSeriesModalComponent, DefaultModalOptions); + ref.componentInstance.series = series; + ref.closed.subscribe(saved => { + if (callback) { + callback(saved); + } + }); + } + + async deleteFilter(filterId: number, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-smart-filter'))) { + if (callback) { + callback(false); + } + return; + } + + this.filterService.deleteFilter(filterId).subscribe(_ => { + this.toastr.success(translate('toasts.smart-filter-deleted')); + + if (callback) { + callback(true); + } + }); + } + } diff --git a/UI/Web/src/app/_services/chapter.service.ts b/UI/Web/src/app/_services/chapter.service.ts new file mode 100644 index 000000000..c722031bd --- /dev/null +++ b/UI/Web/src/app/_services/chapter.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import { HttpClient } from "@angular/common/http"; +import {Chapter} from "../_models/chapter"; +import {TextResonse} from "../_types/text-response"; + +@Injectable({ + providedIn: 'root' +}) +export class ChapterService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getChapterMetadata(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'chapter?chapterId=' + chapterId); + } + + deleteChapter(chapterId: number) { + return this.httpClient.delete(this.baseUrl + 'chapter?chapterId=' + chapterId); + } + + deleteMultipleChapters(seriesId: number, chapterIds: Array) { + return this.httpClient.post(this.baseUrl + `chapter/delete-multiple?seriesId=${seriesId}`, {chapterIds}); + } + + updateChapter(chapter: Chapter) { + return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse); + } + +} diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index 3e4b8b508..df668f13a 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -1,10 +1,12 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { CollectionTag } from '../_models/collection-tag'; -import { TextResonse } from '../_types/text-response'; -import { ImageService } from './image.service'; +import {Injectable} from '@angular/core'; +import {environment} from 'src/environments/environment'; +import {UserCollection} from '../_models/collection-tag'; +import {TextResonse} from '../_types/text-response'; +import {MalStack} from "../_models/collection/mal-stack"; +import {Action, ActionItem} from "./action-factory.service"; +import {User} from "../_models/user"; +import {AccountService} from "./account.service"; @Injectable({ providedIn: 'root' @@ -13,24 +15,25 @@ export class CollectionTagService { baseUrl = environment.apiUrl; - constructor(private httpClient: HttpClient, private imageService: ImageService) { } + constructor(private httpClient: HttpClient, private accountService: AccountService) { } - allTags() { - return this.httpClient.get(this.baseUrl + 'collection/'); + allCollections(ownedOnly = false) { + return this.httpClient.get(this.baseUrl + 'collection?ownedOnly=' + ownedOnly); } - search(query: string) { - return this.httpClient.get(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => { - tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id))); - return tags; - })); + allCollectionsForSeries(seriesId: number, ownedOnly = false) { + return this.httpClient.get(this.baseUrl + 'collection/all-series?ownedOnly=' + ownedOnly + '&seriesId=' + seriesId); } - updateTag(tag: CollectionTag) { + updateTag(tag: UserCollection) { return this.httpClient.post(this.baseUrl + 'collection/update', tag, TextResonse); } - updateSeriesForTag(tag: CollectionTag, seriesIdsToRemove: Array) { + promoteMultipleCollections(tags: Array, promoted: boolean) { + return this.httpClient.post(this.baseUrl + 'collection/promote-multiple', {collectionIds: tags, promoted}, TextResonse); + } + + updateSeriesForTag(tag: UserCollection, seriesIdsToRemove: Array) { return this.httpClient.post(this.baseUrl + 'collection/update-series', {tag, seriesIdsToRemove}, TextResonse); } @@ -45,4 +48,24 @@ export class CollectionTagService { deleteTag(tagId: number) { return this.httpClient.delete(this.baseUrl + 'collection?tagId=' + tagId, TextResonse); } + + deleteMultipleCollections(tags: Array) { + return this.httpClient.post(this.baseUrl + 'collection/delete-multiple', {collectionIds: tags}, TextResonse); + } + + getMalStacks() { + return this.httpClient.get>(this.baseUrl + 'collection/mal-stacks'); + } + + actionListFilter(action: ActionItem, user: User) { + const canPromote = this.accountService.hasAdminRole(user) || this.accountService.hasPromoteRole(user); + const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote; + + if (isPromotionAction) return canPromote; + return true; + } + + importStack(stack: MalStack) { + return this.httpClient.post(this.baseUrl + 'collection/import-stack', stack, TextResonse); + } } diff --git a/UI/Web/src/app/_services/colorscape.service.ts b/UI/Web/src/app/_services/colorscape.service.ts new file mode 100644 index 000000000..88cbd7460 --- /dev/null +++ b/UI/Web/src/app/_services/colorscape.service.ts @@ -0,0 +1,511 @@ +import { Injectable, Inject } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import {BehaviorSubject, filter, take, tap, timer} from 'rxjs'; +import {NavigationEnd, Router} from "@angular/router"; + +interface ColorSpace { + primary: string; + lighter: string; + darker: string; + complementary: string; +} + +interface ColorSpaceRGBA { + primary: RGBAColor; + lighter: RGBAColor; + darker: RGBAColor; + complementary: RGBAColor; +} + +interface RGBAColor {r: number;g: number;b: number;a: number;} +interface RGB { r: number;g: number; b: number; } +interface HSL { h: number; s: number; l: number; } + +const colorScapeSelector = 'colorscape'; + +/** + * ColorScape handles setting the scape and managing the transitions + */ +@Injectable({ + providedIn: 'root' +}) +export class ColorscapeService { + private colorSubject = new BehaviorSubject(null); + private colorSeedSubject = new BehaviorSubject<{primary: string, complementary: string | null} | null>(null); + public readonly colors$ = this.colorSubject.asObservable(); + + private minDuration = 1000; // minimum duration + private maxDuration = 4000; // maximum duration + private defaultColorspaceDuration = 300; // duration to wait before defaulting back to default colorspace + + constructor(@Inject(DOCUMENT) private document: Document, private readonly router: Router) { + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + tap(() => this.checkAndResetColorscapeAfterDelay()) + ).subscribe(); + + } + + /** + * Due to changing ColorScape on route end, we might go from one space to another, but the router events resets to default + * This delays it to see if the colors changed or not in 500ms and if not, then we will reset to default. + * @private + */ + private checkAndResetColorscapeAfterDelay() { + // Capture the current colors at the start of NavigationEnd + const initialColors = this.colorSubject.getValue(); + + // Wait for X ms, then check if colors have changed + timer(this.defaultColorspaceDuration).pipe( + take(1), // Complete after the timer emits + tap(() => { + const currentColors = this.colorSubject.getValue(); + if (initialColors != null && currentColors != null && this.areColorSpacesVisuallyEqual(initialColors, currentColors)) { + this.setColorScape(''); // Reset to default if colors haven't changed + } + }) + ).subscribe(); + } + + /** + * Sets a color scape for the active theme + * @param primaryColor + * @param complementaryColor + */ + setColorScape(primaryColor: string, complementaryColor: string | null = null) { + if (this.getCssVariable('--colorscape-enabled') === 'false') { + return; + } + + const elem = this.document.querySelector('#backgroundCanvas'); + + if (!elem) { + return; + } + + // Check the old seed colors and check if they are similar, then avoid a change. In case you scan a series and this re-generates + const previousColors = this.colorSeedSubject.getValue(); + if (previousColors != null && primaryColor == previousColors.primary) { + this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); + return; + } + this.colorSeedSubject.next({primary: primaryColor, complementary: complementaryColor}); + + // TODO: Check if there is a secondary color and if the color is a strong contrast (opposite on color wheel) to primary + + // If we have a STRONG primary and secondary, generate LEFT/RIGHT orientation + // If we have only one color, randomize the position of the primary + // If we have 2 colors, but their contrast isn't STRONG, then use diagonal for Primary and Secondary + + + + + const newColors: ColorSpace = primaryColor ? + this.generateBackgroundColors(primaryColor, complementaryColor, this.isDarkTheme()) : + this.defaultColors(); + + const newColorsRGBA = this.convertColorsToRGBA(newColors); + const oldColors = this.colorSubject.getValue() || this.convertColorsToRGBA(this.defaultColors()); + const duration = this.calculateTransitionDuration(oldColors, newColorsRGBA); + + // Check if the colors we are transitioning to are visually equal + if (this.areColorSpacesVisuallyEqual(oldColors, newColorsRGBA)) { + return; + } + + this.animateColorTransition(oldColors, newColorsRGBA, duration); + + this.colorSubject.next(newColorsRGBA); + } + + private areColorSpacesVisuallyEqual(color1: ColorSpaceRGBA, color2: ColorSpaceRGBA, threshold: number = 0): boolean { + return this.areRGBAColorsVisuallyEqual(color1.primary, color2.primary, threshold) && + this.areRGBAColorsVisuallyEqual(color1.lighter, color2.lighter, threshold) && + this.areRGBAColorsVisuallyEqual(color1.darker, color2.darker, threshold) && + this.areRGBAColorsVisuallyEqual(color1.complementary, color2.complementary, threshold); + } + + private areRGBAColorsVisuallyEqual(color1: RGBAColor, color2: RGBAColor, threshold: number = 0): boolean { + return Math.abs(color1.r - color2.r) <= threshold && + Math.abs(color1.g - color2.g) <= threshold && + Math.abs(color1.b - color2.b) <= threshold && + Math.abs(color1.a - color2.a) <= threshold / 255; + } + + private convertColorsToRGBA(colors: ColorSpace): ColorSpaceRGBA { + return { + primary: this.parseColorToRGBA(colors.primary), + lighter: this.parseColorToRGBA(colors.lighter), + darker: this.parseColorToRGBA(colors.darker), + complementary: this.parseColorToRGBA(colors.complementary) + }; + } + + private parseColorToRGBA(color: string) { + + if (color.startsWith('#')) { + return this.hexToRGBA(color); + } else if (color.startsWith('rgb')) { + return this.rgbStringToRGBA(color); + } else { + console.warn(`Unsupported color format: ${color}. Defaulting to black.`); + return { r: 0, g: 0, b: 0, a: 1 }; + } + } + + private hexToRGBA(hex: string, opacity: number = 1): RGBAColor { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + a: opacity + } + : { r: 0, g: 0, b: 0, a: opacity }; + } + + private rgbStringToRGBA(rgb: string): RGBAColor { + const matches = rgb.match(/(\d+(\.\d+)?)/g); + if (matches) { + return { + r: parseInt(matches[0], 10), + g: parseInt(matches[1], 10), + b: parseInt(matches[2], 10), + a: matches.length === 4 ? parseFloat(matches[3]) : 1 + }; + } + return { r: 0, g: 0, b: 0, a: 1 }; + } + + private calculateTransitionDuration(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA): number { + const colorKeys: (keyof ColorSpaceRGBA)[] = ['primary', 'lighter', 'darker', 'complementary']; + let totalDistance = 0; + + for (const key of colorKeys) { + const oldRGB = this.rgbaToRgb(oldColors[key]); + const newRGB = this.rgbaToRgb(newColors[key]); + totalDistance += this.calculateColorDistance(oldRGB, newRGB); + } + + // Normalize the total distance and map it to our duration range + const normalizedDistance = Math.min(totalDistance / (255 * 3 * 4), 1); // Max possible distance is 255*3*4 + const duration = this.minDuration + normalizedDistance * (this.maxDuration - this.minDuration); + + // Add random variance to the duration + const durationVariance = this.getRandomInRange(-500, 500); + + return Math.round(duration + durationVariance); + } + + private rgbaToRgb(rgba: RGBAColor): RGB { + return { r: rgba.r, g: rgba.g, b: rgba.b }; + } + + private calculateColorDistance(rgb1: RGB, rgb2: RGB): number { + return Math.sqrt( + Math.pow(rgb2.r - rgb1.r, 2) + + Math.pow(rgb2.g - rgb1.g, 2) + + Math.pow(rgb2.b - rgb1.b, 2) + ); + } + + + private defaultColors() { + return { + primary: this.getCssVariable('--colorscape-primary-default-color'), + lighter: this.getCssVariable('--colorscape-lighter-default-color'), + darker: this.getCssVariable('--colorscape-darker-default-color'), + complementary: this.getCssVariable('--colorscape-complementary-default-color'), + } + } + + private animateColorTransition(oldColors: ColorSpaceRGBA, newColors: ColorSpaceRGBA, duration: number) { + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + const interpolatedColors: ColorSpaceRGBA = { + primary: this.interpolateRGBAColor(oldColors.primary, newColors.primary, progress), + lighter: this.interpolateRGBAColor(oldColors.lighter, newColors.lighter, progress), + darker: this.interpolateRGBAColor(oldColors.darker, newColors.darker, progress), + complementary: this.interpolateRGBAColor(oldColors.complementary, newColors.complementary, progress) + }; + + this.setColorsImmediately(interpolatedColors); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + } + + private easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + + private interpolateRGBAColor(color1: RGBAColor, color2: RGBAColor, progress: number): RGBAColor { + + const easedProgress = this.easeInOutCubic(progress); + + return { + r: Math.round(color1.r + (color2.r - color1.r) * easedProgress), + g: Math.round(color1.g + (color2.g - color1.g) * easedProgress), + b: Math.round(color1.b + (color2.b - color1.b) * easedProgress), + a: color1.a + (color2.a - color1.a) * easedProgress + }; + } + + private setColorsImmediately(colors: ColorSpaceRGBA) { + this.injectStyleElement(colorScapeSelector, ` + :root, :root .default { + --colorscape-primary-color: ${this.rgbToString(colors.primary)}; + --colorscape-lighter-color: ${this.rgbToString(colors.lighter)}; + --colorscape-darker-color: ${this.rgbToString(colors.darker)}; + --colorscape-complementary-color: ${this.rgbToString(colors.complementary)}; + --colorscape-primary-no-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })}; + --colorscape-lighter-no-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })}; + --colorscape-darker-no-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })}; + --colorscape-complementary-no-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })}; + --colorscape-primary-full-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 1 })}; + --colorscape-lighter-full-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 1 })}; + --colorscape-darker-full-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 1 })}; + --colorscape-complementary-full-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 1 })}; + --colorscape-primary-half-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0.5 })}; + --colorscape-lighter-half-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0.5 })}; + --colorscape-darker-half-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0.5 })}; + --colorscape-complementary-half-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0.5 })}; + } + `); + } + + private generateBackgroundColors(primaryColor: string, secondaryColor: string | null = null, isDarkTheme: boolean = true): ColorSpace { + const primary = this.hexToRgb(primaryColor); + const secondary = secondaryColor ? this.hexToRgb(secondaryColor) : this.calculateComplementaryRgb(primary); + + const primaryHSL = this.rgbToHsl(primary); + const secondaryHSL = this.rgbToHsl(secondary); + + return isDarkTheme + ? this.calculateDarkThemeColors(secondaryHSL, primaryHSL, primary) + : this.calculateLightThemeDarkColors(primaryHSL, primary); // NOTE: Light themes look bad in general with this system. + } + + private adjustColorWithVariance(color: string): string { + const rgb = this.hexToRgb(color); + const randomVariance = () => this.getRandomInRange(-10, 10); // Random variance for each color channel + return this.rgbToHex({ + r: Math.min(255, Math.max(0, rgb.r + randomVariance())), + g: Math.min(255, Math.max(0, rgb.g + randomVariance())), + b: Math.min(255, Math.max(0, rgb.b + randomVariance())) + }); + } + + private calculateLightThemeDarkColors(primaryHSL: HSL, primary: RGB) { + const lighterHSL = {...primaryHSL}; + lighterHSL.s = Math.max(lighterHSL.s - 0.3, 0); + lighterHSL.l = Math.min(lighterHSL.l + 0.5, 0.95); + + const darkerHSL = {...primaryHSL}; + darkerHSL.s = Math.max(darkerHSL.s - 0.1, 0); + darkerHSL.l = Math.min(darkerHSL.l + 0.3, 0.9); + + const complementaryHSL = this.adjustHue(primaryHSL, 180); + complementaryHSL.s = Math.max(complementaryHSL.s - 0.2, 0); + complementaryHSL.l = Math.min(complementaryHSL.l + 0.4, 0.9); + + return { + primary: this.rgbToHex(primary), + lighter: this.rgbToHex(this.hslToRgb(lighterHSL)), + darker: this.rgbToHex(this.hslToRgb(darkerHSL)), + complementary: this.rgbToHex(this.hslToRgb(complementaryHSL)) + }; + } + + private calculateDarkThemeColors(secondaryHSL: HSL, primaryHSL: { + h: number; + s: number; + l: number + }, primary: RGB) { + const lighterHSL = this.adjustHue(secondaryHSL, 30); + lighterHSL.s = Math.min(lighterHSL.s + 0.2, 1); + lighterHSL.l = Math.min(lighterHSL.l + 0.1, 0.6); + + const darkerHSL = {...primaryHSL}; + darkerHSL.l = Math.max(darkerHSL.l - 0.3, 0.1); + + const complementaryHSL = this.adjustHue(primaryHSL, 180); + complementaryHSL.s = Math.min(complementaryHSL.s + 0.1, 1); + complementaryHSL.l = Math.max(complementaryHSL.l - 0.2, 0.2); + + // Array of colors to shuffle + const colors = [ + this.rgbToHex(primary), + this.rgbToHex(this.hslToRgb(lighterHSL)), + this.rgbToHex(this.hslToRgb(darkerHSL)), + this.rgbToHex(this.hslToRgb(complementaryHSL)) + ]; + + // Shuffle colors array + this.shuffleArray(colors); + + // Set a brightness threshold (you can adjust this value as needed) + const brightnessThreshold = 100; // Adjust based on your needs (0-255) + + // Ensure the 'lighter' color is not too bright + if (this.getBrightness(colors[1]) > brightnessThreshold) { + // If it is too bright, find a suitable swap + for (let i = 0; i < colors.length; i++) { + if (this.getBrightness(colors[i]) <= brightnessThreshold) { + // Swap colors[1] (lighter) with a less bright color + [colors[1], colors[i]] = [colors[i], colors[1]]; + break; + } + } + } + + // Ensure no color is repeating and variance is maintained + const uniqueColors = new Set(colors); + if (uniqueColors.size < colors.length) { + // If there are duplicates, re-shuffle the array + this.shuffleArray(colors); + } + + return { + primary: colors[0], + lighter: colors[1], + darker: colors[2], + complementary: colors[3] + }; + } + + // Calculate brightness of a color (RGB) + private getBrightness(color: string) { + const rgb = this.hexToRgb(color); // Convert hex to RGB + // Using the luminance formula for brightness + return (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b); + } + + // Fisher-Yates shuffle algorithm + private shuffleArray(array: string[]) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + + private hexToRgb(hex: string): RGB { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : { r: 0, g: 0, b: 0 }; + } + + private rgbToHex(rgb: RGB): string { + return `#${((1 << 24) + (rgb.r << 16) + (rgb.g << 8) + rgb.b).toString(16).slice(1)}`; + } + + private rgbToHsl(rgb: RGB): HSL { + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + + return { h, s, l }; + } + + private hslToRgb(hsl: HSL): RGB { + const { h, s, l } = hsl; + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1/6) return p + (q - p) * 6 * t; + if (t < 1/2) return q; + if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; + return p; + }; + + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1/3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1/3); + } + + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; + } + + private adjustHue(hsl: HSL, amount: number): HSL { + return { + h: (hsl.h + amount / 360) % 1, + s: hsl.s, + l: hsl.l + }; + } + + private calculateComplementaryRgb(rgb: RGB): RGB { + const hsl = this.rgbToHsl(rgb); + const complementaryHsl = this.adjustHue(hsl, 180); + return this.hslToRgb(complementaryHsl); + } + + private rgbaToString(color: RGBAColor): string { + return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; + } + + private rgbToString(color: RGBAColor): string { + return `rgb(${color.r}, ${color.g}, ${color.b})`; + } + + private getCssVariable(variableName: string): string { + return getComputedStyle(this.document.body).getPropertyValue(variableName).trim(); + } + + private isDarkTheme(): boolean { + return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim().toLowerCase() === 'dark'; + } + + private injectStyleElement(id: string, styles: string) { + let styleElement = this.document.getElementById(id); + if (!styleElement) { + styleElement = this.document.createElement('style'); + styleElement.id = id; + this.document.head.appendChild(styleElement); + } + styleElement.textContent = styles; + } + + private getRandomInRange(min: number, max: number): number { + return Math.random() * (max - min) + min; + } +} diff --git a/UI/Web/src/app/_services/dashboard.service.ts b/UI/Web/src/app/_services/dashboard.service.ts index 9d9deeffb..493fae370 100644 --- a/UI/Web/src/app/_services/dashboard.service.ts +++ b/UI/Web/src/app/_services/dashboard.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import {Injectable} from '@angular/core'; import {TextResonse} from "../_types/text-response"; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; @@ -26,4 +26,8 @@ export class DashboardService { createDashboardStream(smartFilterId: number) { return this.httpClient.post(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {}); } + + deleteSmartFilterStream(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-dashboard-stream?dashboardStreamId=' + streamId, {}); + } } diff --git a/UI/Web/src/app/_services/device.service.ts b/UI/Web/src/app/_services/device.service.ts index 1ba491177..496abf9c2 100644 --- a/UI/Web/src/app/_services/device.service.ts +++ b/UI/Web/src/app/_services/device.service.ts @@ -33,11 +33,11 @@ export class DeviceService { } createDevice(name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}, TextResonse); + return this.httpClient.post(this.baseUrl + 'device/create', {name, platform, emailAddress}); } updateDevice(id: number, name: string, platform: DevicePlatform, emailAddress: string) { - return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}, TextResonse); + return this.httpClient.post(this.baseUrl + 'device/update', {id, name, platform, emailAddress}); } deleteDevice(id: number) { diff --git a/UI/Web/src/app/_services/email.service.ts b/UI/Web/src/app/_services/email.service.ts new file mode 100644 index 000000000..5afb62ca7 --- /dev/null +++ b/UI/Web/src/app/_services/email.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {EmailHistory} from "../_models/email-history"; + +@Injectable({ + providedIn: 'root' +}) +export class EmailService { + baseUrl = environment.apiUrl; + constructor(private httpClient: HttpClient) { } + + getEmailHistory() { + return this.httpClient.get(`${this.baseUrl}email/all`); + } +} diff --git a/UI/Web/src/app/_services/external-source.service.ts b/UI/Web/src/app/_services/external-source.service.ts index 0fd726fa6..5fbc5a397 100644 --- a/UI/Web/src/app/_services/external-source.service.ts +++ b/UI/Web/src/app/_services/external-source.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import {ExternalSource} from "../_models/sidenav/external-source"; import {TextResonse} from "../_types/text-response"; import {map} from "rxjs/operators"; diff --git a/UI/Web/src/app/_services/filter.service.ts b/UI/Web/src/app/_services/filter.service.ts index 7d2648072..e76c1926f 100644 --- a/UI/Web/src/app/_services/filter.service.ts +++ b/UI/Web/src/app/_services/filter.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import {JumpKey} from "../_models/jumpbar/jump-key"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; @@ -23,4 +23,8 @@ export class FilterService { return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId); } + renameSmartFilter(filter: SmartFilter) { + return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {}); + } + } diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 2c85f2605..86aa8872a 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -17,18 +17,21 @@ export class ImageService { public errorImage = 'assets/images/error-placeholder2.dark-min.png'; public resetCoverImage = 'assets/images/image-reset-cover-min.png'; public errorWebLinkImage = 'assets/images/broken-white-32x32.png'; - public nextChapterImage = 'assets/images/image-placeholder.dark-min.png' + public nextChapterImage = 'assets/images/image-placeholder.dark-min.png'; + public noPersonImage = 'assets/images/error-person-missing.dark.min.png'; constructor(private accountService: AccountService, private themeService: ThemeService) { this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(theme => { if (this.themeService.isDarkTheme()) { this.placeholderImage = 'assets/images/image-placeholder.dark-min.png'; this.errorImage = 'assets/images/error-placeholder2.dark-min.png'; - this.errorWebLinkImage = 'assets/images/broken-white-32x32.png'; + this.errorWebLinkImage = 'assets/images/broken-black-32x32.png'; + this.noPersonImage = 'assets/images/error-person-missing.dark.min.png'; } else { this.placeholderImage = 'assets/images/image-placeholder-min.png'; this.errorImage = 'assets/images/error-placeholder2-min.png'; - this.errorWebLinkImage = 'assets/images/broken-black-32x32.png'; + this.errorWebLinkImage = 'assets/images/broken-white-32x32.png'; + this.noPersonImage = 'assets/images/error-person-missing.min.png'; } }); @@ -59,6 +62,13 @@ export class ImageService { return part.substring(0, equalIndex).replace('Id', ''); } + getPersonImage(personId: number) { + return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey}`; + } + getPersonImageByName(name: string) { + return `${this.baseUrl}image/person-cover-by-name?name=${name}&apiKey=${this.encodedKey}`; + } + getLibraryCoverImage(libraryId: number) { return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey}`; } @@ -91,6 +101,10 @@ export class ImageService { return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`; } + getPublisherImage(name: string) { + return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey}`; + } + getCoverUploadImage(filename: string) { return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`; } diff --git a/UI/Web/src/app/_services/jumpbar.service.ts b/UI/Web/src/app/_services/jumpbar.service.ts index 6ae2cb2e2..d9919ff57 100644 --- a/UI/Web/src/app/_services/jumpbar.service.ts +++ b/UI/Web/src/app/_services/jumpbar.service.ts @@ -16,21 +16,23 @@ export class JumpbarService { getResumeKey(key: string) { - if (this.resumeKeys.hasOwnProperty(key)) return this.resumeKeys[key]; + const k = key.toUpperCase(); + if (this.resumeKeys.hasOwnProperty(k)) return this.resumeKeys[k]; return ''; } - getResumePosition(key: string) { - if (this.resumeScroll.hasOwnProperty(key)) return this.resumeScroll[key]; + getResumePosition(url: string) { + if (this.resumeScroll.hasOwnProperty(url)) return this.resumeScroll[url]; return 0; } saveResumeKey(key: string, value: string) { - this.resumeKeys[key] = value; + const k = key.toUpperCase(); + this.resumeKeys[k] = value; } - saveScrollOffset(key: string, value: number) { - this.resumeScroll[key] = value; + saveResumePosition(url: string, value: number) { + this.resumeScroll[url] = value; } generateJumpBar(jumpBarKeys: Array, currentSize: number) { @@ -75,9 +77,11 @@ export class JumpbarService { _removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array, jumpBarKeysToRender: Array) { const removedIndexes: Array = []; + for(let removal = 0; removal < numberOfRemovals; removal++) { let min = 100000000; let minIndex = -1; + for(let i = 1; i < midPoint; i++) { if (jumpBarKeys[i].size < min && !removedIndexes.includes(i)) { min = jumpBarKeys[i].size; @@ -93,15 +97,15 @@ export class JumpbarService { } /** - * + * * @param data An array of objects * @param keySelector A method to fetch a string from the object, which is used to classify the JumpKey - * @returns + * @returns */ getJumpKeys(data :Array, keySelector: (data: any) => string) { const keys: {[key: string]: number} = {}; data.forEach(obj => { - let ch = keySelector(obj).charAt(0); + let ch = keySelector(obj).charAt(0).toUpperCase(); if (/\d|\#|!|%|@|\(|\)|\^|\.|_|\*/g.test(ch)) { ch = '#'; } @@ -111,10 +115,11 @@ export class JumpbarService { keys[ch] += 1; }); return Object.keys(keys).map(k => { + k = k.toUpperCase(); return { key: k, size: keys[k], - title: k.toUpperCase() + title: k } }).sort((a, b) => { if (a.key < b.key) return -1; diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 18cb55027..9178cd137 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -23,7 +23,9 @@ export class LibraryService { constructor(private httpClient: HttpClient, private readonly messageHub: MessageHubService, private readonly destroyRef: DestroyRef) { this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef), filter(e => e.event === EVENTS.LibraryModified), tap((e) => { - this.libraryNames = undefined; + console.log('LibraryModified event came in, clearing library name cache'); + this.libraryNames = undefined; + this.libraryTypes = undefined; })).subscribe(); } @@ -32,7 +34,7 @@ export class LibraryService { return of(this.libraryNames); } - return this.httpClient.get(this.baseUrl + 'library').pipe(map(libraries => { + return this.httpClient.get(this.baseUrl + 'library/libraries').pipe(map(libraries => { this.libraryNames = {}; libraries.forEach(lib => { if (this.libraryNames !== undefined) { @@ -47,7 +49,7 @@ export class LibraryService { if (this.libraryNames != undefined && this.libraryNames.hasOwnProperty(libraryId)) { return of(this.libraryNames[libraryId]); } - return this.httpClient.get(this.baseUrl + 'library').pipe(map(l => { + return this.httpClient.get(this.baseUrl + 'library/libraries').pipe(map(l => { this.libraryNames = {}; l.forEach(lib => { if (this.libraryNames !== undefined) { @@ -75,8 +77,12 @@ export class LibraryService { return this.httpClient.get(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId); } + getLibrary(libraryId: number) { + return this.httpClient.get(this.baseUrl + 'library?libraryId=' + libraryId); + } + getLibraries() { - return this.httpClient.get(this.baseUrl + 'library'); + return this.httpClient.get(this.baseUrl + 'library/libraries'); } updateLibrariesForMember(username: string, selectedLibraries: Library[]) { @@ -87,12 +93,28 @@ export class LibraryService { return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId + '&force=' + force, {}); } + scanMultipleLibraries(libraryIds: Array, force = false) { + return this.httpClient.post(this.baseUrl + 'library/scan-multiple', {ids: libraryIds, force: force}); + } + analyze(libraryId: number) { return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {}); } - refreshMetadata(libraryId: number, forceUpdate = false) { - return this.httpClient.post(this.baseUrl + 'library/refresh-metadata?libraryId=' + libraryId + '&force=' + forceUpdate, {}); + refreshMetadata(libraryId: number, forceUpdate = false, forceColorscape = false) { + return this.httpClient.post(this.baseUrl + `library/refresh-metadata?libraryId=${libraryId}&force=${forceUpdate}&forceColorscape=${forceColorscape}`, {}); + } + + refreshMetadataMultipleLibraries(libraryIds: Array, force = false, forceColorscape = false) { + return this.httpClient.post(this.baseUrl + 'library/refresh-metadata-multiple?forceColorscape=' + forceColorscape, {ids: libraryIds, force: force}); + } + + analyzeFilesMultipleLibraries(libraryIds: Array) { + return this.httpClient.post(this.baseUrl + 'library/analyze-multiple', {ids: libraryIds, force: false}); + } + + copySettingsFromLibrary(sourceLibraryId: number, targetLibraryIds: Array, includeType: boolean) { + return this.httpClient.post(this.baseUrl + 'library/copy-settings-from', {sourceLibraryId, targetLibraryIds, includeType}); } create(model: {name: string, type: number, folders: string[]}) { @@ -103,6 +125,13 @@ export class LibraryService { return this.httpClient.delete(this.baseUrl + 'library/delete?libraryId=' + libraryId, {}); } + deleteMultiple(libraryIds: Array) { + if (libraryIds.length === 0) { + return of(); + } + return this.httpClient.delete(this.baseUrl + 'library/delete-multiple?libraryIds=' + libraryIds.join(','), {}); + } + update(model: {name: string, folders: string[], id: number}) { return this.httpClient.post(this.baseUrl + 'library/update', model); } diff --git a/UI/Web/src/app/_services/license.service.ts b/UI/Web/src/app/_services/license.service.ts new file mode 100644 index 000000000..a2e77f2fe --- /dev/null +++ b/UI/Web/src/app/_services/license.service.ts @@ -0,0 +1,83 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {catchError, map, ReplaySubject, tap, throwError} from "rxjs"; +import {environment} from "../../environments/environment"; +import {TextResonse} from '../_types/text-response'; +import {LicenseInfo} from "../_models/kavitaplus/license-info"; + +@Injectable({ + providedIn: 'root' +}) +export class LicenseService { + private readonly httpClient = inject(HttpClient); + + baseUrl = environment.apiUrl; + + private readonly hasValidLicenseSource = new ReplaySubject(1); + /** + * Does the user have an active license + */ + public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable(); + + + /** + * Delete the license from the server and update hasValidLicenseSource to false + */ + deleteLicense() { + return this.httpClient.delete(this.baseUrl + 'license', TextResonse).pipe( + map(res => res === "true"), + tap(_ => { + this.hasValidLicenseSource.next(false) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + resetLicense(license: string, email: string) { + return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); + } + + /** + * Returns information about License and will internally cache if license is valid or not + */ + licenseInfo(forceCheck: boolean = false) { + return this.httpClient.get(this.baseUrl + `license/info?forceCheck=${forceCheck}`).pipe( + tap(res => { + this.hasValidLicenseSource.next(res?.isActive || false) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + hasValidLicense(forceCheck: boolean = false) { + return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) + .pipe( + map(res => res === "true"), + tap(res => { + this.hasValidLicenseSource.next(res) + }), + catchError(error => { + this.hasValidLicenseSource.next(false); + return throwError(error); // Rethrow the error to propagate it further + }) + ); + } + + hasAnyLicense() { + return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) + .pipe( + map(res => res === "true"), + ); + } + + updateUserLicense(license: string, email: string, discordId?: string) { + return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) + .pipe(map(res => res === "true")); + } +} diff --git a/UI/Web/src/app/_services/localization.service.ts b/UI/Web/src/app/_services/localization.service.ts index 23ba213b6..7519a9562 100644 --- a/UI/Web/src/app/_services/localization.service.ts +++ b/UI/Web/src/app/_services/localization.service.ts @@ -1,18 +1,36 @@ -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {environment} from "../../environments/environment"; -import {HttpClient} from "@angular/common/http"; -import {Language} from "../_models/metadata/language"; +import { HttpClient } from "@angular/common/http"; +import {KavitaLocale, Language} from "../_models/metadata/language"; +import {ReplaySubject, tap} from "rxjs"; +import {TranslocoService} from "@jsverse/transloco"; @Injectable({ providedIn: 'root' }) export class LocalizationService { + private readonly translocoService = inject(TranslocoService); + baseUrl = environment.apiUrl; + private readonly localeSubject = new ReplaySubject(1); + public readonly locales$ = this.localeSubject.asObservable(); + constructor(private httpClient: HttpClient) { } getLocales() { - return this.httpClient.get(this.baseUrl + 'locale'); + return this.httpClient.get(this.baseUrl + 'locale').pipe(tap(locales => { + this.localeSubject.next(locales); + })); + } + + refreshTranslations(lang: string) { + + // Clear the cached translation + localStorage.removeItem(`@@TRANSLOCO_PERSIST_TRANSLATIONS/${lang}`); + + // Reload the translation + return this.translocoService.load(lang); } } diff --git a/UI/Web/src/app/_services/manage.service.ts b/UI/Web/src/app/_services/manage.service.ts new file mode 100644 index 000000000..781830caa --- /dev/null +++ b/UI/Web/src/app/_services/manage.service.ts @@ -0,0 +1,18 @@ +import {inject, Injectable} from '@angular/core'; +import {environment} from "../../environments/environment"; +import {HttpClient} from "@angular/common/http"; +import {ManageMatchSeries} from "../_models/kavitaplus/manage-match-series"; +import {ManageMatchFilter} from "../_models/kavitaplus/manage-match-filter"; + +@Injectable({ + providedIn: 'root' +}) +export class ManageService { + + baseUrl = environment.apiUrl; + private readonly httpClient = inject(HttpClient); + + getAllKavitaPlusSeries(filter: ManageMatchFilter) { + return this.httpClient.post>(this.baseUrl + `manage/series-metadata`, filter); + } +} diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index b6afbd8a9..d93098995 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { environment } from 'src/environments/environment'; import { Member } from '../_models/auth/member'; +import {UserTokenInfo} from "../_models/kavitaplus/user-token-info"; @Injectable({ providedIn: 'root' @@ -20,6 +21,10 @@ export class MemberService { return this.httpClient.get(this.baseUrl + 'users/names'); } + getUserTokenInfo() { + return this.httpClient.get(this.baseUrl + 'users/tokens'); + } + adminExists() { return this.httpClient.get(this.baseUrl + 'admin/exists'); } @@ -37,11 +42,11 @@ export class MemberService { } addSeriesToWantToRead(seriesIds: Array) { - return this.httpClient.post>(this.baseUrl + 'want-to-read/add-series', {seriesIds}); + return this.httpClient.post(this.baseUrl + 'want-to-read/add-series', {seriesIds}); } removeSeriesToWantToRead(seriesIds: Array) { - return this.httpClient.post>(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); + return this.httpClient.post(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); } getMember() { diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 92fcfeeb6..ea1819bd7 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -9,15 +9,21 @@ import { UserUpdateEvent } from '../_models/events/user-update-event'; import { User } from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; +import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', ScanSeries = 'ScanSeries', SeriesAdded = 'SeriesAdded', SeriesRemoved = 'SeriesRemoved', + VolumeRemoved = 'VolumeRemoved', + ChapterRemoved = 'ChapterRemoved', ScanLibraryProgress = 'ScanLibraryProgress', OnlineUsers = 'OnlineUsers', - SeriesAddedToCollection = 'SeriesAddedToCollection', + /** + * When a Collection has been updated + */ + CollectionUpdated = 'CollectionUpdated', /** * A generic error that occurs during operations on the server */ @@ -40,6 +46,10 @@ export enum EVENTS { * A subtype of NotificationProgress that represents the underlying file being processed during a scan */ FileScanProgress = 'FileScanProgress', + /** + * A subtype of NotificationProgress that represents a single series being processed (into the DB) + */ + ScanProgress = 'ScanProgress', /** * A custom user site theme is added or removed during a scan */ @@ -91,7 +101,15 @@ export enum EVENTS { /** * User's sidenav needs to be re-rendered */ - SideNavUpdate = 'SideNavUpdate' + SideNavUpdate = 'SideNavUpdate', + /** + * A Theme was updated and UI should refresh to get the latest version + */ + SiteThemeUpdated = 'SiteThemeUpdated', + /** + * A Progress event when a smart collection is synchronizing + */ + SmartCollectionSync = 'SmartCollectionSync' } export interface Message { @@ -141,7 +159,7 @@ export class MessageHubService { accessTokenFactory: () => user.token }) .withAutomaticReconnect() - //.withStatefulReconnect() // Needs @microsoft/signalr@8 + //.withStatefulReconnect() // Requires signalr@8.0 .build(); this.hubConnection @@ -187,6 +205,20 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.SmartCollectionSync, resp => { + this.messagesSource.next({ + event: EVENTS.NotificationProgress, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => { + this.messagesSource.next({ + event: EVENTS.SiteThemeUpdated, + payload: resp.body as SiteThemeUpdatedEvent + }); + }); + this.hubConnection.on(EVENTS.DashboardUpdate, resp => { this.messagesSource.next({ event: EVENTS.DashboardUpdate, @@ -214,9 +246,9 @@ export class MessageHubService { }); }); - this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => { + this.hubConnection.on(EVENTS.CollectionUpdated, resp => { this.messagesSource.next({ - event: EVENTS.SeriesAddedToCollection, + event: EVENTS.CollectionUpdated, payload: resp.body }); }); @@ -263,6 +295,20 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.ChapterRemoved, resp => { + this.messagesSource.next({ + event: EVENTS.ChapterRemoved, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.VolumeRemoved, resp => { + this.messagesSource.next({ + event: EVENTS.VolumeRemoved, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.CoverUpdate, resp => { this.messagesSource.next({ event: EVENTS.CoverUpdate, diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index dbd4b6a68..314e5c37b 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -11,13 +11,15 @@ import {Person, PersonRole} from '../_models/metadata/person'; import {Tag} from '../_models/tag'; import {FilterComparison} from '../_models/metadata/v2/filter-comparison'; import {FilterField} from '../_models/metadata/v2/filter-field'; -import {Router} from "@angular/router"; import {SortField} from "../_models/metadata/series-filter"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {FilterStatement} from "../_models/metadata/v2/filter-statement"; import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus"; import {LibraryType} from "../_models/library/library"; +import {IHasCast} from "../_models/common/i-has-cast"; +import {TextResonse} from "../_types/text-response"; +import {QueryContext} from "../_models/metadata/v2/query-context"; @Injectable({ providedIn: 'root' @@ -61,11 +63,14 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } - getAllGenres(libraries?: Array) { + getAllGenres(libraries?: Array, context: QueryContext = QueryContext.None) { let method = 'metadata/genres' if (libraries != undefined && libraries.length > 0) { - method += '?libraryIds=' + libraries.join(','); + method += '?libraryIds=' + libraries.join(',') + '&context=' + context; + } else { + method += '?context=' + context; } + return this.httpClient.get>(this.baseUrl + method); } @@ -77,6 +82,10 @@ export class MetadataService { return this.httpClient.get>(this.baseUrl + method); } + getLanguageNameForCode(code: string) { + return this.httpClient.get(`${this.baseUrl}metadata/language-title?code=${code}`, TextResonse); + } + /** * All the potential language tags there can be @@ -126,4 +135,52 @@ export class MetadataService { arr[index].field = filterStmt.field; arr[index].value = filterStmt.value ? filterStmt.value + '' : ''; } + + updatePerson(entity: IHasCast, persons: Person[], role: PersonRole) { + switch (role) { + case PersonRole.Other: + break; + case PersonRole.Artist: + break; + case PersonRole.CoverArtist: + entity.coverArtists = persons; + break; + case PersonRole.Character: + entity.characters = persons; + break; + case PersonRole.Colorist: + entity.colorists = persons; + break; + case PersonRole.Editor: + entity.editors = persons; + break; + case PersonRole.Inker: + entity.inkers = persons; + break; + case PersonRole.Letterer: + entity.letterers = persons; + break; + case PersonRole.Penciller: + entity.pencillers = persons; + break; + case PersonRole.Publisher: + entity.publishers = persons; + break; + case PersonRole.Imprint: + entity.imprints = persons; + break; + case PersonRole.Team: + entity.teams = persons; + break; + case PersonRole.Location: + entity.locations = persons; + break; + case PersonRole.Writer: + entity.writers = persons; + break; + case PersonRole.Translator: + entity.translators = persons; + break; + } + } } diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 53eaac7fd..65d9fca17 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -1,18 +1,24 @@ -import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; -import { ReplaySubject, take } from 'rxjs'; +import {DOCUMENT} from '@angular/common'; +import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core'; +import {filter, ReplaySubject, take} from 'rxjs'; import {HttpClient} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {TextResonse} from "../_types/text-response"; -import {DashboardStream} from "../_models/dashboard/dashboard-stream"; import {AccountService} from "./account.service"; -import {tap} from "rxjs/operators"; +import {map} from "rxjs/operators"; +import {NavigationEnd, Router} from "@angular/router"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @Injectable({ providedIn: 'root' }) export class NavService { + + private readonly accountService = inject(AccountService); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + public localStorageSideNavKey = 'kavita--sidenav--expanded'; private navbarVisibleSource = new ReplaySubject(1); @@ -33,10 +39,22 @@ export class NavService { */ sideNavVisibility$ = this.sideNavVisibilitySource.asObservable(); + usePreferenceSideNav$ = this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map((evt) => { + const event = (evt as NavigationEnd); + const url = event.urlAfterRedirects || event.url; + return ( + /\/admin\/dashboard(#.*)?/.test(url) || /\/preferences(\/[^\/]+|#.*)?/.test(url) || /\/settings(\/[^\/]+|#.*)?/.test(url) + ); + }), + takeUntilDestroyed(this.destroyRef), + ); + private renderer: Renderer2; baseUrl = environment.apiUrl; - constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient, private accountService: AccountService) { + constructor(@Inject(DOCUMENT) private document: Document, rendererFactory: RendererFactory2, private httpClient: HttpClient) { this.renderer = rendererFactory.createRenderer(null, null); // To avoid flashing, let's check if we are authenticated before we show @@ -75,24 +93,38 @@ export class NavService { return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility}); } + deleteSideNavSmartFilter(streamId: number) { + return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-side-nav-stream?sideNavStreamId=' + streamId, {}); + } + /** * Shows the top nav bar. This should be visible on all pages except the reader. */ showNavBar() { - this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '56px'); - this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - 56px)'); - this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - 56px)'); - this.navbarVisibleSource.next(true); + setTimeout(() => { + const bodyElem = this.document.querySelector('body'); + this.renderer.setStyle(bodyElem, 'margin-top', 'var(--nav-offset)'); + this.renderer.removeStyle(bodyElem, 'scrollbar-gutter'); + this.renderer.setStyle(bodyElem, 'height', 'calc(var(--vh)*100 - var(--nav-offset))'); + this.renderer.setStyle(bodyElem, 'overflow', 'hidden'); + this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))'); + this.navbarVisibleSource.next(true); + }, 10); } /** * Hides the top nav bar. */ hideNavBar() { - this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px'); - this.renderer.removeStyle(this.document.querySelector('body'), 'height'); - this.renderer.removeStyle(this.document.querySelector('html'), 'height'); - this.navbarVisibleSource.next(false); + setTimeout(() => { + const bodyElem = this.document.querySelector('body'); + this.renderer.removeStyle(bodyElem, 'height'); + this.renderer.setStyle(bodyElem, 'margin-top', '0px', RendererStyleFlags2.Important); + this.renderer.setStyle(bodyElem, 'scrollbar-gutter', 'initial', RendererStyleFlags2.Important); + this.renderer.removeStyle(this.document.querySelector('html'), 'height'); + this.renderer.setStyle(bodyElem, 'overflow', 'auto'); + this.navbarVisibleSource.next(false); + }, 10); } /** @@ -117,4 +149,9 @@ export class NavService { localStorage.setItem(this.localStorageSideNavKey, newVal + ''); }); } + + collapseSideNav(isCollapsed: boolean) { + this.sideNavCollapseSource.next(isCollapsed); + localStorage.setItem(this.localStorageSideNavKey, isCollapsed + ''); + } } diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts new file mode 100644 index 000000000..676aa6e71 --- /dev/null +++ b/UI/Web/src/app/_services/person.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from "@angular/common/http"; +import {environment} from "../../environments/environment"; +import {Person, PersonRole} from "../_models/metadata/person"; +import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; +import {PaginatedResult} from "../_models/pagination"; +import {Series} from "../_models/series"; +import {map} from "rxjs/operators"; +import {UtilityService} from "../shared/_services/utility.service"; +import {BrowsePerson} from "../_models/person/browse-person"; +import {Chapter} from "../_models/chapter"; +import {StandaloneChapter} from "../_models/standalone-chapter"; +import {TextResonse} from "../_types/text-response"; + +@Injectable({ + providedIn: 'root' +}) +export class PersonService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } + + updatePerson(person: Person) { + return this.httpClient.post(this.baseUrl + "person/update", person); + } + + get(name: string) { + return this.httpClient.get(this.baseUrl + `person?name=${name}`); + } + + getRolesForPerson(personId: number) { + return this.httpClient.get>(this.baseUrl + `person/roles?personId=${personId}`); + } + + getSeriesMostKnownFor(personId: number) { + return this.httpClient.get>(this.baseUrl + `person/series-known-for?personId=${personId}`); + } + + getChaptersByRole(personId: number, role: PersonRole) { + return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); + } + + getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'person/all', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } + + downloadCover(personId: number) { + return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); + } +} diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index d3754d5f3..9941cd005 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -18,7 +18,11 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {PersonalToC} from "../_models/readers/personal-toc"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import NoSleep from 'nosleep.js'; -import {LibraryType} from "../_models/library/library"; +import {FullProgress} from "../_models/readers/full-progress"; +import {Volume} from "../_models/volume"; +import {UtilityService} from "../shared/_services/utility.service"; +import {translate} from "@jsverse/transloco"; +import {ToastrService} from "ngx-toastr"; export const CHAPTER_ID_DOESNT_EXIST = -1; @@ -30,17 +34,22 @@ export const CHAPTER_ID_NOT_FETCHED = -2; export class ReaderService { private readonly destroyRef = inject(DestroyRef); + private readonly utilityService = inject(UtilityService); + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly accountService = inject(AccountService); + private readonly toastr = inject(ToastrService); + baseUrl = environment.apiUrl; encodedKey: string = ''; // Override background color for reader and restore it onDestroy private originalBodyColor!: string; - private noSleep = new NoSleep(); - constructor(private httpClient: HttpClient, private router: Router, - private location: Location, private accountService: AccountService, - @Inject(DOCUMENT) private document: Document) { + private noSleep: NoSleep = new NoSleep(); + + constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { if (user) { this.encodedKey = encodeURIComponent(user.apiKey); @@ -48,17 +57,18 @@ export class ReaderService { }); } + enableWakeLock(element?: Element | Document) { // Enable wake lock. // (must be wrapped in a user input event handler e.g. a mouse or touch handler) if (!element) element = this.document; - const enableNoSleepHandler = () => { + const enableNoSleepHandler = async () => { element!.removeEventListener('click', enableNoSleepHandler, false); element!.removeEventListener('touchmove', enableNoSleepHandler, false); element!.removeEventListener('mousemove', enableNoSleepHandler, false); - this.noSleep!.enable(); + await this.noSleep.enable(); }; // Enable wake lock. @@ -73,15 +83,12 @@ export class ReaderService { } - getNavigationArray(libraryId: number, seriesId: number, chapterId: number, format: MangaFormat, libraryType: LibraryType) { + getNavigationArray(libraryId: number, seriesId: number, chapterId: number, format: MangaFormat) { if (format === undefined) format = MangaFormat.ARCHIVE; if (format === MangaFormat.EPUB) { return ['library', libraryId, 'series', seriesId, 'book', chapterId]; } else if (format === MangaFormat.PDF) { - if (libraryType === LibraryType.Magazine) { - return ['library', libraryId, 'series', seriesId, 'manga', chapterId]; - } return ['library', libraryId, 'series', seriesId, 'pdf', chapterId]; } else { return ['library', libraryId, 'series', seriesId, 'manga', chapterId]; @@ -135,8 +142,8 @@ export class ReaderService { return this.httpClient.get(this.baseUrl + 'reader/get-progress?chapterId=' + chapterId); } - getPageUrl(chapterId: number, page: number, extractPdf = false) { - return `${this.baseUrl}reader/image?chapterId=${chapterId}&apiKey=${this.encodedKey}&page=${page}&extractPdf=${extractPdf}`; + getPageUrl(chapterId: number, page: number) { + return `${this.baseUrl}reader/image?chapterId=${chapterId}&apiKey=${this.encodedKey}&page=${page}`; } getThumbnailUrl(chapterId: number, page: number) { @@ -147,8 +154,8 @@ export class ReaderService { return this.baseUrl + 'reader/bookmark-image?seriesId=' + seriesId + '&page=' + page + '&apiKey=' + encodeURIComponent(apiKey); } - getChapterInfo(chapterId: number, includeDimensions = false, extractPdf = false) { - return this.httpClient.get(this.baseUrl + `reader/chapter-info?chapterId=${chapterId}&includeDimensions=${includeDimensions}&extractPdf=${extractPdf}`); + getChapterInfo(chapterId: number, includeDimensions = false) { + return this.httpClient.get(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId + '&includeDimensions=' + includeDimensions); } getFileDimensions(chapterId: number) { @@ -159,6 +166,10 @@ export class ReaderService { return this.httpClient.post(this.baseUrl + 'reader/progress', {libraryId, seriesId, volumeId, chapterId, pageNum: page, bookScrollId}); } + getAllProgressForChapter(chapterId: number) { + return this.httpClient.get>(this.baseUrl + 'reader/all-chapter-progress?chapterId=' + chapterId); + } + markVolumeRead(seriesId: number, volumeId: number) { return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId}); } @@ -352,4 +363,30 @@ export class ReaderService { } return ''; } + + readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) { + if (volume.pagesRead < volume.pages && volume.pagesRead > 0) { + // Find the continue point chapter and load it + const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages); + if (unreadChapters.length > 0) { + this.readChapter(libraryId, seriesId, unreadChapters[0], incognitoMode); + return; + } + this.readChapter(libraryId, seriesId, volume.chapters[0], incognitoMode); + return; + } + + // Sort the chapters, then grab first if no reading progress + this.readChapter(libraryId, seriesId, [...volume.chapters].sort(this.utilityService.sortChapters)[0], incognitoMode); + } + + readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) { + if (chapter.pages === 0) { + this.toastr.error(translate('series-detail.no-pages')); + return; + } + + this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format), + {queryParams: {incognitoMode}}); + } } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 4789fe67d..088263a33 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -1,14 +1,14 @@ -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { UtilityService } from '../shared/_services/utility.service'; -import { Person } from '../_models/metadata/person'; -import { PaginatedResult } from '../_models/pagination'; -import { ReadingList, ReadingListItem } from '../_models/reading-list'; -import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary'; -import { TextResonse } from '../_types/text-response'; -import { ActionItem } from './action-factory.service'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {UtilityService} from '../shared/_services/utility.service'; +import {Person, PersonRole} from '../_models/metadata/person'; +import {PaginatedResult} from '../_models/pagination'; +import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; +import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; +import {TextResonse} from '../_types/text-response'; +import {Action, ActionItem} from './action-factory.service'; @Injectable({ providedIn: 'root' @@ -20,7 +20,7 @@ export class ReadingListService { constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } getReadingList(readingListId: number) { - return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); + return this.httpClient.get(this.baseUrl + 'readinglist?readingListId=' + readingListId); } getReadingLists(includePromoted: boolean = true, sortByLastModified: boolean = false, pageNum?: number, itemsPerPage?: number) { @@ -39,6 +39,10 @@ export class ReadingListService { return this.httpClient.get(this.baseUrl + 'readinglist/lists-for-series?seriesId=' + seriesId); } + getReadingListsForChapter(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'readinglist/lists-for-chapter?chapterId=' + chapterId); + } + getListItems(readingListId: number) { return this.httpClient.get(this.baseUrl + 'readinglist/items?readingListId=' + readingListId); } @@ -87,24 +91,50 @@ export class ReadingListService { return this.httpClient.post(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse); } - actionListFilter(action: ActionItem, readingList: ReadingList, isAdmin: boolean) { - if (readingList?.promoted && !isAdmin) return false; + actionListFilter(action: ActionItem, readingList: ReadingList, canPromote: boolean) { + + const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote; + + if (isPromotionAction) return canPromote; return true; + + // if (readingList?.promoted && !isAdmin) return false; + // return true; } nameExists(name: string) { return this.httpClient.get(this.baseUrl + 'readinglist/name-exists?name=' + name); } - validateCbl(form: FormData) { - return this.httpClient.post(this.baseUrl + 'cbl/validate', form); + validateCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) { + return this.httpClient.post(this.baseUrl + `cbl/validate?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } - importCbl(form: FormData) { - return this.httpClient.post(this.baseUrl + 'cbl/import', form); + importCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) { + return this.httpClient.post(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); } - getCharacters(readingListId: number) { - return this.httpClient.get>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId); + getPeople(readingListId: number, role: PersonRole) { + return this.httpClient.get>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`); } + + getAllPeople(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/all-people?readingListId=${readingListId}`); + } + + + getReadingListInfo(readingListId: number) { + return this.httpClient.get(this.baseUrl + `readinglist/info?readingListId=${readingListId}`); + } + + + promoteMultipleReadingLists(listIds: Array, promoted: boolean) { + return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse); + } + + deleteMultipleReadingLists(listIds: Array) { + return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse); + } + + } diff --git a/UI/Web/src/app/_services/scrobbling.service.ts b/UI/Web/src/app/_services/scrobbling.service.ts index 9edca977c..76b9212f4 100644 --- a/UI/Web/src/app/_services/scrobbling.service.ts +++ b/UI/Web/src/app/_services/scrobbling.service.ts @@ -1,8 +1,8 @@ import {HttpClient, HttpParams} from '@angular/common/http'; import {Injectable} from '@angular/core'; -import { map } from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { TextResonse } from '../_types/text-response'; +import {map} from 'rxjs/operators'; +import {environment} from 'src/environments/environment'; +import {TextResonse} from '../_types/text-response'; import {ScrobbleError} from "../_models/scrobbling/scrobble-error"; import {ScrobbleEvent} from "../_models/scrobbling/scrobble-event"; import {ScrobbleHold} from "../_models/scrobbling/scrobble-hold"; @@ -12,9 +12,10 @@ import {UtilityService} from "../shared/_services/utility.service"; export enum ScrobbleProvider { Kavita = 0, - AniList= 1, + AniList = 1, Mal = 2, - GoogleBooks = 3 + GoogleBooks = 3, + Cbr = 4 } @Injectable({ @@ -32,14 +33,35 @@ export class ScrobblingService { .pipe(map(r => r === "true")); } + /** + * Returns if the token was new or not + */ updateAniListToken(token: string) { - return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}); + return this.httpClient.post(this.baseUrl + 'scrobbling/update-anilist-token', {token}, TextResonse) + .pipe(map(r => r + '' === 'true')); + } + + /** + * Returns if the token was new or not + */ + updateMalToken(username: string, accessToken: string) { + return this.httpClient.post(this.baseUrl + 'scrobbling/update-mal-token', {username, accessToken}, TextResonse) + .pipe(map(r => r + '' === 'true')); } getAniListToken() { return this.httpClient.get(this.baseUrl + 'scrobbling/anilist-token', TextResonse); } + getMalToken() { + return this.httpClient.get<{username: string, accessToken: string}>(this.baseUrl + 'scrobbling/mal-token'); + } + + + hasRunScrobbleGen() { + return this.httpClient.get(this.baseUrl + 'scrobbling/has-ran-scrobble-gen ', TextResonse).pipe(map(r => r === 'true')); + } + getScrobbleErrors() { return this.httpClient.get>(this.baseUrl + 'scrobbling/scrobble-errors'); } @@ -79,4 +101,9 @@ export class ScrobblingService { removeHold(seriesId: number) { return this.httpClient.delete(this.baseUrl + 'scrobbling/remove-hold?seriesId=' + seriesId, TextResonse); } + + triggerScrobbleEventGeneration() { + return this.httpClient.post(this.baseUrl + 'scrobbling/generate-scrobble-events', TextResonse); + + } } diff --git a/UI/Web/src/app/_services/search.service.ts b/UI/Web/src/app/_services/search.service.ts index fa989fa35..4a95fff99 100644 --- a/UI/Web/src/app/_services/search.service.ts +++ b/UI/Web/src/app/_services/search.service.ts @@ -14,11 +14,11 @@ export class SearchService { constructor(private httpClient: HttpClient) { } - search(term: string) { + search(term: string, includeChapterAndFiles: boolean = false) { if (term === '') { return of(new SearchResultGroup()); } - return this.httpClient.get(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term)); + return this.httpClient.get(this.baseUrl + `search/search?includeChapterAndFiles=${includeChapterAndFiles}&queryString=${encodeURIComponent(term)}`); } getSeriesForMangaFile(mangaFileId: number) { diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 6de88b0aa..f221b2f1a 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -5,8 +5,6 @@ import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; -import { ChapterMetadata } from '../_models/metadata/chapter-metadata'; -import { CollectionTag } from '../_models/collection-tag'; import { PaginatedResult } from '../_models/pagination'; import { Series } from '../_models/series'; import { RelatedSeries } from '../_models/series-detail/related-series'; @@ -22,6 +20,9 @@ import {Rating} from "../_models/rating"; import {Recommendation} from "../_models/series-detail/recommendation"; import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail"; import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter"; +import {QueryContext} from "../_models/metadata/v2/query-context"; +import {ExternalSeries} from "../_models/series-detail/external-series"; +import {ExternalSeriesMatch} from "../_models/series-detail/external-series-match"; @Injectable({ providedIn: 'root' @@ -35,12 +36,12 @@ export class SeriesService { constructor(private httpClient: HttpClient, private imageService: ImageService, private utilityService: UtilityService) { } - getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) { + getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) { let params = new HttpParams(); params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = filter || {}; - return this.httpClient.post>(this.baseUrl + 'series/all-v2', data, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + 'series/all-v2?context=' + context, data, {observe: 'response', params}).pipe( map((response: any) => { return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) @@ -75,10 +76,6 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/chapter?chapterId=' + chapterId); } - getChapterMetadata(chapterId: number) { - return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); - } - delete(seriesId: number) { return this.httpClient.delete(this.baseUrl + 'series/' + seriesId, TextResonse).pipe(map(s => s === "true")); } @@ -149,8 +146,8 @@ export class SeriesService { } - refreshMetadata(series: Series) { - return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id}); + refreshMetadata(series: Series, force = true, forceColorscape = true) { + return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id, forceUpdate: force, forceColorscape}); } scan(libraryId: number, seriesId: number, force = false) { @@ -162,16 +159,12 @@ export class SeriesService { } getMetadata(seriesId: number) { - return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => { - items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id)); - return items; - })); + return this.httpClient.get(this.baseUrl + 'series/metadata?seriesId=' + seriesId); } - updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) { + updateMetadata(seriesMetadata: SeriesMetadata) { const data = { seriesMetadata, - collectionTags, }; return this.httpClient.post(this.baseUrl + 'series/metadata', data, TextResonse); } @@ -199,10 +192,11 @@ export class SeriesService { updateRelationships(seriesId: number, adaptations: Array, characters: Array, contains: Array, others: Array, prequels: Array, sequels: Array, sideStories: Array, spinOffs: Array, - alternativeSettings: Array, alternativeVersions: Array, doujinshis: Array, editions: Array) { + alternativeSettings: Array, alternativeVersions: Array, + doujinshis: Array, editions: Array, annuals: Array) { return this.httpClient.post(this.baseUrl + 'series/update-related?seriesId=' + seriesId, {seriesId, adaptations, characters, sequels, prequels, contains, others, sideStories, spinOffs, - alternativeSettings, alternativeVersions, doujinshis, editions}); + alternativeSettings, alternativeVersions, doujinshis, editions, annuals}); } getSeriesDetail(seriesId: number) { @@ -243,4 +237,16 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/next-expected?seriesId=' + seriesId); } + matchSeries(model: any) { + return this.httpClient.post>(this.baseUrl + 'series/match', model); + } + + updateMatch(seriesId: number, series: ExternalSeriesDetail) { + return this.httpClient.post(this.baseUrl + `series/update-match?seriesId=${seriesId}&aniListId=${series.aniListId || 0}&malId=${series.malId || 0}&cbrId=${series.cbrId || 0}`, {}, TextResonse); + } + + updateDontMatch(seriesId: number, dontMatch: boolean) { + return this.httpClient.post(this.baseUrl + `series/dont-match?seriesId=${seriesId}&dontMatch=${dontMatch}`, {}, TextResonse); + } + } diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index a70ef5a07..4a71e836e 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -17,6 +17,9 @@ export class ServerService { constructor(private http: HttpClient) { } + getVersion(apiKey: string) { + return this.http.get(this.baseUrl + 'plugin/version?apiKey=' + apiKey, TextResonse); + } getServerInfo() { return this.http.get(this.baseUrl + 'server/server-info-slim'); @@ -30,29 +33,29 @@ export class ServerService { return this.http.post(this.baseUrl + 'server/cleanup-want-to-read', {}); } + cleanup() { + return this.http.post(this.baseUrl + 'server/cleanup', {}); + } + backupDatabase() { return this.http.post(this.baseUrl + 'server/backup-db', {}); } - analyzeFiles() { - return this.http.post(this.baseUrl + 'server/analyze-files', {}); + syncThemes() { + return this.http.post(this.baseUrl + 'server/sync-themes', {}); } checkForUpdate() { return this.http.get(this.baseUrl + 'server/check-update'); } - checkHowOutOfDate() { - return this.http.get(this.baseUrl + 'server/checkHowOutOfDate', TextResonse) + checkHowOutOfDate(stableOnly: boolean = true) { + return this.http.get(this.baseUrl + `server/check-out-of-date?stableOnly=${stableOnly}`, TextResonse) .pipe(map(r => parseInt(r, 10))); } - checkForUpdates() { - return this.http.get(this.baseUrl + 'server/check-for-updates', {}); - } - - getChangelog() { - return this.http.get(this.baseUrl + 'server/changelog', {}); + getChangelog(count: number = 0) { + return this.http.get(this.baseUrl + 'server/changelog?count=' + count, {}); } getRecurringJobs() { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index f30eb26aa..f13b29c87 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,9 +1,9 @@ import { HttpClient } from '@angular/common/http'; -import {inject, Injectable} from '@angular/core'; +import {Inject, inject, Injectable} from '@angular/core'; import { environment } from 'src/environments/environment'; import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import { map } from 'rxjs'; +import {asyncScheduler, finalize, map, tap} from 'rxjs'; import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; import { TopUserRead } from '../statistics/_models/top-reads'; @@ -13,7 +13,12 @@ import { StatCount } from '../statistics/_models/stat-count'; import { PublicationStatus } from '../_models/metadata/publication-status'; import { MangaFormat } from '../_models/manga-format'; import { TextResonse } from '../_types/text-response'; -import {TranslocoService} from "@ngneat/transloco"; +import {TranslocoService} from "@jsverse/transloco"; +import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; +import {throttleTime} from "rxjs/operators"; +import {DEBOUNCE_TIME} from "../shared/_services/download.service"; +import {download} from "../shared/_models/download"; +import {Saver, SAVER} from "../_providers/saver.provider"; export enum DayOfWeek { @@ -36,7 +41,7 @@ export class StatisticsService { publicationStatusPipe = new PublicationStatusPipe(this.translocoService); mangaFormatPipe = new MangaFormatPipe(this.translocoService); - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { // TODO: Convert to httpParams object @@ -108,6 +113,20 @@ export class StatisticsService { return this.httpClient.get(this.baseUrl + 'stats/server/file-breakdown'); } + downloadFileBreakdown(extension: string) { + return this.httpClient.get(this.baseUrl + 'stats/server/file-extension?fileExtension=' + encodeURIComponent(extension), + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, decodeURIComponent(filename)); + }), + // tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)), + // finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); + + } + getReadCountByDay(userId: number = 0, days: number = 0) { return this.httpClient.get>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days); } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 5b7e325b9..3e186f8ac 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -1,4 +1,4 @@ -import { DOCUMENT } from '@angular/common'; +import {DOCUMENT} from '@angular/common'; import { HttpClient } from '@angular/common/http'; import { DestroyRef, @@ -9,18 +9,24 @@ import { RendererFactory2, SecurityContext } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; -import { ToastrService } from 'ngx-toastr'; -import { map, ReplaySubject, take } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { ConfirmService } from '../shared/confirm.service'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; -import { TextResonse } from '../_types/text-response'; -import { EVENTS, MessageHubService } from './message-hub.service'; +import {DomSanitizer} from '@angular/platform-browser'; +import {ToastrService} from 'ngx-toastr'; +import {filter, map, ReplaySubject, take, tap} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {ConfirmService} from '../shared/confirm.service'; +import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; +import {SiteTheme, ThemeProvider} from '../_models/preferences/site-theme'; +import {TextResonse} from '../_types/text-response'; +import {EVENTS, MessageHubService} from './message-hub.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; -import {translate} from "@ngneat/transloco"; - +import {translate} from "@jsverse/transloco"; +import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme"; +import {NgxFileDropEntry} from "ngx-file-drop"; +import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; +import {NavigationEnd, Router} from "@angular/router"; +import {ColorscapeService} from "./colorscape.service"; +import {ColorScape} from "../_models/theme/colorscape"; +import {debounceTime} from "rxjs/operators"; @Injectable({ providedIn: 'root' @@ -28,6 +34,8 @@ import {translate} from "@ngneat/transloco"; export class ThemeService { private readonly destroyRef = inject(DestroyRef); + private readonly colorTransitionService = inject(ColorscapeService); + public defaultTheme: string = 'dark'; public defaultBookTheme: string = 'Dark'; @@ -36,6 +44,9 @@ export class ThemeService { private themesSource = new ReplaySubject(1); public themes$ = this.themesSource.asObservable(); + + private darkModeSource = new ReplaySubject(1); + public isDarkMode$ = this.darkModeSource.asObservable(); /** * Maintain a cache of themes. SignalR will inform us if we need to refresh cache @@ -47,42 +58,70 @@ export class ThemeService { constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient, - messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService) { + messageHub: MessageHubService, private domSanitizer: DomSanitizer, private confirmService: ConfirmService, private toastr: ToastrService, + private router: Router) { this.renderer = rendererFactory.createRenderer(null, null); messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { - if (message.event !== EVENTS.NotificationProgress) return; - const notificationEvent = (message.payload as NotificationProgressEvent); - if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; + if (message.event === EVENTS.NotificationProgress) { + const notificationEvent = (message.payload as NotificationProgressEvent); + if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; - if (notificationEvent.eventType === 'ended') { - if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => { + if (notificationEvent.eventType === 'ended') { + if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(); + } + return; + } + + if (message.event === EVENTS.SiteThemeUpdated) { + const evt = (message.payload as SiteThemeUpdatedEvent); + this.currentTheme$.pipe(take(1)).subscribe(currentTheme => { + if (currentTheme && currentTheme.name !== EVENTS.SiteThemeProgress) return; + console.log('Active theme has been updated, refreshing theme'); + this.setTheme(currentTheme.name); }); } + + }); } + getDownloadableThemes() { + return this.httpClient.get>(this.baseUrl + 'theme/browse'); + } + + downloadTheme(theme: DownloadableSiteTheme) { + return this.httpClient.post(this.baseUrl + 'theme/download-theme', theme); + } + + uploadTheme(themeFile: File, fileEntry: NgxFileDropEntry) { + const formData = new FormData() + formData.append('formFile', themeFile, fileEntry.relativePath); + + return this.httpClient.post(this.baseUrl + 'theme/upload-theme', formData); + } + getColorScheme() { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } - /** - * --theme-color from theme. Updates the meta tag - * @returns - */ - getThemeColor() { - return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); - } + /** + * --theme-color from theme. Updates the meta tag + * @returns + */ + getThemeColor() { + return getComputedStyle(this.document.body).getPropertyValue('--theme-color').trim(); + } - /** - * --msapplication-TileColor from theme. Updates the meta tag - * @returns - */ - getTileColor() { - return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim(); - } + /** + * --msapplication-TileColor from theme. Updates the meta tag + * @returns + */ + getTileColor() { + return getComputedStyle(this.document.body).getPropertyValue('--title-color').trim(); + } getCssVariable(variable: string) { return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); @@ -113,6 +152,12 @@ export class ThemeService { this.unsetThemes(); } + deleteTheme(themeId: number) { + return this.httpClient.delete(this.baseUrl + 'theme?themeId=' + themeId).pipe(map(() => { + this.getThemes().subscribe(() => {}); + })); + } + setDefault(themeId: number) { return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => { // Refresh the cache when a default state is changed @@ -120,10 +165,6 @@ export class ThemeService { })); } - scan() { - return this.httpClient.post(this.baseUrl + 'theme/scan', {}); - } - /** * Sets the book theme on the body tag so css variable overrides can take place * @param selector brtheme- prefixed string @@ -138,6 +179,26 @@ export class ThemeService { } + /** + * Set's the background color from a single primary color. + * @param primaryColor + * @param complementaryColor + */ + setColorScape(primaryColor: string, complementaryColor: string | null = null) { + this.colorTransitionService.setColorScape(primaryColor, complementaryColor); + } + + /** + * Trigger a request to get the colors for a given entity and apply them + * @param entity + * @param id + */ + refreshColorScape(entity: 'series' | 'volume' | 'chapter' | 'person', id: number) { + return this.httpClient.get(`${this.baseUrl}colorscape/${entity}?id=${id}`).pipe(tap((cs) => { + this.setColorScape(cs.primary || '', cs.secondary); + })); + } + /** * Sets the theme as active. Will inject a style tag into document to load a custom theme and apply the selector to the body * @param themeName @@ -148,7 +209,7 @@ export class ThemeService { this.unsetThemes(); this.renderer.addClass(this.document.querySelector('body'), theme.selector); - if (theme.provider === ThemeProvider.User && !this.hasThemeInHead(theme.name)) { + if (theme.provider !== ThemeProvider.System && !this.hasThemeInHead(theme.name)) { // We need to load the styles into the browser this.fetchThemeContent(theme.id).subscribe(async (content) => { if (content === null) { @@ -159,7 +220,6 @@ export class ThemeService { const styleElem = this.document.createElement('style'); styleElem.id = 'theme-' + theme.name; styleElem.appendChild(this.document.createTextNode(content)); - this.renderer.appendChild(this.document.head, styleElem); // Check if the theme has --theme-color and apply it to meta tag @@ -180,9 +240,11 @@ export class ThemeService { } this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); }); } else { this.currentThemeSource.next(theme); + this.darkModeSource.next(this.isDarkTheme()); } } else { // Only time themes isn't already loaded is on first load @@ -210,6 +272,4 @@ export class ThemeService { private unsetBookThemes() { Array.from(this.document.body.classList).filter(cls => cls.startsWith('brtheme-')).forEach(c => this.document.body.classList.remove(c)); } - - } diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index 8c3d6295a..f2a811161 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -1,7 +1,10 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import { environment } from 'src/environments/environment'; import { TextResonse } from '../_types/text-response'; +import {translate} from "@jsverse/transloco"; +import {ToastrService} from "ngx-toastr"; +import {tap} from "rxjs"; @Injectable({ providedIn: 'root' @@ -9,6 +12,7 @@ import { TextResonse } from '../_types/text-response'; export class UploadService { private baseUrl = environment.apiUrl; + private readonly toastr = inject(ToastrService); constructor(private httpClient: HttpClient) { } @@ -18,29 +22,52 @@ export class UploadService { } /** - * + * * @param seriesId Series to overwrite cover image for * @param url A base64 encoded url - * @returns + * @param lockCover Should the cover be locked or not + * @returns */ - updateSeriesCoverImage(seriesId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url)}); + updateSeriesCoverImage(seriesId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateCollectionCoverImage(tagId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)}); + updateCollectionCoverImage(tagId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateReadingListCoverImage(readingListId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)}); + updateReadingListCoverImage(readingListId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateChapterCoverImage(chapterId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)}); + updateChapterCoverImage(chapterId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } - updateLibraryCoverImage(libraryId: number, url: string) { - return this.httpClient.post(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url)}); + updateVolumeCoverImage(volumeId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/volume', {id: volumeId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); + } + + updateLibraryCoverImage(libraryId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); + } + + updatePersonCoverImage(personId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/person', {id: personId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); } resetChapterCoverLock(chapterId: number, ) { diff --git a/UI/Web/src/app/_services/version.service.ts b/UI/Web/src/app/_services/version.service.ts new file mode 100644 index 000000000..ed9d8bac6 --- /dev/null +++ b/UI/Web/src/app/_services/version.service.ts @@ -0,0 +1,250 @@ +import {inject, Injectable, OnDestroy} from '@angular/core'; +import {interval, Subscription, switchMap} from 'rxjs'; +import {ServerService} from "./server.service"; +import {AccountService} from "./account.service"; +import {filter, take} from "rxjs/operators"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {NewUpdateModalComponent} from "../announcements/_components/new-update-modal/new-update-modal.component"; +import {OutOfDateModalComponent} from "../announcements/_components/out-of-date-modal/out-of-date-modal.component"; +import {Router} from "@angular/router"; + +@Injectable({ + providedIn: 'root' +}) +export class VersionService implements OnDestroy{ + + private readonly serverService = inject(ServerService); + private readonly accountService = inject(AccountService); + private readonly modalService = inject(NgbModal); + private readonly router = inject(Router); + + public static readonly SERVER_VERSION_KEY = 'kavita--version'; + public static readonly CLIENT_REFRESH_KEY = 'kavita--client-refresh-last-shown'; + public static readonly NEW_UPDATE_KEY = 'kavita--new-update-last-shown'; + public static readonly OUT_OF_BAND_KEY = 'kavita--out-of-band-last-shown'; + + // Notification intervals + private readonly CLIENT_REFRESH_INTERVAL = 0; // Show immediately (once) + private readonly NEW_UPDATE_INTERVAL = 7 * 24 * 60 * 60 * 1000; // 1 week in milliseconds + private readonly OUT_OF_BAND_INTERVAL = 30 * 24 * 60 * 60 * 1000; // 1 month in milliseconds + + // Check intervals + private readonly VERSION_CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes + private readonly OUT_OF_DATE_CHECK_INTERVAL = 2 * 60 * 60 * 1000; // 2 hours + private readonly OUT_Of_BAND_AMOUNT = 3; // How many releases before we show "You're X releases out of date" + + // Routes where version update modals should not be shown + private readonly EXCLUDED_ROUTES = [ + '/manga/', + '/book/', + '/pdf/', + '/reader/' + ]; + + + private versionCheckSubscription?: Subscription; + private outOfDateCheckSubscription?: Subscription; + private modalOpen = false; + + constructor() { + this.startInitialVersionCheck(); + this.startVersionCheck(); + this.startOutOfDateCheck(); + } + + ngOnDestroy() { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Initial version check to ensure localStorage is populated on first load + */ + private startInitialVersionCheck(): void { + this.accountService.currentUser$ + .pipe( + filter(user => !!user), + take(1), + switchMap(user => this.serverService.getVersion(user!.apiKey)) + ) + .subscribe(serverVersion => { + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + + // Always update localStorage on first load + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + + console.log('Initial version check - Server version:', serverVersion, 'Cached version:', cachedVersion); + }); + } + + /** + * Periodic check for server version to detect client refreshes and new updates + */ + private startVersionCheck(): void { + console.log('Starting version checker'); + this.versionCheckSubscription = interval(this.VERSION_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(user => !!user && !this.modalOpen), + switchMap(user => this.serverService.getVersion(user!.apiKey)), + filter(update => !!update), + ).subscribe(version => this.handleVersionUpdate(version)); + } + + /** + * Checks if the server is out of date compared to the latest release + */ + private startOutOfDateCheck() { + console.log('Starting out-of-date checker'); + this.outOfDateCheckSubscription = interval(this.OUT_OF_DATE_CHECK_INTERVAL) + .pipe( + switchMap(() => this.accountService.currentUser$), + filter(u => u !== undefined && this.accountService.hasAdminRole(u) && !this.modalOpen), + switchMap(_ => this.serverService.checkHowOutOfDate(true)), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > this.OUT_Of_BAND_AMOUNT), + ) + .subscribe(versionsOutOfDate => this.handleOutOfDateNotification(versionsOutOfDate)); + } + + /** + * Checks if the current route is in the excluded routes list + */ + private isExcludedRoute(): boolean { + const currentUrl = this.router.url; + return this.EXCLUDED_ROUTES.some(route => currentUrl.includes(route)); + } + + /** + * Handles the version check response to determine if client refresh or new update notification is needed + */ + private handleVersionUpdate(serverVersion: string) { + if (this.modalOpen) return; + + // Validate if we are on a reader route and if so, suppress + if (this.isExcludedRoute()) { + console.log('Version update blocked due to user reading'); + return; + } + + const cachedVersion = localStorage.getItem(VersionService.SERVER_VERSION_KEY); + console.log('Server version:', serverVersion, 'Cached version:', cachedVersion); + + const isNewServerVersion = cachedVersion !== null && cachedVersion !== serverVersion; + + // Case 1: Client Refresh needed (server has updated since last client load) + if (isNewServerVersion) { + this.showClientRefreshNotification(serverVersion); + } + // Case 2: Check for new updates (for server admin) + else { + this.checkForNewUpdates(); + } + + // Always update the cached version + localStorage.setItem(VersionService.SERVER_VERSION_KEY, serverVersion); + } + + /** + * Shows a notification that client refresh is needed due to server update + */ + private showClientRefreshNotification(newVersion: string): void { + this.pauseChecks(); + + // Client refresh notifications should always show (once) + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { + size: 'lg', + keyboard: false, + backdrop: 'static' // Prevent closing by clicking outside + }); + + ref.componentInstance.version = newVersion; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = true; + + // Update the last shown timestamp + localStorage.setItem(VersionService.CLIENT_REFRESH_KEY, Date.now().toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + + /** + * Checks for new server updates and shows notification if appropriate + */ + private checkForNewUpdates(): void { + this.accountService.currentUser$ + .pipe( + take(1), + filter(user => user !== undefined && this.accountService.hasAdminRole(user)), + switchMap(_ => this.serverService.checkHowOutOfDate()), + filter(versionsOutOfDate => !isNaN(versionsOutOfDate) && versionsOutOfDate > 0 && versionsOutOfDate <= this.OUT_Of_BAND_AMOUNT) + ) + .subscribe(versionsOutOfDate => { + const lastShown = Number(localStorage.getItem(VersionService.NEW_UPDATE_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last week + if (currentTime - lastShown >= this.NEW_UPDATE_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + this.serverService.getChangelog(1).subscribe(changelog => { + const ref = this.modalService.open(NewUpdateModalComponent, { size: 'lg' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + ref.componentInstance.update = changelog[0]; + ref.componentInstance.requiresRefresh = false; + + // Update the last shown timestamp + localStorage.setItem(VersionService.NEW_UPDATE_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + }); + } + }); + } + + /** + * Handles the notification for servers that are significantly out of date + */ + private handleOutOfDateNotification(versionsOutOfDate: number): void { + const lastShown = Number(localStorage.getItem(VersionService.OUT_OF_BAND_KEY) || '0'); + const currentTime = Date.now(); + + // Show notification if it hasn't been shown in the last month + if (currentTime - lastShown >= this.OUT_OF_BAND_INTERVAL) { + this.pauseChecks(); + this.modalOpen = true; + + const ref = this.modalService.open(OutOfDateModalComponent, { size: 'xl', fullscreen: 'md' }); + ref.componentInstance.versionsOutOfDate = versionsOutOfDate; + + // Update the last shown timestamp + localStorage.setItem(VersionService.OUT_OF_BAND_KEY, currentTime.toString()); + + ref.closed.subscribe(_ => this.onModalClosed()); + ref.dismissed.subscribe(_ => this.onModalClosed()); + } + } + + /** + * Pauses all version checks while modals are open + */ + private pauseChecks(): void { + this.versionCheckSubscription?.unsubscribe(); + this.outOfDateCheckSubscription?.unsubscribe(); + } + + /** + * Resumes all checks when modals are closed + */ + private onModalClosed(): void { + this.modalOpen = false; + this.startVersionCheck(); + this.startOutOfDateCheck(); + } +} diff --git a/UI/Web/src/app/_services/volume.service.ts b/UI/Web/src/app/_services/volume.service.ts new file mode 100644 index 000000000..f53a20543 --- /dev/null +++ b/UI/Web/src/app/_services/volume.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import {environment} from "../../environments/environment"; +import { HttpClient } from "@angular/common/http"; +import {Volume} from "../_models/volume"; +import {TextResonse} from "../_types/text-response"; + +@Injectable({ + providedIn: 'root' +}) +export class VolumeService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getVolumeMetadata(volumeId: number) { + return this.httpClient.get(this.baseUrl + 'volume?volumeId=' + volumeId); + } + + deleteVolume(volumeId: number) { + return this.httpClient.delete(this.baseUrl + 'volume?volumeId=' + volumeId); + } + + deleteMultipleVolumes(volumeIds: number[]) { + return this.httpClient.post(this.baseUrl + "volume/multiple", volumeIds) + } + + updateVolume(volume: any) { + return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); + } +} diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html new file mode 100644 index 000000000..067dc5fb2 --- /dev/null +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -0,0 +1,31 @@ + + + diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_omake.zip b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.scss similarity index 100% rename from API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_omake.zip rename to UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.scss diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts new file mode 100644 index 000000000..a35007bb3 --- /dev/null +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts @@ -0,0 +1,102 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output +} from '@angular/core'; +import {NgClass} from "@angular/common"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {Action, ActionItem} from "../../_services/action-factory.service"; +import {AccountService} from "../../_services/account.service"; +import {tap} from "rxjs"; +import {User} from "../../_models/user"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; + +@Component({ + selector: 'app-actionable-modal', + imports: [ + TranslocoDirective + ], + templateUrl: './actionable-modal.component.html', + styleUrl: './actionable-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ActionableModalComponent implements OnInit { + + protected readonly utilityService = inject(UtilityService); + protected readonly modal = inject(NgbActiveModal); + protected readonly accountService = inject(AccountService); + protected readonly cdRef = inject(ChangeDetectorRef); + protected readonly destroyRef = inject(DestroyRef); + protected readonly Breakpoint = Breakpoint; + + @Input() actions: ActionItem[] = []; + @Input() willRenderAction!: (action: ActionItem) => boolean; + @Input() shouldRenderSubMenu!: (action: ActionItem, dynamicList: null | Array) => boolean; + @Output() actionPerformed = new EventEmitter>(); + + currentLevel: string[] = []; + currentItems: ActionItem[] = []; + user!: User | undefined; + + ngOnInit() { + this.currentItems = this.translateOptions(this.actions); + + this.accountService.currentUser$.pipe(tap(user => { + this.user = user; + this.cdRef.markForCheck(); + }), takeUntilDestroyed(this.destroyRef)).subscribe(); + } + + handleItemClick(item: ActionItem) { + if (item.children && item.children.length > 0) { + this.currentLevel.push(item.title); + + if (item.children.length === 1 && item.children[0].dynamicList) { + item.children[0].dynamicList.subscribe(dynamicItems => { + this.currentItems = dynamicItems.map(di => ({ + ...item, + children: [], // Required as dynamic list is only one deep + title: di.title, + _extra: di, + action: item.children[0].action // override action to be correct from child + })); + }); + } else { + this.currentItems = this.translateOptions(item.children); + } + } + else { + this.actionPerformed.emit(item); + this.modal.close(item); + } + this.cdRef.markForCheck(); + } + + handleBack() { + if (this.currentLevel.length > 0) { + this.currentLevel.pop(); + + let items = this.actions; + for (let level of this.currentLevel) { + items = items.find(item => item.title === level)?.children || []; + } + + this.currentItems = this.translateOptions(items); + this.cdRef.markForCheck(); + } + } + + translateOptions(opts: Array>) { + return opts.map(a => { + return {...a, title: translate('actionable.' + a.title)}; + }) + } + +} diff --git a/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.html b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.html new file mode 100644 index 000000000..2be9b618d --- /dev/null +++ b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.html @@ -0,0 +1 @@ + diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v01.zip b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.scss similarity index 100% rename from API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v01.zip rename to UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.scss diff --git a/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts new file mode 100644 index 000000000..ebd4b2900 --- /dev/null +++ b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts @@ -0,0 +1,107 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Input, + OnChanges, + OnInit, + SimpleChanges +} from '@angular/core'; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {ImageComponent} from "../../shared/image/image.component"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {AgeRatingPipe} from "../../_pipes/age-rating.pipe"; +import {AsyncPipe} from "@angular/common"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; + +const basePath = './assets/images/ratings/'; + +@Component({ + selector: 'app-age-rating-image', + imports: [ + ImageComponent, + NgbTooltip, + AgeRatingPipe, + ], + templateUrl: './age-rating-image.component.html', + styleUrl: './age-rating-image.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AgeRatingImageComponent implements OnInit, OnChanges { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly filterUtilityService = inject(FilterUtilitiesService); + + protected readonly AgeRating = AgeRating; + + @Input({required: true}) rating: AgeRating = AgeRating.Unknown; + + imageUrl: string = 'unknown-rating.png'; + + ngOnInit() { + this.setImage(); + } + + ngOnChanges() { + this.setImage(); + } + + setImage() { + switch (this.rating) { + case AgeRating.Unknown: + this.imageUrl = basePath + 'unknown-rating.png'; + break; + case AgeRating.RatingPending: + this.imageUrl = basePath + 'rating-pending-rating.png'; + break; + case AgeRating.EarlyChildhood: + this.imageUrl = basePath + 'early-childhood-rating.png'; + break; + case AgeRating.Everyone: + this.imageUrl = basePath + 'everyone-rating.png'; + break; + case AgeRating.G: + this.imageUrl = basePath + 'g-rating.png'; + break; + case AgeRating.Everyone10Plus: + this.imageUrl = basePath + 'everyone-10+-rating.png'; + break; + case AgeRating.PG: + this.imageUrl = basePath + 'pg-rating.png'; + break; + case AgeRating.KidsToAdults: + this.imageUrl = basePath + 'kids-to-adults-rating.png'; + break; + case AgeRating.Teen: + this.imageUrl = basePath + 'teen-rating.png'; + break; + case AgeRating.Mature15Plus: + this.imageUrl = basePath + 'ma15+-rating.png'; + break; + case AgeRating.Mature17Plus: + this.imageUrl = basePath + 'mature-17+-rating.png'; + break; + case AgeRating.Mature: + this.imageUrl = basePath + 'm-rating.png'; + break; + case AgeRating.R18Plus: + this.imageUrl = basePath + 'r18+-rating.png'; + break; + case AgeRating.AdultsOnly: + this.imageUrl = basePath + 'adults-only-18+-rating.png'; + break; + case AgeRating.X18Plus: + this.imageUrl = basePath + 'x18+-rating.png'; + break; + } + this.cdRef.markForCheck(); + } + + openRating() { + this.filterUtilityService.applyFilter(['all-series'], FilterField.AgeRating, FilterComparison.Equal, `${this.rating}`).subscribe(); + } + + +} diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html index f2452ea19..2543a7106 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.html @@ -1,40 +1,51 @@ - -
- -
- + @if (actions.length > 0) { + @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { + + } @else { +
+ +
+ +
-
- - - - - - - - - - - - - - - - - - -
- -
- + + @for(action of list; track action.title) { + + @if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) { + @if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) { + @for(dynamicItem of dList; track dynamicItem.title) { + + } + } @else if (willRenderAction(action)) { + + } + } @else { + @if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) { + +
+ @if (willRenderAction(action)) { + + } +
+ +
-
- - - - - - + } + } + } + + } + } diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss index 5768c28f8..6f1d105e0 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.scss @@ -26,3 +26,13 @@ float: right; padding: var(--bs-dropdown-item-padding-y) 0; } + +.btn { + padding: 5px; +} + +// Robbie added this but it broke most of the uses +//.dropdown-toggle { +// padding-top: 0; +// padding-bottom: 0; +//} diff --git a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts index e9e9952dc..64719a226 100644 --- a/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/_single-module/card-actionables/card-actionables.component.ts @@ -1,46 +1,70 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle} from '@ng-bootstrap/ng-bootstrap'; -import { take } from 'rxjs'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, DestroyRef, + EventEmitter, + inject, + Input, + OnInit, + Output +} from '@angular/core'; +import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap'; import { AccountService } from 'src/app/_services/account.service'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; -import {CommonModule} from "@angular/common"; -import {TranslocoDirective} from "@ngneat/transloco"; +import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; +import {TranslocoDirective} from "@jsverse/transloco"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; +import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component"; @Component({ - selector: 'app-card-actionables', - standalone: true, - imports: [CommonModule, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective], - templateUrl: './card-actionables.component.html', - styleUrls: ['./card-actionables.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-card-actionables', + imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet], + templateUrl: './card-actionables.component.html', + styleUrls: ['./card-actionables.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class CardActionablesComponent implements OnInit { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); + protected readonly utilityService = inject(UtilityService); + protected readonly modalService = inject(NgbModal); + + protected readonly Breakpoint = Breakpoint; + @Input() iconClass = 'fa-ellipsis-v'; @Input() btnClass = ''; @Input() actions: ActionItem[] = []; @Input() labelBy = 'card'; + /** + * Text to display as if actionable was a button + */ + @Input() label = ''; @Input() disabled: boolean = false; @Output() actionHandler = new EventEmitter>(); isAdmin: boolean = false; canDownload: boolean = false; + canPromote: boolean = false; submenu: {[key: string]: NgbDropdown} = {}; - constructor(private readonly cdRef: ChangeDetectorRef, private accountService: AccountService) { } ngOnInit(): void { - this.accountService.currentUser$.pipe(take(1)).subscribe((user) => { + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { if (!user) return; this.isAdmin = this.accountService.hasAdminRole(user); this.canDownload = this.accountService.hasDownloadRole(user); + this.canPromote = this.accountService.hasPromoteRole(user); // We want to avoid an empty menu when user doesn't have access to anything if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) { this.actions = []; } + this.cdRef.markForCheck(); }); } @@ -61,7 +85,10 @@ export class CardActionablesComponent implements OnInit { willRenderAction(action: ActionItem) { return (action.requiresAdmin && this.isAdmin) || (action.action === Action.Download && (this.canDownload || this.isAdmin)) - || (!action.requiresAdmin && action.action !== Action.Download); + || (!action.requiresAdmin && action.action !== Action.Download) + || (action.action === Action.Promote && (this.canPromote || this.isAdmin)) + || (action.action === Action.UnPromote && (this.canPromote || this.isAdmin)) + ; } shouldRenderSubMenu(action: ActionItem, dynamicList: null | Array) { @@ -92,4 +119,16 @@ export class CardActionablesComponent implements OnInit { action._extra = dynamicItem; this.performAction(event, action); } + + openMobileActionableMenu(event: any) { + this.preventEvent(event); + + const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true}); + ref.componentInstance.actions = this.actions; + ref.componentInstance.willRenderAction = this.willRenderAction.bind(this); + ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this); + ref.componentInstance.actionPerformed.subscribe((action: ActionItem) => { + this.performAction(event, action); + }); + } } diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.html b/UI/Web/src/app/_single-module/cover-image/cover-image.component.html new file mode 100644 index 000000000..18a137cfa --- /dev/null +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.html @@ -0,0 +1,31 @@ + + @if(mobileSeriesImgBackground === 'true') { + + } @else { + + } +
+
+
+ + +
+ +
+
+
+
+ + @if (entity.pagesRead < entity.pages && entity.pagesRead > 0) { +
+ +
+ @if (continueTitle !== '') { +
+
+ {{t('continue-from', {title: continueTitle})}} +
+
+ } + } +
diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss b/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss new file mode 100644 index 000000000..0c9c9d032 --- /dev/null +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.scss @@ -0,0 +1,146 @@ +.overlay-information { + position: relative; + top: -364px; + height: 364px; + transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + &:hover { + cursor: pointer; + background-color: var(--card-overlay-hover-bg-color) !important; + + .overlay-information--centered { + visibility: visible; + } + } + + .overlay-information--centered { + position: absolute; + border-radius: 15px; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 50px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 115; + visibility: hidden; + + &:hover { + background-color: var(--primary-color) !important; + cursor: pointer; + } + + div { + width: 60px; + height: 60px; + i { + font-size: 1.6rem; + line-height: 60px; + width: 100%; + } + } + } +} + +.overlay-information { + position: absolute; + top: 0; + left: 12px; + width: calc(100% - 24px); + height: 100%; + transition: all 0.2s; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + + &:hover { + background-color: var(--card-overlay-hover-bg-color); + cursor: pointer; + } + + .overlay-information--centered { + position: absolute; + border-radius: 15px; + background-color: rgba(0, 0, 0, 0.7); + border-radius: 50px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 115; + + &:hover { + background-color: var(--primary-color) !important; + cursor: pointer; + } + } +} + +.series { + .overlay-information--centered { + div { + height: 32px; + width: 32px; + i { + font-size: 1.4rem; + line-height: 32px; + } + } + } +} + +::ng-deep .image-container app-image img { + border-radius: 4px 4px 0 0; +} + +.progress { + border-radius: 0; +} + +.progress-banner.series { + position: relative; +} + +::ng-deep .progress-banner.series span { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + color: white; + top: 50%; +} + +.under-image { + position: relative; + + .continue-from { + background-color: var(--breadcrumb-bg-color); + color: white; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + text-align: center; + position: absolute; + width: 100%; + font-size: 0.8rem; + -webkit-line-clamp: 1; + font-size: 0.8rem; + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + padding: 0 10px 0 0; + } +} + +@media screen and (max-width: 991px) { + .overlay-information { + visibility: hidden; + .overlay-information--centered { + visibility: hidden !important; + } + } + .progress-banner { + display: none; + } + + .under-image { + display: none; + } +} diff --git a/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts b/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts new file mode 100644 index 000000000..fa8996428 --- /dev/null +++ b/UI/Web/src/app/_single-module/cover-image/cover-image.component.ts @@ -0,0 +1,34 @@ +import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core'; +import {DecimalPipe, NgClass} from "@angular/common"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {ImageComponent} from "../../shared/image/image.component"; +import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {IHasProgress} from "../../_models/common/i-has-progress"; + +/** + * Used for the Series/Volume/Chapter Detail pages + */ +@Component({ + selector: 'app-cover-image', + imports: [ + TranslocoDirective, + ImageComponent, + NgbProgressbar, + DecimalPipe, + NgbTooltip + ], + templateUrl: './cover-image.component.html', + styleUrl: './cover-image.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CoverImageComponent { + + @Input({required: true}) coverImage!: string; + @Input({required: true}) entity!: IHasProgress; + @Input() continueTitle: string = ''; + @Output() read = new EventEmitter(); + + mobileSeriesImgBackground = getComputedStyle(document.documentElement) + .getPropertyValue('--mobile-series-img-background').trim(); + +} diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html new file mode 100644 index 000000000..1087f3d3b --- /dev/null +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -0,0 +1,191 @@ + +
+ + @if (readingTime) { +
+

{{t('read-time-title')}}

+
+ {{readingTime | readTime}} +
+
+ } + + @if (releaseYear) { +
+

{{t('release-title')}}

+
+ {{releaseYear}} +
+
+ } + + @if (language) { +
+

{{t('language-title')}}

+
+ {{language | languageName | async}} +
+
+ } + + @if (ageRating) { +
+

{{t('age-rating-title')}}

+
+ +
+
+ } + + @if (format) { +
+

{{t('format-title')}}

+
+ {{format | mangaFormat }} +
+
+ } + + + @if (!suppressEmptyGenres || genres.length > 0) { + + +
+

{{t('genres-title')}}

+
+ + + {{item.title}} + + +
+
+ } + + @if (!suppressEmptyTags || tags.length > 0) { +
+

{{t('tags-title')}}

+
+ + + {{item.title}} + + +
+
+ } + +
+ + + + + + + +
+ + @if (genres.length > 0 || tags.length > 0 || webLinks.length > 0) { + + } + + +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ + +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ + + + + +
+
+
diff --git a/UI/Web/src/app/user-settings/user-holds/user-holds.component.scss b/UI/Web/src/app/_single-module/details-tab/details-tab.component.scss similarity index 100% rename from UI/Web/src/app/user-settings/user-holds/user-holds.component.scss rename to UI/Web/src/app/_single-module/details-tab/details-tab.component.scss diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts new file mode 100644 index 000000000..096826964 --- /dev/null +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -0,0 +1,72 @@ +import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; +import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {IHasCast} from "../../_models/common/i-has-cast"; +import {PersonRole} from "../../_models/metadata/person"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {Genre} from "../../_models/metadata/genre"; +import {Tag} from "../../_models/tag"; +import {ImageComponent} from "../../shared/image/image.component"; +import {ImageService} from "../../_services/image.service"; +import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component"; +import {IHasReadingTime} from "../../_models/common/i-has-reading-time"; +import {ReadTimePipe} from "../../_pipes/read-time.pipe"; +import {MangaFormat} from "../../_models/manga-format"; +import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; +import {MangaFormatPipe} from "../../_pipes/manga-format.pipe"; +import {LanguageNamePipe} from "../../_pipes/language-name.pipe"; +import {AsyncPipe} from "@angular/common"; +import {SafeUrlPipe} from "../../_pipes/safe-url.pipe"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {AgeRatingImageComponent} from "../age-rating-image/age-rating-image.component"; + +@Component({ + selector: 'app-details-tab', + imports: [ + CarouselReelComponent, + PersonBadgeComponent, + TranslocoDirective, + ImageComponent, + BadgeExpanderComponent, + ReadTimePipe, + SeriesFormatComponent, + MangaFormatPipe, + LanguageNamePipe, + AsyncPipe, + SafeUrlPipe, + AgeRatingImageComponent + ], + templateUrl: './details-tab.component.html', + styleUrl: './details-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DetailsTabComponent { + + protected readonly imageService = inject(ImageService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + + protected readonly PersonRole = PersonRole; + protected readonly FilterField = FilterField; + protected readonly MangaFormat = MangaFormat; + + @Input({required: true}) metadata!: IHasCast; + @Input() readingTime: IHasReadingTime | undefined; + @Input() ageRating: AgeRating | undefined; + @Input() language: string | undefined; + @Input() format: MangaFormat | undefined; + @Input() releaseYear: number | undefined; + @Input() genres: Array = []; + @Input() tags: Array = []; + @Input() webLinks: Array = []; + @Input() suppressEmptyGenres: boolean = false; + @Input() suppressEmptyTags: boolean = false; + + + openGeneric(queryParamName: FilterField, filter: string | number) { + if (queryParamName === FilterField.None) return; + this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); + } +} diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html new file mode 100644 index 000000000..979794d20 --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -0,0 +1,666 @@ + + + + + + + + {{t('field-locked-alt')}} + + + + + diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.scss b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.scss new file mode 100644 index 000000000..fcef7bfcb --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.scss @@ -0,0 +1,6 @@ +.lock-active { + > .input-group-text { + background-color: var(--primary-color); + color: white; + } +} diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts new file mode 100644 index 000000000..467452a9f --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -0,0 +1,545 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core'; +import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import {AsyncPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common"; +import {NgbActiveModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {AccountService} from "../../_services/account.service"; +import {Chapter} from "../../_models/chapter"; +import {LibraryType} from "../../_models/library/library"; +import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings"; +import {Tag} from "../../_models/tag"; +import {Language} from "../../_models/metadata/language"; +import {Person, PersonRole} from "../../_models/metadata/person"; +import {Genre} from "../../_models/metadata/genre"; +import {AgeRatingDto} from "../../_models/metadata/age-rating-dto"; +import {ImageService} from "../../_services/image.service"; +import {UploadService} from "../../_services/upload.service"; +import {MetadataService} from "../../_services/metadata.service"; +import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; +import {ActionService} from "../../_services/action.service"; +import {DownloadService} from "../../shared/_services/download.service"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; +import {forkJoin, Observable, of, tap} from "rxjs"; +import {map, switchMap} from "rxjs/operators"; +import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; +import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; +import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; +import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {MangaFormat} from "../../_models/manga-format"; +import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {BytesPipe} from "../../_pipes/bytes.pipe"; +import {ImageComponent} from "../../shared/image/image.component"; +import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; +import {ReadTimePipe} from "../../_pipes/read-time.pipe"; +import {ChapterService} from "../../_services/chapter.service"; +import {AgeRating} from "../../_models/metadata/age-rating"; +import {User} from "../../_models/user"; + +enum TabID { + General = 'general-tab', + CoverImage = 'cover-image-tab', + Info = 'info-tab', + People = 'people-tab', + Tasks = 'tasks-tab', + Progress = 'progress-tab', + Tags = 'tags-tab' +} + +export interface EditChapterModalCloseResult { + success: boolean; + chapter: Chapter; + coverImageUpdate: boolean; + needsReload: boolean; + isDeleted: boolean; +} + +const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; + +@Component({ + selector: 'app-edit-chapter-modal', + imports: [ + FormsModule, + NgbNav, + NgbNavContent, + NgbNavLink, + TranslocoDirective, + AsyncPipe, + NgbNavOutlet, + ReactiveFormsModule, + NgbNavItem, + SettingItemComponent, + NgTemplateOutlet, + NgClass, + TypeaheadComponent, + EntityTitleComponent, + TitleCasePipe, + SettingButtonComponent, + CoverImageChooserComponent, + EditChapterProgressComponent, + CompactNumberPipe, + DefaultDatePipe, + UtcToLocalTimePipe, + BytesPipe, + ImageComponent, + SafeHtmlPipe, + ReadTimePipe, + ], + templateUrl: './edit-chapter-modal.component.html', + styleUrl: './edit-chapter-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditChapterModalComponent implements OnInit { + + protected readonly modal = inject(NgbActiveModal); + public readonly utilityService = inject(UtilityService); + public readonly imageService = inject(ImageService); + private readonly uploadService = inject(UploadService); + private readonly metadataService = inject(MetadataService); + private readonly cdRef = inject(ChangeDetectorRef); + protected readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly downloadService = inject(DownloadService); + private readonly chapterService = inject(ChapterService); + + protected readonly Breakpoint = Breakpoint; + protected readonly TabID = TabID; + protected readonly Action = Action; + protected readonly PersonRole = PersonRole; + protected readonly MangaFormat = MangaFormat; + + @Input({required: true}) chapter!: Chapter; + @Input({required: true}) libraryType!: LibraryType; + @Input({required: true}) libraryId!: number; + @Input({required: true}) seriesId!: number; + + activeId = TabID.General; + editForm: FormGroup = new FormGroup({}); + selectedCover: string = ''; + coverImageReset = false; + + tagsSettings: TypeaheadSettings = new TypeaheadSettings(); + languageSettings: TypeaheadSettings = new TypeaheadSettings(); + peopleSettings: {[PersonRole: string]: TypeaheadSettings} = {}; + genreSettings: TypeaheadSettings = new TypeaheadSettings(); + + tags: Tag[] = []; + genres: Genre[] = []; + ageRatings: Array = []; + validLanguages: Array = []; + + tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList); + /** + * A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified + */ + initChapter!: Chapter; + imageUrls: Array = []; + size: number = 0; + user!: User; + + get WebLinks() { + if (this.chapter.webLinks === '') return []; + return this.chapter.webLinks.split(','); + } + + + + ngOnInit() { + this.initChapter = Object.assign({}, this.chapter); + this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id)); + + this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0); + this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), tap(u => { + if (!u) return; + this.user = u; + + if (!this.accountService.hasAdminRole(this.user)) { + this.activeId = TabID.Info; + } + this.cdRef.markForCheck(); + + })).subscribe(); + + this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, [])); + this.editForm.addControl('sortOrder', new FormControl(Math.max(0, this.chapter.sortOrder), [Validators.required, Validators.min(0)])); + this.editForm.addControl('summary', new FormControl(this.chapter.summary || '', [])); + this.editForm.addControl('language', new FormControl(this.chapter.language, [])); + this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, [])); + this.editForm.addControl('ageRating', new FormControl(this.chapter.ageRating, [])); + + if (this.chapter.releaseDate !== '0001-01-01T00:00:00') { + this.editForm.addControl('releaseDate', new FormControl(this.chapter.releaseDate.substring(0, 10), [])); + } else { + this.editForm.addControl('releaseDate', new FormControl('', [])); + } + + + this.editForm.addControl('genres', new FormControl(this.chapter.genres, [])); + this.editForm.addControl('tags', new FormControl(this.chapter.tags, [])); + + + this.editForm.addControl('coverImageIndex', new FormControl(0, [])); + this.editForm.addControl('coverImageLocked', new FormControl(this.chapter.coverImageLocked, [])); + + this.metadataService.getAllValidLanguages().pipe( + tap(validLanguages => { + this.validLanguages = validLanguages; + this.cdRef.markForCheck(); + }), + switchMap(_ => this.setupLanguageTypeahead()) + ).subscribe(); + + this.metadataService.getAllAgeRatings().subscribe(ratings => { + this.ageRatings = ratings; + this.cdRef.markForCheck(); + }); + + this.editForm.get('titleName')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.titleNameLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('sortOrder')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.sortOrderLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('isbn')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.isbnLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('ageRating')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.ageRatingLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('summary')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.summaryLocked = true; + this.cdRef.markForCheck(); + }); + + this.editForm.get('releaseDate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.chapter.releaseDateLocked = true; + this.cdRef.markForCheck(); + }); + + this.setupTypeaheads(); + + } + + + close() { + this.modal.dismiss(); + } + + save() { + const model = this.editForm.value; + const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0; + + // Patch in data from the model that is not typeahead (as those are updated during setting) + if (model.releaseDate === '') { + this.chapter.releaseDate = '0001-01-01T00:00:00'; + } else { + this.chapter.releaseDate = model.releaseDate + 'T00:00:00'; + } + + this.chapter.ageRating = parseInt(model.ageRating + '', 10) as AgeRating; + this.chapter.sortOrder = model.sortOrder; + this.chapter.titleName = model.titleName; + this.chapter.summary = model.summary; + this.chapter.isbn = model.isbn; + + + const apis = [ + this.chapterService.updateChapter(this.chapter) + ]; + + // We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image + const needsReload = this.editForm.get('titleName')?.dirty || this.editForm.get('sortOrder')?.dirty; + + + if (selectedIndex > 0 || this.coverImageReset) { + apis.push(this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover, !this.coverImageReset)); + } + + forkJoin(apis).subscribe(results => { + this.modal.close({success: true, chapter: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: needsReload, isDeleted: false} as EditChapterModalCloseResult); + }); + } + + unlock(b: any, field: string) { + if (b) { + b[field] = !b[field]; + } + this.cdRef.markForCheck(); + } + + async runTask(action: ActionItem) { + switch (action.action) { + + case Action.MarkAsRead: + this.actionService.markChapterAsRead(this.libraryId, this.seriesId, this.chapter, (p) => { + this.chapter.pagesRead = p.pagesRead; + this.cdRef.markForCheck(); + }); + break; + case Action.MarkAsUnread: + this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, this.chapter, (p) => { + this.chapter.pagesRead = 0; + this.cdRef.markForCheck(); + }); + break; + case Action.Delete: + await this.actionService.deleteChapter(this.chapter.id, (b) => { + if (!b) return; + this.modal.close({success: b, chapter: this.chapter, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditChapterModalCloseResult); + }); + break; + case Action.Download: + this.downloadService.download('chapter', this.chapter); + break; + } + } + + setupTypeaheads() { + forkJoin([ + this.setupTagSettings(), + this.setupGenreTypeahead(), + this.setupPersonTypeahead(), + this.setupLanguageTypeahead() + ]).subscribe(results => { + this.cdRef.markForCheck(); + }); + } + + setupTagSettings() { + this.tagsSettings.minCharacters = 0; + this.tagsSettings.multiple = true; + this.tagsSettings.id = 'tags'; + this.tagsSettings.unique = true; + this.tagsSettings.showLocked = true; + this.tagsSettings.addIfNonExisting = true; + + + this.tagsSettings.compareFn = (options: Tag[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags() + .pipe(map(items => this.tagsSettings.compareFn(items, filter))); + + this.tagsSettings.addTransformFn = ((title: string) => { + return {id: 0, title: title }; + }); + this.tagsSettings.selectionCompareFn = (a: Tag, b: Tag) => { + return a.title.toLowerCase() == b.title.toLowerCase(); + } + this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } + this.tagsSettings.trackByIdentityFn = (index, value) => value.title + (value.id + ''); + + if (this.chapter.tags) { + this.tagsSettings.savedData = this.chapter.tags; + } + return of(true); + } + + setupGenreTypeahead() { + this.genreSettings.minCharacters = 0; + this.genreSettings.multiple = true; + this.genreSettings.id = 'genres'; + this.genreSettings.unique = true; + this.genreSettings.showLocked = true; + this.genreSettings.addIfNonExisting = true; + this.genreSettings.fetchFn = (filter: string) => { + return this.metadataService.getAllGenres() + .pipe(map(items => this.genreSettings.compareFn(items, filter))); + }; + this.genreSettings.compareFn = (options: Genre[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.genreSettings.compareFnForAdd = (options: Genre[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } + this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => { + return a.title.toLowerCase() == b.title.toLowerCase(); + } + + this.genreSettings.addTransformFn = ((title: string) => { + return {id: 0, title: title }; + }); + this.genreSettings.trackByIdentityFn = (index, value) => value.title + (value.id + ''); + + if (this.chapter.genres) { + this.genreSettings.savedData = this.chapter.genres; + } + return of(true); + } + + setupLanguageTypeahead() { + this.languageSettings.minCharacters = 0; + this.languageSettings.multiple = false; + this.languageSettings.id = 'language'; + this.languageSettings.unique = true; + this.languageSettings.showLocked = true; + this.languageSettings.addIfNonExisting = false; + this.languageSettings.compareFn = (options: Language[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.title, filter)); + } + this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.title, filter)); + } + this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages) + .pipe(map(items => this.languageSettings.compareFn(items, filter))); + + this.languageSettings.selectionCompareFn = (a: Language, b: Language) => { + return a.isoCode == b.isoCode; + } + this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode; + + const l = this.validLanguages.find(l => l.isoCode === this.chapter.language); + if (l !== undefined) { + this.languageSettings.savedData = l; + } + return of(true); + } + + + updateFromPreset(id: string, presetField: Array | undefined, role: PersonRole) { + const personSettings = this.createBlankPersonSettings(id, role) + + if (presetField && presetField.length > 0) { + const fetch = personSettings.fetchFn as ((filter: string) => Observable); + return fetch('').pipe(map(people => { + const presetIds = presetField.map(p => p.id); + personSettings.savedData = people.filter(person => presetIds.includes(person.id)); + this.peopleSettings[role] = personSettings; + this.metadataService.updatePerson(this.chapter, personSettings.savedData as Person[], role); + this.cdRef.markForCheck(); + return true; + })); + } + + this.peopleSettings[role] = personSettings; + return of(true); + + } + + setupPersonTypeahead() { + this.peopleSettings = {}; + + return forkJoin([ + this.updateFromPreset('writer', this.chapter.writers, PersonRole.Writer), + this.updateFromPreset('character', this.chapter.characters, PersonRole.Character), + this.updateFromPreset('colorist', this.chapter.colorists, PersonRole.Colorist), + this.updateFromPreset('cover-artist', this.chapter.coverArtists, PersonRole.CoverArtist), + this.updateFromPreset('editor', this.chapter.editors, PersonRole.Editor), + this.updateFromPreset('inker', this.chapter.inkers, PersonRole.Inker), + this.updateFromPreset('letterer', this.chapter.letterers, PersonRole.Letterer), + this.updateFromPreset('penciller', this.chapter.pencillers, PersonRole.Penciller), + this.updateFromPreset('publisher', this.chapter.publishers, PersonRole.Publisher), + this.updateFromPreset('imprint', this.chapter.imprints, PersonRole.Imprint), + this.updateFromPreset('translator', this.chapter.translators, PersonRole.Translator), + this.updateFromPreset('teams', this.chapter.teams, PersonRole.Team), + this.updateFromPreset('locations', this.chapter.locations, PersonRole.Location), + ]).pipe(map(_ => { + return of(true); + })); + } + + fetchPeople(role: PersonRole, filter: string) { + return this.metadataService.getAllPeople().pipe(map(people => { + return people.filter(p => this.utilityService.filter(p.name, filter)); + })); + } + + createBlankPersonSettings(id: string, role: PersonRole) { + let personSettings = new TypeaheadSettings(); + personSettings.minCharacters = 0; + personSettings.multiple = true; + personSettings.showLocked = true; + personSettings.unique = true; + personSettings.addIfNonExisting = true; + personSettings.id = id; + personSettings.compareFn = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + personSettings.compareFnForAdd = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filterMatches(m.name, filter)); + } + + personSettings.selectionCompareFn = (a: Person, b: Person) => { + return a.name == b.name; + } + personSettings.fetchFn = (filter: string) => { + return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); + }; + + personSettings.addTransformFn = ((title: string) => { + return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; + }); + + personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); + + return personSettings; + } + + updateTags(tags: Tag[]) { + this.tags = tags; + this.chapter.tags = tags; + this.cdRef.markForCheck(); + } + + updateGenres(genres: Genre[]) { + this.genres = genres; + this.chapter.genres = genres; + this.cdRef.markForCheck(); + } + + updatePerson(persons: Person[], role: PersonRole) { + this.metadataService.updatePerson(this.chapter, persons, role); + this.chapter.locationLocked = true; + this.cdRef.markForCheck(); + } + + updateLanguage(language: Array) { + if (language.length === 0) { + this.chapter.language = ''; + return; + } + this.chapter.language = language[0].isoCode; + this.chapter.languageLocked = true; + this.cdRef.markForCheck(); + } + + updateSelectedIndex(index: number) { + this.editForm.patchValue({ + coverImageIndex: index + }); + this.cdRef.markForCheck(); + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + this.cdRef.markForCheck(); + } + + handleReset() { + this.coverImageReset = true; + this.editForm.patchValue({ + coverImageLocked: false + }); + this.cdRef.markForCheck(); + } + + getPersonsSettings(role: PersonRole) { + return this.peopleSettings[role]; + } +} diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html new file mode 100644 index 000000000..b0f331b51 --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html @@ -0,0 +1,148 @@ + + + + + + diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v02.zip b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.scss similarity index 100% rename from API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v02.zip rename to UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.scss diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts new file mode 100644 index 000000000..3407afd83 --- /dev/null +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts @@ -0,0 +1,203 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {NgbActiveModal, NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavOutlet} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {NgClass} from "@angular/common"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; +import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; +import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; +import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; +import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; +import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {BytesPipe} from "../../_pipes/bytes.pipe"; +import {ReadTimePipe} from "../../_pipes/read-time.pipe"; +import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; +import {Volume} from "../../_models/volume"; +import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; +import {ImageService} from "../../_services/image.service"; +import {UploadService} from "../../_services/upload.service"; +import {AccountService} from "../../_services/account.service"; +import {ActionService} from "../../_services/action.service"; +import {DownloadService} from "../../shared/_services/download.service"; +import {LibraryType} from "../../_models/library/library"; +import {PersonRole} from "../../_models/metadata/person"; +import {forkJoin} from "rxjs"; +import {MangaFormat} from 'src/app/_models/manga-format'; +import {MangaFile} from "../../_models/manga-file"; +import {VolumeService} from "../../_services/volume.service"; +import {User} from "../../_models/user"; + +enum TabID { + General = 'general-tab', + CoverImage = 'cover-image-tab', + Info = 'info-tab', + Tasks = 'tasks-tab', + Progress = 'progress-tab', +} + +export interface EditVolumeModalCloseResult { + success: boolean; + volume: Volume; + coverImageUpdate: boolean; + needsReload: boolean; + isDeleted: boolean; +} + +const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; + +@Component({ + selector: 'app-edit-volume-modal', + imports: [ + FormsModule, + NgbNav, + NgbNavContent, + NgbNavLink, + TranslocoDirective, + NgbNavOutlet, + ReactiveFormsModule, + NgbNavItem, + SettingItemComponent, + NgClass, + EntityTitleComponent, + SettingButtonComponent, + CoverImageChooserComponent, + EditChapterProgressComponent, + CompactNumberPipe, + DefaultDatePipe, + UtcToLocalTimePipe, + BytesPipe, + ReadTimePipe + ], + templateUrl: './edit-volume-modal.component.html', + styleUrl: './edit-volume-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditVolumeModalComponent implements OnInit { + public readonly modal = inject(NgbActiveModal); + public readonly utilityService = inject(UtilityService); + public readonly imageService = inject(ImageService); + private readonly uploadService = inject(UploadService); + private readonly cdRef = inject(ChangeDetectorRef); + public readonly accountService = inject(AccountService); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly downloadService = inject(DownloadService); + private readonly volumeService = inject(VolumeService); + + protected readonly Breakpoint = Breakpoint; + protected readonly TabID = TabID; + protected readonly Action = Action; + protected readonly PersonRole = PersonRole; + protected readonly MangaFormat = MangaFormat; + + @Input({required: true}) volume!: Volume; + @Input({required: true}) libraryType!: LibraryType; + @Input({required: true}) libraryId!: number; + @Input({required: true}) seriesId!: number; + + activeId = TabID.Info; + editForm: FormGroup = new FormGroup({}); + selectedCover: string = ''; + coverImageReset = false; + user!: User; + + + tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList); + /** + * A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified + */ + initVolume!: Volume; + imageUrls: Array = []; + size: number = 0; + files: Array = []; + + constructor() { + this.accountService.currentUser$.subscribe(user => { + this.user = user!; + + if (!this.accountService.hasAdminRole(user!)) { + this.activeId = TabID.Info; + } + this.cdRef.markForCheck(); + }); + } + + + ngOnInit() { + this.initVolume = Object.assign({}, this.volume); + this.imageUrls.push(this.imageService.getVolumeCoverImage(this.volume.id)); + + this.files = this.volume.chapters.flatMap(c => c.files); + this.size = this.files.reduce((sum, v) => sum + v.bytes, 0); + + this.editForm.addControl('coverImageIndex', new FormControl(0, [])); + this.editForm.addControl('coverImageLocked', new FormControl(this.volume.coverImageLocked, [])); + } + + close() { + this.modal.dismiss(); + } + + save() { + const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0; + + const apis = []; + + if (selectedIndex > 0 || this.coverImageReset) { + apis.push(this.uploadService.updateVolumeCoverImage(this.volume.id, this.selectedCover, !this.coverImageReset)); + } + + forkJoin(apis).subscribe(results => { + this.modal.close({success: true, volume: this.volume, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: false, isDeleted: false} as EditVolumeModalCloseResult); + }); + } + + + async runTask(action: ActionItem) { + switch (action.action) { + case Action.MarkAsRead: + this.actionService.markVolumeAsRead(this.seriesId, this.volume, (p) => { + this.volume.pagesRead = p.pagesRead; + this.cdRef.markForCheck(); + }); + break; + case Action.MarkAsUnread: + this.actionService.markVolumeAsUnread(this.seriesId, this.volume, (p) => { + this.volume.pagesRead = 0; + this.cdRef.markForCheck(); + }); + break; + case Action.Delete: + await this.actionService.deleteVolume(this.volume.id, (b) => { + if (!b) return; + this.modal.close({success: b, volume: this.volume, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditVolumeModalCloseResult); + }); + break; + case Action.Download: + this.downloadService.download('volume', this.volume); + break; + } + } + + updateSelectedIndex(index: number) { + this.editForm.patchValue({ + coverImageIndex: index + }); + this.cdRef.markForCheck(); + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + this.cdRef.markForCheck(); + } + + handleReset() { + this.coverImageReset = true; + this.editForm.patchValue({ + coverImageLocked: false + }); + this.cdRef.markForCheck(); + } +} diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html new file mode 100644 index 000000000..c34a3d888 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.html @@ -0,0 +1,68 @@ + +
+ + + +
+
+ diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss new file mode 100644 index 000000000..d3a1cb9a9 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss @@ -0,0 +1,3 @@ +.setting-section-break { + margin: 0 !important; +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts new file mode 100644 index 000000000..793737923 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts @@ -0,0 +1,99 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {Series} from "../../_models/series"; +import {SeriesService} from "../../_services/series.service"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {MatchSeriesResultItemComponent} from "../match-series-result-item/match-series-result-item.component"; +import {LoadingComponent} from "../../shared/loading/loading.component"; +import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; +import {ToastrService} from "ngx-toastr"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; +import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; +import { ThemeService } from 'src/app/_services/theme.service'; +import { AsyncPipe } from '@angular/common'; + +@Component({ + selector: 'app-match-series-modal', + imports: [ + AsyncPipe, + TranslocoDirective, + MatchSeriesResultItemComponent, + LoadingComponent, + ReactiveFormsModule, + SettingItemComponent, + SettingSwitchComponent + ], + templateUrl: './match-series-modal.component.html', + styleUrl: './match-series-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MatchSeriesModalComponent implements OnInit { + private readonly cdRef = inject(ChangeDetectorRef); + private readonly seriesService = inject(SeriesService); + private readonly modalService = inject(NgbActiveModal); + private readonly toastr = inject(ToastrService); + protected readonly themeService = inject(ThemeService); + + @Input({required: true}) series!: Series; + + formGroup = new FormGroup({}); + matches: Array = []; + isLoading = true; + + ngOnInit() { + this.formGroup.addControl('query', new FormControl('', [])); + this.formGroup.addControl('dontMatch', new FormControl(this.series?.dontMatch || false, [])); + + this.search(); + } + + search() { + this.isLoading = true; + this.cdRef.markForCheck(); + + const model: any = this.formGroup.value; + model.seriesId = this.series.id; + + if (model.dontMatch) return; + + this.seriesService.matchSeries(model).subscribe(results => { + this.isLoading = false; + this.matches = results; + this.cdRef.markForCheck(); + }); + } + + close() { + this.modalService.close(false); + } + + save() { + + const model: any = this.formGroup.value; + model.seriesId = this.series.id; + + const dontMatchChanged = this.series.dontMatch !== model.dontMatch; + + // We need to update the dontMatch status + if (dontMatchChanged) { + this.seriesService.updateDontMatch(this.series.id, model.dontMatch).subscribe(_ => { + this.modalService.close(true); + }); + } else { + this.toastr.success(translate('toasts.match-success')); + this.modalService.close(true); + } + } + + selectMatch(item: ExternalSeriesMatch) { + const data = item.series; + data.tags = data.tags || []; + data.genres = data.genres || []; + + this.seriesService.updateMatch(this.series.id, item.series).subscribe(_ => { + this.save(); + }); + } + +} diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html new file mode 100644 index 000000000..15d0c5239 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html @@ -0,0 +1,47 @@ + +
+
+
+ @if (item.series.coverUrl) { + + } +
+
+
{{item.series.name}} ({{item.matchRating | translocoPercent}})
+
+ @for(synm of item.series.synonyms; track synm; let last = $last) { + {{synm}} + @if (!last) { + , + } + } +
+ @if (item.series.summary) { + + } +
+
+ + @if (isSelected) { +
+ + {{t('updating-metadata-status')}} +
+ } @else { +
+ @if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) { + {{t('volume-count', {num: item.series.volumes})}} + {{t('chapter-count', {num: item.series.chapters})}} + } @else { + {{t('releasing')}} + } + + {{item.series.plusMediaFormat | plusMediaFormat}} +
+ } + +
+
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss new file mode 100644 index 000000000..5df806397 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss @@ -0,0 +1,33 @@ +.search-result { + img { + max-width: 100px; + min-width: 100px; + } +} +.title { + font-size: 1.2rem; + font-weight: bold; + margin: 0; + padding: 0; +} + +.match-item-container { + &.dark { + background-color: var(--elevation-layer6-dark); + } + + &.light { + background-color: var(--elevation-layer6); + } + border-radius: 15px; + + &:hover { + &.dark { + background-color: var(--elevation-layer11-dark); + } + + &.light { + background-color: var(--elevation-layer11); + } + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts new file mode 100644 index 000000000..7dd953ad8 --- /dev/null +++ b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts @@ -0,0 +1,50 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + inject, + Input, + Output +} from '@angular/core'; +import {ImageComponent} from "../../shared/image/image.component"; +import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match"; +import {TranslocoPercentPipe} from "@jsverse/transloco-locale"; +import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe"; +import {LoadingComponent} from "../../shared/loading/loading.component"; + +@Component({ + selector: 'app-match-series-result-item', + imports: [ + ImageComponent, + TranslocoPercentPipe, + ReadMoreComponent, + TranslocoDirective, + PlusMediaFormatPipe, + LoadingComponent + ], + templateUrl: './match-series-result-item.component.html', + styleUrl: './match-series-result-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class MatchSeriesResultItemComponent { + + private readonly cdRef = inject(ChangeDetectorRef); + + @Input({required: true}) item!: ExternalSeriesMatch; + @Input({required: true}) isDarkMode = true; + @Output() selected: EventEmitter = new EventEmitter(); + + isSelected = false; + + selectItem() { + if (this.isSelected) return; + + this.isSelected = true; + this.cdRef.markForCheck(); + this.selected.emit(this.item); + } + +} diff --git a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html new file mode 100644 index 000000000..00b625413 --- /dev/null +++ b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.html @@ -0,0 +1,39 @@ +@if (publishers.length > 0) { +
+
+
+
+ +
+ {{currentPublisher!.name}} +
+
+
+
+
+ +
+ {{nextPublisher!.name}} +
+
+
+
+
+} + diff --git a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss new file mode 100644 index 000000000..9f4486d16 --- /dev/null +++ b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.scss @@ -0,0 +1,59 @@ +// +//.publisher-img-container { +// background-color: var(--card-bg-color); +// border-radius: 3px; +// padding: 2px 5px; +// font-size: 0.8rem; +// vertical-align: middle; +// +// div { +// min-height: 32px; +// line-height: 32px; +// } +//} + +// Animation code + +.publisher-wrapper { + perspective: 1000px; + height: 32px; + + background-color: var(--card-bg-color); + border-radius: 3px; + padding: 2px 5px; + font-size: 0.8rem; + vertical-align: middle; + + div { + min-height: 32px; + line-height: 32px; + } +} + +.publisher-flipper { + position: relative; + width: 100%; + height: 100%; + text-align: left; + transition: transform 0.6s ease; + transform-style: preserve-3d; +} + +.publisher-flipper.is-flipped { + transform: rotateX(180deg); +} + +.publisher-side { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; +} + +.publisher-front { + z-index: 2; +} + +.publisher-back { + transform: rotateX(180deg); +} diff --git a/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts new file mode 100644 index 000000000..fe54cdaa1 --- /dev/null +++ b/UI/Web/src/app/_single-module/publisher-flipper/publisher-flipper.component.ts @@ -0,0 +1,89 @@ +import { + AfterViewChecked, + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + inject, + Input, + OnDestroy, + OnInit +} from '@angular/core'; +import {ImageComponent} from "../../shared/image/image.component"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; +import {Person} from "../../_models/metadata/person"; +import {ImageService} from "../../_services/image.service"; +import {FilterComparison} from "../../_models/metadata/v2/filter-comparison"; +import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service"; +import {Router} from "@angular/router"; + +const ANIMATION_TIME = 3000; + +@Component({ + selector: 'app-publisher-flipper', + imports: [ + ImageComponent + ], + templateUrl: './publisher-flipper.component.html', + styleUrl: './publisher-flipper.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PublisherFlipperComponent implements OnInit, OnDestroy, AfterViewInit, AfterViewChecked { + + protected readonly imageService = inject(ImageService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly router = inject(Router); + + @Input() publishers: Array = []; + + + currentPublisher: Person | undefined = undefined; + nextPublisher: Person | undefined = undefined; + + currentIndex = 0; + isFlipped = false; + private intervalId: any; + + ngOnInit() { + if (this.publishers.length > 0) { + this.currentPublisher = this.publishers[0]; + this.nextPublisher = this.publishers[1] || this.publishers[0]; + } + } + + ngAfterViewInit() { + if (this.publishers.length > 1) { + this.startFlipping(); // Start flipping cycle once the view is initialized + } + } + + ngAfterViewChecked() { + // This lifecycle hook will be called after Angular performs change detection in each cycle + if (this.isFlipped) { + // Only update publishers after the flip is complete + this.currentIndex = (this.currentIndex + 1) % this.publishers.length; + this.currentPublisher = this.publishers[this.currentIndex]; + this.nextPublisher = this.publishers[(this.currentIndex + 1) % this.publishers.length]; + } + } + + ngOnDestroy() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + } + + private startFlipping() { + this.intervalId = setInterval(() => { + // Toggle flip state, initiating the flip animation + this.isFlipped = !this.isFlipped; + this.cdRef.detectChanges(); // Explicitly detect changes to trigger re-render + }, ANIMATION_TIME); + } + + openPublisher(filter: string | number) { + // TODO: once we build out publisher person-detail page, we can redirect there + this.filterUtilityService.applyFilter(['all-series'], FilterField.Publisher, FilterComparison.Equal, `${filter}`).subscribe(); + } +} diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.html b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html new file mode 100644 index 000000000..8334eaf21 --- /dev/null +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.html @@ -0,0 +1,47 @@ + +
+ @if (relations.length > 0) { + + + + + + } + + @if (collections.length > 0) { + + + + + + } + + + @if (readingLists.length > 0) { + + + + + + } + + @if (bookmarks.length > 0) { + + + + + + } +
+
diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v03.zip b/UI/Web/src/app/_single-module/related-tab/related-tab.component.scss similarity index 100% rename from API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v03.zip rename to UI/Web/src/app/_single-module/related-tab/related-tab.component.scss diff --git a/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts new file mode 100644 index 000000000..8d8a767d5 --- /dev/null +++ b/UI/Web/src/app/_single-module/related-tab/related-tab.component.ts @@ -0,0 +1,54 @@ +import {ChangeDetectionStrategy, Component, inject, Input, OnInit} from '@angular/core'; +import {ReadingList} from "../../_models/reading-list"; +import {CardItemComponent} from "../../cards/card-item/card-item.component"; +import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; +import {ImageService} from "../../_services/image.service"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {UserCollection} from "../../_models/collection-tag"; +import {Router} from "@angular/router"; +import {SeriesCardComponent} from "../../cards/series-card/series-card.component"; +import {Series} from "../../_models/series"; +import {RelationKind} from "../../_models/series-detail/relation-kind"; +import {PageBookmark} from "../../_models/readers/page-bookmark"; + +export interface RelatedSeriesPair { + series: Series; + relation: RelationKind; +} + +@Component({ + selector: 'app-related-tab', + imports: [ + CardItemComponent, + CarouselReelComponent, + TranslocoDirective, + SeriesCardComponent + ], + templateUrl: './related-tab.component.html', + styleUrl: './related-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RelatedTabComponent { + + protected readonly imageService = inject(ImageService); + protected readonly router = inject(Router); + + @Input() readingLists: Array = []; + @Input() collections: Array = []; + @Input() relations: Array = []; + @Input() bookmarks: Array = []; + @Input() libraryId!: number; + + openReadingList(readingList: ReadingList) { + this.router.navigate(['lists', readingList.id]); + } + + openCollection(collection: UserCollection) { + this.router.navigate(['collections', collection.id]); + } + + viewBookmark(bookmark: PageBookmark) { + this.router.navigate(['library', this.libraryId, 'series', bookmark.seriesId, 'manga', 0], {queryParams: {incognitoMode: false, bookmarkMode: true}}); + } + +} diff --git a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html index c76ddacef..773817c90 100644 --- a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html +++ b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.html @@ -2,18 +2,25 @@
diff --git a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts index 4926ac5e8..99ab421a5 100644 --- a/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-card-modal/review-card-modal.component.ts @@ -1,30 +1,30 @@ import { AfterViewInit, ChangeDetectionStrategy, - Component, inject, + Component, + inject, Inject, - Input, ViewChild, + Input, + ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; -import {CommonModule, DOCUMENT, NgOptimizedImage} from '@angular/common'; +import {DOCUMENT, NgOptimizedImage} from '@angular/common'; import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; import {ReactiveFormsModule} from "@angular/forms"; import {UserReview} from "../review-card/user-review"; import {SpoilerComponent} from "../spoiler/spoiler.component"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; -import {TranslocoDirective} from "@ngneat/transloco"; -import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; +import {TranslocoDirective} from "@jsverse/transloco"; import {ProviderImagePipe} from "../../_pipes/provider-image.pipe"; @Component({ selector: 'app-review-card-modal', - standalone: true, - imports: [CommonModule, ReactiveFormsModule, SpoilerComponent, SafeHtmlPipe, TranslocoDirective, DefaultValuePipe, NgOptimizedImage, ProviderImagePipe], + imports: [ReactiveFormsModule, SafeHtmlPipe, TranslocoDirective, NgOptimizedImage, ProviderImagePipe], templateUrl: './review-card-modal.component.html', styleUrls: ['./review-card-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, + encapsulation: ViewEncapsulation.None }) export class ReviewCardModalComponent implements AfterViewInit { @@ -45,7 +45,8 @@ export class ReviewCardModalComponent implements AfterViewInit { for (let i = 0; i < spoilers.length; i++) { const spoiler = spoilers[i]; - const componentRef = this.container.createComponent(SpoilerComponent); + const componentRef = this.container.createComponent(SpoilerComponent, + {projectableNodes: [[document.createTextNode('')]]}); componentRef.instance.html = spoiler.innerHTML; if (spoiler.parentNode != null) { spoiler.parentNode.replaceChild(componentRef.location.nativeElement, spoiler); diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.html b/UI/Web/src/app/_single-module/review-card/review-card.component.html index e1235070f..99a788471 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.html +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.html @@ -1,37 +1,36 @@ -
+
-
- -
- - {{t('your-review')}} -
+
+ @if (isMyReview) { + + + } @else { + + }
-
-
+
+
+

- +

-
diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.scss b/UI/Web/src/app/_single-module/review-card/review-card.component.scss index 511e10a96..7859ba11c 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.scss +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.scss @@ -1,5 +1,12 @@ +.review-card { + max-width: 320px; + max-height: 130px; + height: 130px; + width: 320px; +} + .profile-image { - font-size: 2rem; + font-size: 1.2rem; padding: 20px; } @@ -26,8 +33,6 @@ } .card-text.no-images { - min-height: 63px; - max-height: 63px; text-overflow: ellipsis; overflow: hidden; } @@ -42,4 +47,16 @@ max-width: 319px; justify-content: space-between; margin: 0 auto; + + & > * { + margin: 0 5px; + display: inline-flex; + } } + +.card-body { + display: block; + visibility: visible; + min-height: 93.5px; + max-height: 93.5px; +} \ No newline at end of file diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.ts b/UI/Web/src/app/_single-module/review-card/review-card.component.ts index 7a0b29de2..55216b169 100644 --- a/UI/Web/src/app/_single-module/review-card/review-card.component.ts +++ b/UI/Web/src/app/_single-module/review-card/review-card.component.ts @@ -8,27 +8,24 @@ import { OnInit, Output } from '@angular/core'; -import {CommonModule, NgOptimizedImage} from '@angular/common'; +import {NgOptimizedImage} from '@angular/common'; import {UserReview} from "./user-review"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component"; import {AccountService} from "../../_services/account.service"; import { - ReviewSeriesModalCloseAction, ReviewSeriesModalCloseEvent, ReviewSeriesModalComponent } from "../review-series-modal/review-series-modal.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; -import {ImageComponent} from "../../shared/image/image.component"; import {ProviderImagePipe} from "../../_pipes/provider-image.pipe"; -import {TranslocoDirective} from "@ngneat/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; import {ScrobbleProvider} from "../../_services/scrobbling.service"; @Component({ selector: 'app-review-card', - standalone: true, - imports: [CommonModule, ReadMoreComponent, DefaultValuePipe, ImageComponent, NgOptimizedImage, ProviderImagePipe, TranslocoDirective], + imports: [ReadMoreComponent, DefaultValuePipe, NgOptimizedImage, ProviderImagePipe, TranslocoDirective], templateUrl: './review-card.component.html', styleUrls: ['./review-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/review-card/user-review.ts b/UI/Web/src/app/_single-module/review-card/user-review.ts index f735d9548..1b5771463 100644 --- a/UI/Web/src/app/_single-module/review-card/user-review.ts +++ b/UI/Web/src/app/_single-module/review-card/user-review.ts @@ -9,6 +9,6 @@ export interface UserReview { tagline?: string; isExternal: boolean; bodyJustText?: string; - externalUrl?: string; + siteUrl?: string; provider: ScrobbleProvider; } diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html index 90ff4867a..6539a4e41 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.html @@ -13,14 +13,16 @@ -
- @if (reviewGroup.get('reviewBody')?.errors?.required) { -
{{t('required')}}
- } - @if (reviewGroup.get('reviewBody')?.errors?.minlength) { -
{{t('min-length', {count: minLength})}}
- } -
+ @if (reviewGroup.dirty || reviewGroup.touched) { +
+ @if (reviewGroup.get('reviewBody')?.errors?.required) { +
{{t('required')}}
+ } + @if (reviewGroup.get('reviewBody')?.errors?.minlength) { +
{{t('min-length', {count: minLength})}}
+ } +
+ }
diff --git a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts index f7158b27c..d15c3076c 100644 --- a/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-series-modal/review-series-modal.component.ts @@ -1,18 +1,9 @@ -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - inject, - Input, - OnInit -} from '@angular/core'; +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; -import {NgbActiveModal, NgbRating} from '@ng-bootstrap/ng-bootstrap'; -import { SeriesService } from 'src/app/_services/series.service'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {SeriesService} from 'src/app/_services/series.service'; import {UserReview} from "../review-card/user-review"; -import {CommonModule} from "@angular/common"; -import {translate, TranslocoDirective} from "@ngneat/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ConfirmService} from "../../shared/confirm.service"; import {ToastrService} from "ngx-toastr"; @@ -30,8 +21,7 @@ export interface ReviewSeriesModalCloseEvent { @Component({ selector: 'app-review-series-modal', - standalone: true, - imports: [CommonModule, NgbRating, ReactiveFormsModule, TranslocoDirective], + imports: [ReactiveFormsModule, TranslocoDirective], templateUrl: './review-series-modal.component.html', styleUrls: ['./review-series-modal.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html index 88b2a4747..e9be86cce 100644 --- a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html +++ b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.html @@ -2,22 +2,25 @@
{{name}} -
- + @if (CoverUrl; as coverUrl) {
- + @if (coverUrl) { + + }
-
+ } - -
- {{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}} -
+ @if (externalSeries) { + @if ((externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0) { +
+ {{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}} +
+ } @if(isExternalSeries && externalSeries) {
@@ -26,14 +29,20 @@
} - + @if (externalSeries.summary) { + + } + } + + {{t('series-preview-drawer.view-series')}} + + + @if (externalSeries) {
- - {{item}} - + {{item}}
@@ -41,25 +50,70 @@
- - {{item.name}} - + {{item.name}}
- + -
+
+
+
+ @if (item.imageUrl && !item.imageUrl.endsWith('default.jpg')) { + + } @else { + + } +
+
+
+
{{item.name}}
+

{{item.role}}

+
+
+
+
+ + +
+ } + @else if(localSeries) { +
+ {{localSeries.publicationStatus | publicationStatus}} + +
+ + + +
+ + + {{item.title}} + + +
+ +
+ + + {{item.title}} + + +
+ +
+ + +
- - - - - - +
@@ -72,67 +126,8 @@
- - - - -
- {{localSeries.publicationStatus | publicationStatus}} - -
- - -
- - - - {{item.title}} - - - -
- -
- - - - {{item.title}} - - - -
- -
- - -
-
-
- -
-
-
-
{{item.name}}
-

{{item.role}}

-
-
-
-
-
-
-
- -
-
+ } - - - {{t('series-preview-drawer.view-series')}} -
diff --git a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.scss b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.scss index c34531887..d3c04eff2 100644 --- a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.scss +++ b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.scss @@ -15,4 +15,13 @@ a.read-more-link { white-space: nowrap; -} \ No newline at end of file +} + +.not-clickable { + cursor: text; +} + +.offcanvas-body { + mask-image: linear-gradient(to bottom, transparent, black 0%, black 97%, transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 97%, transparent 100%); +} diff --git a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.ts b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.ts index f2eeefbf5..46afe7f17 100644 --- a/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.ts +++ b/UI/Web/src/app/_single-module/series-preview-drawer/series-preview-drawer.component.ts @@ -1,33 +1,38 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; -import {CommonModule, NgOptimizedImage} from '@angular/common'; -import {TranslocoDirective} from "@ngneat/transloco"; +import {NgOptimizedImage} from '@angular/common'; +import {TranslocoDirective} from "@jsverse/transloco"; import {NgbActiveOffcanvas, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {ExternalSeriesDetail, SeriesStaff} from "../../_models/series-detail/external-series-detail"; import {SeriesService} from "../../_services/series.service"; import {ImageComponent} from "../../shared/image/image.component"; import {LoadingComponent} from "../../shared/loading/loading.component"; -import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; -import {A11yClickDirective} from "../../shared/a11y-click.directive"; import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component"; -import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component"; -import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component"; import {ImageService} from "../../_services/image.service"; import {PublicationStatusPipe} from "../../_pipes/publication-status.pipe"; import {SeriesMetadata} from "../../_models/metadata/series-metadata"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {ActionService} from "../../_services/action.service"; import {ProviderImagePipe} from "../../_pipes/provider-image.pipe"; -import {ScrobbleProvider} from "../../_services/scrobbling.service"; +import {FilterField} from "../../_models/metadata/v2/filter-field"; @Component({ - selector: 'app-series-preview-drawer', - standalone: true, - imports: [CommonModule, TranslocoDirective, ImageComponent, LoadingComponent, SafeHtmlPipe, A11yClickDirective, MetadataDetailComponent, PersonBadgeComponent, TagBadgeComponent, PublicationStatusPipe, ReadMoreComponent, NgbTooltip, NgOptimizedImage, ProviderImagePipe], - templateUrl: './series-preview-drawer.component.html', - styleUrls: ['./series-preview-drawer.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-series-preview-drawer', + imports: [TranslocoDirective, ImageComponent, LoadingComponent, MetadataDetailComponent, + PublicationStatusPipe, ReadMoreComponent, NgbTooltip, NgOptimizedImage, ProviderImagePipe], + templateUrl: './series-preview-drawer.component.html', + styleUrls: ['./series-preview-drawer.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class SeriesPreviewDrawerComponent implements OnInit { + + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly seriesService = inject(SeriesService); + private readonly imageService = inject(ImageService); + private readonly actionService = inject(ActionService); + private readonly cdRef = inject(ChangeDetectorRef); + + protected readonly FilterField = FilterField; + @Input({required: true}) name!: string; @Input() aniListId?: number; @Input() malId?: number; @@ -42,11 +47,7 @@ export class SeriesPreviewDrawerComponent implements OnInit { url: string = ''; wantToRead: boolean = false; - private readonly activeOffcanvas = inject(NgbActiveOffcanvas); - private readonly seriesService = inject(SeriesService); - private readonly imageService = inject(ImageService); - private readonly actionService = inject(ActionService); - private readonly cdRef = inject(ChangeDetectorRef); + get CoverUrl() { if (this.isExternalSeries) { diff --git a/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.html b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.html new file mode 100644 index 000000000..525155e6a --- /dev/null +++ b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.html @@ -0,0 +1,40 @@ + +
+
+ {{collection.title}} +
+ +
+ +
+ +
+ + + {{collection.lastSyncUtc | utcToLocalTime:'shortDate' | defaultDate}} + + +
+ + +
+ + + {{collection.totalSourceCount - series.length}} / {{collection.totalSourceCount | number}} + + + + @if(collection.missingSeriesFromSource) { +

+ } + @for(s of series; track s.name) { + + } +
+
+
+
+
diff --git a/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss new file mode 100644 index 000000000..55f5cfd90 --- /dev/null +++ b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.scss @@ -0,0 +1,5 @@ +:host { + height: 100%; + display: flex; + flex-direction: column; +} diff --git a/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts new file mode 100644 index 000000000..ec67ecf7a --- /dev/null +++ b/UI/Web/src/app/_single-module/smart-collection-drawer/smart-collection-drawer.component.ts @@ -0,0 +1,42 @@ +import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; +import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; +import {UserCollection} from "../../_models/collection-tag"; +import {DecimalPipe} from "@angular/common"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {Series} from "../../_models/series"; +import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; +import {RouterLink} from "@angular/router"; +import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; +import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; + +@Component({ + selector: 'app-smart-collection-drawer', + imports: [ + TranslocoDirective, + SafeHtmlPipe, + RouterLink, + DefaultDatePipe, + UtcToLocalTimePipe, + SettingItemComponent, + DecimalPipe + ], + templateUrl: './smart-collection-drawer.component.html', + styleUrl: './smart-collection-drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SmartCollectionDrawerComponent implements OnInit { + private readonly activeOffcanvas = inject(NgbActiveOffcanvas); + private readonly cdRef = inject(ChangeDetectorRef); + + @Input({required: true}) collection!: UserCollection; + @Input({required: true}) series: Series[] = []; + + ngOnInit() { + + } + + close() { + this.activeOffcanvas.close(); + } +} diff --git a/UI/Web/src/app/_single-module/spoiler/spoiler.component.html b/UI/Web/src/app/_single-module/spoiler/spoiler.component.html index 67b6e2a5e..c8a4ddc53 100644 --- a/UI/Web/src/app/_single-module/spoiler/spoiler.component.html +++ b/UI/Web/src/app/_single-module/spoiler/spoiler.component.html @@ -1,8 +1,9 @@
- {{t('click-to-show')}} - + @if (isCollapsed) { + {{t('click-to-show')}} + } @else {
-
+ }
diff --git a/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts b/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts index 6eefdcb70..4c5fc1982 100644 --- a/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts +++ b/UI/Web/src/app/_single-module/spoiler/spoiler.component.ts @@ -7,18 +7,16 @@ import { OnInit, ViewEncapsulation } from '@angular/core'; -import {CommonModule} from '@angular/common'; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; -import {TranslocoDirective} from "@ngneat/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; @Component({ - selector: 'app-spoiler', - standalone: true, - imports: [CommonModule, SafeHtmlPipe, TranslocoDirective], - templateUrl: './spoiler.component.html', - styleUrls: ['./spoiler.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None + selector: 'app-spoiler', + imports: [SafeHtmlPipe, TranslocoDirective], + templateUrl: './spoiler.component.html', + styleUrls: ['./spoiler.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None }) export class SpoilerComponent implements OnInit{ diff --git a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts index 20df49758..3f5d880d6 100644 --- a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts +++ b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts @@ -1,4 +1,4 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import {ChangeDetectorRef, Directive, EventEmitter, inject, Input, OnInit, Output} from "@angular/core"; export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0); export type SortColumn = keyof T | ''; @@ -11,6 +11,7 @@ export interface SortEvent { } @Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector selector: 'th[sortable]', host: { '[class.asc]': 'direction === "asc"', @@ -29,4 +30,4 @@ export class SortableHeader { this.direction = rotate[this.direction]; this.sort.emit({ column: this.sortable, direction: this.direction }); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index e1cf24d51..51caae2f3 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -1,78 +1,111 @@ -
{{t('title')}}
-

{{t('description')}}

-
-
-
-
- - -
-
-
-
- -
+ + @let currentUser = accountService.currentUser$ | async; + +
+
- - - - - - - - - - - - - - - - - - - - - - - - -
+ + @if (tokenExpired) { +

{{t('token-expired')}}

+ } @else if (!currentUser!.preferences.aniListScrobblingEnabled) { +

{{t('scrobbling-disabled')}}

+ } + +

{{t('description')}}

+

{{t('not-read-warning')}}

+
+
+
+ + +
+
+
+ + + + + {{t('created-header')}} -
- {{t('last-modified-header')}} - + + + {{value | utcToLocalTime | defaultValue }} + + + + + {{t('type-header')}} - + + + {{value | scrobbleEventType}} + + + + + {{t('series-header')}} - + + + {{item.seriesName}} + + + + + {{t('data-header')}} - - {{t('is-processed-header')}} -
{{t('no-data')}}
- {{item.createdUtc | utcToLocalTime | defaultValue}} - - {{item.lastModifiedUtc | utcToLocalTime | defaultValue }} - - {{item.scrobbleEventType | scrobbleEventType}} - - {{item.seriesName}} - - - - {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} - - + + + @switch (item.scrobbleEventType) { + @case (ScrobbleEventType.ChapterRead) { + @if(item.volumeNumber === LooseLeafOrDefaultNumber) { + @if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('special')}} + } @else { + {{t('chapter-num', {num: item.chapterNumber})}} + } + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('volume-num', {num: item.volumeNumber})}} + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { + Special + } + @else { + {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} + } + } + @case (ScrobbleEventType.ScoreUpdated) { {{t('rating', {r: item.rating})}} - - + } + @default { {{t('not-applicable')}} - - - + } + } + + + + + + {{t('is-processed-header')}} + + @if(item.isProcessed) { } @else if (item.isErrored) { @@ -81,10 +114,10 @@ } - {{item.isProcessed ? t('processed') : t('not-processed')}} - -
+ {{item.isProcessed ? t('processed') : t('not-processed')}} + + + + + diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss index 7c4315507..bf691441b 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.scss @@ -5,3 +5,8 @@ .error { color: var(--error-color); } + +.custom-position { + right: 15px; + top: -42px; +} diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index ed1f76808..c0306c4cf 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -1,91 +1,128 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; -import {CommonModule} from '@angular/common'; import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event"; -import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe"; -import {NgbPagination, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {ScrobbleEventTypePipe} from "../../_pipes/scrobble-event-type.pipe"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {ScrobbleEventSortField} from "../../_models/scrobbling/scrobble-event-filter"; import {debounceTime, take} from "rxjs/operators"; -import {PaginatedResult, Pagination} from "../../_models/pagination"; -import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive"; +import {PaginatedResult} from "../../_models/pagination"; +import {SortEvent} from "../table/_directives/sortable-header.directive"; import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; -import {translate, TranslocoModule} from "@ngneat/transloco"; +import {translate, TranslocoModule} from "@jsverse/transloco"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; -import {TranslocoLocaleModule} from "@ngneat/transloco-locale"; +import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; +import {LooseLeafOrDefaultNumber, SpecialVolumeNumber} from "../../_models/chapter"; +import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {AsyncPipe} from "@angular/common"; +import {AccountService} from "../../_services/account.service"; import {ToastrService} from "ngx-toastr"; +export interface DataTablePage { + pageNumber: number, + size: number, + totalElements: number, + totalPages: number +} + @Component({ - selector: 'app-user-scrobble-history', - standalone: true, - imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip], - templateUrl: './user-scrobble-history.component.html', - styleUrls: ['./user-scrobble-history.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-user-scrobble-history', + imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule, + DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe], + templateUrl: './user-scrobble-history.component.html', + styleUrls: ['./user-scrobble-history.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class UserScrobbleHistoryComponent implements OnInit { + protected readonly SpecialVolumeNumber = SpecialVolumeNumber; + protected readonly LooseLeafOrDefaultNumber = LooseLeafOrDefaultNumber; + protected readonly ColumnMode = ColumnMode; + protected readonly ScrobbleEventType = ScrobbleEventType; + private readonly scrobblingService = inject(ScrobblingService); private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); private readonly toastr = inject(ToastrService); - protected readonly ScrobbleEventType = ScrobbleEventType; + protected readonly accountService = inject(AccountService); - pagination: Pagination | undefined; - events: Array = []; + + + tokenExpired = false; formGroup: FormGroup = new FormGroup({ 'filter': new FormControl('', []) }); + events: Array = []; + isLoading: boolean = true; + pageInfo: DataTablePage = { + pageNumber: 0, + size: 10, + totalElements: 0, + totalPages: 0 + } + private currentSort: SortEvent = { + column: 'lastModifiedUtc', + direction: 'desc' + }; + hasRunScrobbleGen: boolean = false; ngOnInit() { - this.loadPage({column: 'createdUtc', direction: 'desc'}); + + this.pageInfo.pageNumber = 0; + this.cdRef.markForCheck(); + + this.scrobblingService.hasRunScrobbleGen().subscribe(res => { + this.hasRunScrobbleGen = res; + this.cdRef.markForCheck(); + }) this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => { - if (hasExpired) { - this.toastr.error(translate('toasts.anilist-token-expired')); - } + this.tokenExpired = hasExpired; this.cdRef.markForCheck(); }); this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => { this.loadPage(); - }) + }); + + this.loadPage(this.currentSort); } - onPageChange(pageNum: number) { - let prevPage = 0; - if (this.pagination) { - prevPage = this.pagination.currentPage; - this.pagination.currentPage = pageNum; - } - if (prevPage !== pageNum) { - this.loadPage(); - } + onPageChange(pageInfo: any) { + this.pageInfo.pageNumber = pageInfo.offset; + this.cdRef.markForCheck(); + this.loadPage(this.currentSort); } - updateSort(sortEvent: SortEvent) { - this.loadPage(sortEvent); + updateSort(data: any) { + this.currentSort = { + column: data.column.prop, + direction: data.newValue + }; } loadPage(sortEvent?: SortEvent) { - if (sortEvent && this.pagination) { - this.pagination.currentPage = 1; - this.cdRef.markForCheck(); - } - const page = this.pagination?.currentPage || 0; - const pageSize = this.pagination?.itemsPerPage || 0; + const page = (this.pageInfo?.pageNumber || 0) + 1; + const pageSize = this.pageInfo?.size || 0; const isDescending = sortEvent?.direction === 'desc'; const field = this.mapSortColumnField(sortEvent?.column); const query = this.formGroup.get('filter')?.value; + this.isLoading = true; + this.cdRef.markForCheck(); + this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize) .pipe(take(1)) .subscribe((result: PaginatedResult) => { this.events = result.result; - this.pagination = result.pagination; + + this.pageInfo.totalPages = result.pagination.totalPages - 1; // ngx-datatable is 0 based, Kavita is 1 based + this.pageInfo.size = result.pagination.itemsPerPage; + this.pageInfo.totalElements = result.pagination.totalItems; + this.isLoading = false; this.cdRef.markForCheck(); }); } @@ -96,9 +133,14 @@ export class UserScrobbleHistoryComponent implements OnInit { case 'isProcessed': return ScrobbleEventSortField.IsProcessed; case 'lastModifiedUtc': return ScrobbleEventSortField.LastModified; case 'seriesName': return ScrobbleEventSortField.Series; + case 'scrobbleEventType': return ScrobbleEventSortField.ScrobbleEvent; } return ScrobbleEventSortField.None; } - + generateScrobbleEvents() { + this.scrobblingService.triggerScrobbleEventGeneration().subscribe(_ => { + this.toastr.info(translate('toasts.scrobble-gen-init')) + }); + } } diff --git a/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.html b/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.html new file mode 100644 index 000000000..f0056050b --- /dev/null +++ b/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.html @@ -0,0 +1,23 @@ + + + + + + + diff --git a/API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v04.zip b/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.scss similarity index 100% rename from API.Tests/Services/Test Data/ScannerService/Manga/A Town Where You Live/A_Town_Where_You_Live_v04.zip rename to UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.scss diff --git a/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.ts b/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.ts new file mode 100644 index 000000000..b8a1b41ea --- /dev/null +++ b/UI/Web/src/app/admin/_modals/copy-settings-from-library-modal/copy-settings-from-library-modal.component.ts @@ -0,0 +1,29 @@ +import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {Library} from "../../../_models/library/library"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; + +@Component({ + selector: 'app-copy-settings-from-library-modal', + imports: [ + TranslocoDirective, + ReactiveFormsModule, + ], + templateUrl: './copy-settings-from-library-modal.component.html', + styleUrl: './copy-settings-from-library-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CopySettingsFromLibraryModalComponent { + protected readonly modal = inject(NgbActiveModal); + + @Input() libraries: Array = []; + + libForm = new FormGroup({ + 'library': new FormControl(null), + }); + + save() { + this.modal.close(parseInt(this.libForm.get('library')?.value + '', 10)); + } +} diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss index bbe577134..a086f4ecf 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.scss @@ -14,12 +14,8 @@ $breadcrumb-divider: quote(">"); border: 1px solid #ced4da; } -.table { - background-color: lightgrey; -} - .disabled { color: lightgrey !important; cursor: not-allowed !important; background-color: var(--error-color); -} \ No newline at end of file +} diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index 2340bdba5..a1c97ee70 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -6,7 +6,8 @@ import { DirectoryDto } from 'src/app/_models/system/directory-dto'; import { LibraryService } from '../../../_services/library.service'; import { NgIf, NgFor, NgClass } from '@angular/common'; import { ReactiveFormsModule, FormsModule } from '@angular/forms'; -import {TranslocoDirective} from "@ngneat/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {WikiLink} from "../../../_models/wiki"; export interface DirectoryPickerResult { @@ -15,11 +16,10 @@ export interface DirectoryPickerResult { } @Component({ - selector: 'app-directory-picker', - templateUrl: './directory-picker.component.html', - styleUrls: ['./directory-picker.component.scss'], - standalone: true, - imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass, TranslocoDirective] + selector: 'app-directory-picker', + templateUrl: './directory-picker.component.html', + styleUrls: ['./directory-picker.component.scss'], + imports: [ReactiveFormsModule, NgbTypeahead, FormsModule, NgbHighlight, NgIf, NgFor, NgClass, TranslocoDirective] }) export class DirectoryPickerComponent implements OnInit { @@ -27,7 +27,7 @@ export class DirectoryPickerComponent implements OnInit { /** * Url to give more information about selecting directories. Passing nothing will suppress. */ - @Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/first-time-setup#adding-a-library-to-kavita'; + @Input() helpUrl: string = WikiLink.Library; currentRoot = ''; folders: DirectoryDto[] = []; diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html index a205bd4f9..1cb11d21a 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.html @@ -14,16 +14,19 @@
    -
  • -
    - - -
    -
  • -
  • - {{t('no-data')}} -
  • + @for (library of allLibraries; track library.name; let i = $index) { +
  • +
    + + +
    +
  • + } @empty { +
  • + {{t('no-data')}} +
  • + }
diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index 6d1ddd105..3eb8c080c 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -3,34 +3,35 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {Library} from 'src/app/_models/library/library'; import {Member} from 'src/app/_models/auth/member'; import {LibraryService} from 'src/app/_services/library.service'; -import {SelectionModel} from 'src/app/typeahead/_components/typeahead.component'; -import {NgFor, NgIf} from '@angular/common'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; -import {TranslocoDirective} from "@ngneat/transloco"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {SelectionModel} from "../../../typeahead/_models/selection-model"; @Component({ selector: 'app-library-access-modal', templateUrl: './library-access-modal.component.html', styleUrls: ['./library-access-modal.component.scss'], standalone: true, - imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf, TranslocoDirective], + imports: [ReactiveFormsModule, FormsModule, TranslocoDirective], changeDetection: ChangeDetectionStrategy.OnPush }) export class LibraryAccessModalComponent implements OnInit { + protected readonly modal = inject(NgbActiveModal); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly libraryService = inject(LibraryService); + @Input() member: Member | undefined; + allLibraries: Library[] = []; selectedLibraries: Array<{selected: boolean, data: Library}> = []; selections!: SelectionModel; selectAll: boolean = false; - cdRef = inject(ChangeDetectorRef); - get hasSomeSelected() { return this.selections != null && this.selections.hasSomeSelected(); } - constructor(public modal: NgbActiveModal, private libraryService: LibraryService) { } ngOnInit(): void { this.libraryService.getLibraries().subscribe(libs => { diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html index 716f95e78..258503e94 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html @@ -7,9 +7,12 @@