Merged develop in
This commit is contained in:
commit
5423526484
260 changed files with 15553 additions and 2369 deletions
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -75,13 +75,13 @@ body:
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: mobile-browsers
|
id: mobile-browsers
|
||||||
attributes:
|
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
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- Firefox
|
- Firefox
|
||||||
- Chrome
|
- Chrome
|
||||||
- Safari
|
- Safari
|
||||||
- Microsoft Edge
|
- Other iOS Browser
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
|
|
6
.github/workflows/build-and-test.yml
vendored
6
.github/workflows/build-and-test.yml
vendored
|
@ -10,12 +10,12 @@ jobs:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: csproj
|
name: csproj
|
||||||
path: Kavita.Common/Kavita.Common.csproj
|
path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
|
28
.github/workflows/canary-workflow.yml
vendored
28
.github/workflows/canary-workflow.yml
vendored
|
@ -12,11 +12,11 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: csproj
|
name: csproj
|
||||||
path: Kavita.Common/Kavita.Common.csproj
|
path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
@ -26,12 +26,12 @@ jobs:
|
||||||
needs: [ build ]
|
needs: [ build ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
@ -59,14 +59,14 @@ jobs:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Check Out Repo
|
- name: Check Out Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: canary
|
ref: canary
|
||||||
|
|
||||||
- name: NodeJS to Compile WebUI
|
- name: NodeJS to Compile WebUI
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18.13.x'
|
node-version: 20
|
||||||
- run: |
|
- run: |
|
||||||
cd UI/Web || exit
|
cd UI/Web || exit
|
||||||
echo 'Installing web dependencies'
|
echo 'Installing web dependencies'
|
||||||
|
@ -81,7 +81,7 @@ jobs:
|
||||||
cd ../ || exit
|
cd ../ || exit
|
||||||
|
|
||||||
- name: Get csproj Version
|
- 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
|
id: get-version
|
||||||
with:
|
with:
|
||||||
proj-path: Kavita.Common/Kavita.Common.csproj
|
proj-path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
@ -96,7 +96,7 @@ jobs:
|
||||||
run: echo "${{steps.get-version.outputs.assembly-version}}"
|
run: echo "${{steps.get-version.outputs.assembly-version}}"
|
||||||
|
|
||||||
- name: Compile dotnet app
|
- name: Compile dotnet app
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
@ -106,28 +106,28 @@ jobs:
|
||||||
- run: ./monorepo-build.sh
|
- run: ./monorepo-build.sh
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
|
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
@ -46,7 +46,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Swashbuckle CLI
|
- name: Install Swashbuckle CLI
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
37
.github/workflows/develop-workflow.yml
vendored
37
.github/workflows/develop-workflow.yml
vendored
|
@ -2,10 +2,7 @@ name: Nightly Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['!release/**']
|
|
||||||
pull_request:
|
|
||||||
branches: [ 'develop', '!release/**' ]
|
branches: [ 'develop', '!release/**' ]
|
||||||
types: [ closed ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -21,14 +18,14 @@ jobs:
|
||||||
build:
|
build:
|
||||||
name: Upload Kavita.Common for Version Bump
|
name: Upload Kavita.Common for Version Bump
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release')
|
if: github.ref == 'refs/heads/develop'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: csproj
|
name: csproj
|
||||||
path: Kavita.Common/Kavita.Common.csproj
|
path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
@ -37,14 +34,14 @@ jobs:
|
||||||
name: Bump version
|
name: Bump version
|
||||||
needs: [ build ]
|
needs: [ build ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release')
|
if: github.ref == 'refs/heads/develop'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET Core
|
- name: Setup .NET Core
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
@ -59,7 +56,7 @@ jobs:
|
||||||
name: Build Nightly Docker
|
name: Build Nightly Docker
|
||||||
needs: [ build, version ]
|
needs: [ build, version ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event.pull_request.merged == true && !contains(github.head_ref, 'release')
|
if: github.ref == 'refs/heads/develop'
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
@ -92,14 +89,14 @@ jobs:
|
||||||
echo "BODY=$body" >> $GITHUB_OUTPUT
|
echo "BODY=$body" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Check Out Repo
|
- name: Check Out Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: develop
|
ref: develop
|
||||||
|
|
||||||
- name: NodeJS to Compile WebUI
|
- name: NodeJS to Compile WebUI
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18.13.x'
|
node-version: 20
|
||||||
- run: |
|
- run: |
|
||||||
cd UI/Web || exit
|
cd UI/Web || exit
|
||||||
echo 'Installing web dependencies'
|
echo 'Installing web dependencies'
|
||||||
|
@ -114,7 +111,7 @@ jobs:
|
||||||
cd ../ || exit
|
cd ../ || exit
|
||||||
|
|
||||||
- name: Get csproj Version
|
- 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
|
id: get-version
|
||||||
with:
|
with:
|
||||||
proj-path: Kavita.Common/Kavita.Common.csproj
|
proj-path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
@ -129,7 +126,7 @@ jobs:
|
||||||
run: echo "${{steps.get-version.outputs.assembly-version}}"
|
run: echo "${{steps.get-version.outputs.assembly-version}}"
|
||||||
|
|
||||||
- name: Compile dotnet app
|
- name: Compile dotnet app
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
@ -139,28 +136,28 @@ jobs:
|
||||||
- run: ./monorepo-build.sh
|
- run: ./monorepo-build.sh
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
|
26
.github/workflows/release-workflow.yml
vendored
26
.github/workflows/release-workflow.yml
vendored
|
@ -30,11 +30,11 @@ jobs:
|
||||||
if: github.event.pull_request.merged == true && contains(github.head_ref, 'release')
|
if: github.event.pull_request.merged == true && contains(github.head_ref, 'release')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v3
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: csproj
|
name: csproj
|
||||||
path: Kavita.Common/Kavita.Common.csproj
|
path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
@ -77,14 +77,14 @@ jobs:
|
||||||
echo "BODY=$body" >> $GITHUB_OUTPUT
|
echo "BODY=$body" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Check Out Repo
|
- name: Check Out Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: develop
|
ref: develop
|
||||||
|
|
||||||
- name: NodeJS to Compile WebUI
|
- name: NodeJS to Compile WebUI
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18.13.x'
|
node-version: 20
|
||||||
- run: |
|
- run: |
|
||||||
|
|
||||||
cd UI/Web || exit
|
cd UI/Web || exit
|
||||||
|
@ -100,7 +100,7 @@ jobs:
|
||||||
cd ../ || exit
|
cd ../ || exit
|
||||||
|
|
||||||
- name: Get csproj Version
|
- 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
|
id: get-version
|
||||||
with:
|
with:
|
||||||
proj-path: Kavita.Common/Kavita.Common.csproj
|
proj-path: Kavita.Common/Kavita.Common.csproj
|
||||||
|
@ -117,7 +117,7 @@ jobs:
|
||||||
id: parse-version
|
id: parse-version
|
||||||
|
|
||||||
- name: Compile dotnet app
|
- name: Compile dotnet app
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 8.0.x
|
||||||
- name: Install Swashbuckle CLI
|
- name: Install Swashbuckle CLI
|
||||||
|
@ -126,28 +126,28 @@ jobs:
|
||||||
- run: ./monorepo-build.sh
|
- run: ./monorepo-build.sh
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build and push stable
|
- name: Build and push stable
|
||||||
id: docker_build_stable
|
id: docker_build_stable
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
@ -156,7 +156,7 @@ jobs:
|
||||||
|
|
||||||
- name: Build and push nightly
|
- name: Build and push nightly
|
||||||
id: docker_build_nightly
|
id: docker_build_nightly
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -520,6 +520,7 @@ UI/Web/dist/
|
||||||
/API/config/*.db
|
/API/config/*.db
|
||||||
/API/config/*.bak
|
/API/config/*.bak
|
||||||
/API/config/*.backup
|
/API/config/*.backup
|
||||||
|
/API/config/*.csv
|
||||||
/API/config/Hangfire.db
|
/API/config/Hangfire.db
|
||||||
/API/config/Hangfire-log.db
|
/API/config/Hangfire-log.db
|
||||||
API/config/covers/
|
API/config/covers/
|
||||||
|
|
15
.sonarcloud.properties
Normal file
15
.sonarcloud.properties
Normal file
|
@ -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=
|
|
@ -9,8 +9,8 @@
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.3" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.3" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="20.0.28" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="20.0.28" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.2" />
|
||||||
<PackageReference Include="xunit" Version="2.7.0" />
|
<PackageReference Include="xunit" Version="2.7.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
|
@ -10,6 +10,7 @@ using API.Helpers;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
@ -47,6 +48,7 @@ public abstract class AbstractDbTest
|
||||||
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||||
var mapper = config.CreateMapper();
|
var mapper = config.CreateMapper();
|
||||||
|
|
||||||
|
|
||||||
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,17 +45,17 @@ public class QueryableExtensionsTests
|
||||||
[InlineData(false, 1)]
|
[InlineData(false, 1)]
|
||||||
public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
|
public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount)
|
||||||
{
|
{
|
||||||
var items = new List<CollectionTag>()
|
var items = new List<AppUserCollection>()
|
||||||
{
|
{
|
||||||
new CollectionTagBuilder("Test")
|
new AppUserCollectionBuilder("Test")
|
||||||
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build())
|
.WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build())
|
||||||
.Build(),
|
.Build(),
|
||||||
new CollectionTagBuilder("Test 2")
|
new AppUserCollectionBuilder("Test 2")
|
||||||
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build())
|
.WithItem(new SeriesBuilder("S2").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()).Build())
|
||||||
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build())
|
.WithItem(new SeriesBuilder("S1").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()).Build())
|
||||||
.Build(),
|
.Build(),
|
||||||
new CollectionTagBuilder("Test 3")
|
new AppUserCollectionBuilder("Test 3")
|
||||||
.WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build())
|
.WithItem(new SeriesBuilder("S3").WithMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()).Build())
|
||||||
.Build(),
|
.Build(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -123,7 +123,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
FullFilePath = filepath
|
||||||
});
|
});
|
||||||
|
|
||||||
filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip";
|
filepath = @"E:/Manga/Beelzebub/Beelzebub_01_[Noodles].zip";
|
||||||
expected.Add(filepath, new ParserInfo
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Beelzebub", Volumes = Parser.LooseLeafVolume,
|
Series = "Beelzebub", Volumes = Parser.LooseLeafVolume,
|
||||||
|
@ -132,7 +132,7 @@ public class DefaultParserTests
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Lots of duplicates here. I think I can move them to the ParserTests itself
|
// Note: Lots of duplicates here. I think I can move them to the ParserTests itself
|
||||||
filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip";
|
filepath = @"E:/Manga/Ichinensei ni Nacchattara/Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip";
|
||||||
expected.Add(filepath, new ParserInfo
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Ichinensei ni Nacchattara", Volumes = "1",
|
Series = "Ichinensei ni Nacchattara", Volumes = "1",
|
||||||
|
@ -140,7 +140,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "",
|
Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "",
|
||||||
|
@ -148,7 +148,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "",
|
Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "",
|
||||||
|
@ -156,7 +156,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Dorohedoro", Volumes = "1", Edition = "",
|
Series = "Dorohedoro", Volumes = "1", Edition = "",
|
||||||
|
@ -164,7 +164,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "APOSIMZ", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "APOSIMZ", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
@ -172,7 +172,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Kedouin Makoto - Corpse Party Musume", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "Kedouin Makoto - Corpse Party Musume", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
@ -180,7 +180,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Goblin Slayer - Brand New Day", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "Goblin Slayer - Brand New Day", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
@ -188,7 +188,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Summer Time Rendering", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, Edition = "",
|
Series = "Summer Time Rendering", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, Edition = "",
|
||||||
|
@ -196,7 +196,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = true
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Seraph of the End - Vampire Reign", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "Seraph of the End - Vampire Reign", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
@ -204,7 +204,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Kono Subarashii Sekai ni Bakuen wo!", Volumes = "0", Edition = "",
|
Series = "Kono Subarashii Sekai ni Bakuen wo!", Volumes = "0", Edition = "",
|
||||||
|
@ -212,7 +212,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Toukyou Akazukin", Volumes = "1", Edition = "",
|
Series = "Toukyou Akazukin", Volumes = "1", Edition = "",
|
||||||
|
@ -221,10 +221,10 @@ public class DefaultParserTests
|
||||||
});
|
});
|
||||||
|
|
||||||
// If an image is cover exclusively, ignore it
|
// 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);
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "The Beginning After the End", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "The Beginning After the End", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
@ -232,7 +232,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Air Gear", Volumes = "1", Edition = "Omnibus",
|
Series = "Air Gear", Volumes = "1", Edition = "Omnibus",
|
||||||
|
@ -240,7 +240,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "",
|
Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "",
|
||||||
|
@ -279,17 +279,17 @@ public class DefaultParserTests
|
||||||
//[Fact]
|
//[Fact]
|
||||||
public void Parse_ParseInfo_Manga_ImageOnly()
|
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
|
// 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
|
var expectedInfo2 = new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Monster #8", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "Monster #8", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
|
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
|
||||||
FullFilePath = filepath, IsSpecial = false
|
FullFilePath = filepath, IsSpecial = false
|
||||||
};
|
};
|
||||||
var actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Manga, null);
|
var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null);
|
||||||
Assert.NotNull(actual2);
|
Assert.NotNull(actual2);
|
||||||
_testOutputHelper.WriteLine($"Validating {filepath}");
|
_testOutputHelper.WriteLine($"Validating {filepath}");
|
||||||
Assert.Equal(expectedInfo2.Format, actual2.Format);
|
Assert.Equal(expectedInfo2.Format, actual2.Format);
|
||||||
|
@ -307,7 +307,7 @@ public class DefaultParserTests
|
||||||
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
|
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
|
||||||
_testOutputHelper.WriteLine("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
|
expectedInfo2 = new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Just Images the second", Volumes = "19", Edition = "",
|
Series = "Just Images the second", Volumes = "19", Edition = "",
|
||||||
|
@ -315,7 +315,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
FullFilePath = filepath, IsSpecial = false
|
||||||
};
|
};
|
||||||
|
|
||||||
actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga",LibraryType.Manga, null);
|
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null);
|
||||||
Assert.NotNull(actual2);
|
Assert.NotNull(actual2);
|
||||||
_testOutputHelper.WriteLine($"Validating {filepath}");
|
_testOutputHelper.WriteLine($"Validating {filepath}");
|
||||||
Assert.Equal(expectedInfo2.Format, actual2.Format);
|
Assert.Equal(expectedInfo2.Format, actual2.Format);
|
||||||
|
@ -333,7 +333,7 @@ public class DefaultParserTests
|
||||||
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
|
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
|
||||||
_testOutputHelper.WriteLine("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
|
expectedInfo2 = new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Just Images the second", Volumes = "19", Edition = "",
|
Series = "Just Images the second", Volumes = "19", Edition = "",
|
||||||
|
@ -341,7 +341,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
FullFilePath = filepath, IsSpecial = false
|
||||||
};
|
};
|
||||||
|
|
||||||
actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Manga, null);
|
actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null);
|
||||||
Assert.NotNull(actual2);
|
Assert.NotNull(actual2);
|
||||||
_testOutputHelper.WriteLine($"Validating {filepath}");
|
_testOutputHelper.WriteLine($"Validating {filepath}");
|
||||||
Assert.Equal(expectedInfo2.Format, actual2.Format);
|
Assert.Equal(expectedInfo2.Format, actual2.Format);
|
||||||
|
@ -448,7 +448,7 @@ public class DefaultParserTests
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fallback test with bad naming
|
// 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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Babe", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "Babe", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
@ -456,7 +456,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Batman the Detective", Volumes = "6", Edition = "",
|
Series = "Batman the Detective", Volumes = "6", Edition = "",
|
||||||
|
@ -464,7 +464,7 @@ public class DefaultParserTests
|
||||||
FullFilePath = filepath, IsSpecial = false
|
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
|
expected.Add(filepath, new ParserInfo
|
||||||
{
|
{
|
||||||
Series = "Batman - The Man Who Laughs", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
Series = "Batman - The Man Who Laughs", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
|
||||||
|
|
|
@ -78,6 +78,8 @@ public class ComicParsingTests
|
||||||
[InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")]
|
[InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")]
|
||||||
[InlineData("Kebab Том 1 Глава 1", "Kebab")]
|
[InlineData("Kebab Том 1 Глава 1", "Kebab")]
|
||||||
[InlineData("Манга Глава 1", "Манга")]
|
[InlineData("Манга Глава 1", "Манга")]
|
||||||
|
[InlineData("ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก เล่ม 1", "ReZero รีเซทชีวิต ฝ่าวิกฤตต่างโลก")]
|
||||||
|
[InlineData("SKY WORLD สกายเวิลด์ เล่มที่ 1", "SKY WORLD สกายเวิลด์")]
|
||||||
public void ParseComicSeriesTest(string filename, string expected)
|
public void ParseComicSeriesTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename));
|
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename));
|
||||||
|
@ -129,6 +131,9 @@ public class ComicParsingTests
|
||||||
// Russian Tests
|
// Russian Tests
|
||||||
[InlineData("Kebab Том 1 Глава 3", "1")]
|
[InlineData("Kebab Том 1 Глава 3", "1")]
|
||||||
[InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)]
|
[InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)]
|
||||||
|
[InlineData("ย้อนเวลากลับมาร้าย เล่ม 1", "1")]
|
||||||
|
[InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "1")]
|
||||||
|
[InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)]
|
||||||
public void ParseComicVolumeTest(string filename, string expected)
|
public void ParseComicVolumeTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename));
|
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename));
|
||||||
|
@ -178,6 +183,9 @@ public class ComicParsingTests
|
||||||
[InlineData("Манга Глава 2", "2")]
|
[InlineData("Манга Глава 2", "2")]
|
||||||
[InlineData("Манга 2 Глава", "2")]
|
[InlineData("Манга 2 Глава", "2")]
|
||||||
[InlineData("Манга Том 1 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)
|
public void ParseComicChapterTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename));
|
Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(filename));
|
||||||
|
|
|
@ -206,6 +206,10 @@ public class MangaParsingTests
|
||||||
[InlineData("test 2 years 1권", "test 2 years")]
|
[InlineData("test 2 years 1권", "test 2 years")]
|
||||||
[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("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.30 Omake", "Nagasarete Airantou")]
|
||||||
|
[InlineData("Cynthia The Mission - c000 - c006 (v06)", "Cynthia The Mission")]
|
||||||
|
[InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1", "เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท")]
|
||||||
|
[InlineData("Max Level Returner เล่มที่ 5", "Max Level Returner")]
|
||||||
|
[InlineData("หนึ่งความคิด นิจนิรันดร์ เล่ม 2", "หนึ่งความคิด นิจนิรันดร์")]
|
||||||
public void ParseSeriesTest(string filename, string expected)
|
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));
|
||||||
|
@ -295,6 +299,9 @@ public class MangaParsingTests
|
||||||
[InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")]
|
[InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")]
|
||||||
[InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")]
|
[InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")]
|
||||||
[InlineData("Adabana c00-02", "0-2")]
|
[InlineData("Adabana c00-02", "0-2")]
|
||||||
|
[InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "3")]
|
||||||
|
[InlineData("Max Level Returner ตอนที่ 5", "5")]
|
||||||
|
[InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")]
|
||||||
public void ParseChaptersTest(string filename, string expected)
|
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));
|
||||||
|
|
|
@ -114,65 +114,65 @@ public class CollectionTagRepositoryTests
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region RemoveTagsWithoutSeries
|
// #region RemoveTagsWithoutSeries
|
||||||
|
//
|
||||||
[Fact]
|
// [Fact]
|
||||||
public async Task RemoveTagsWithoutSeries_ShouldRemoveTags()
|
// public async Task RemoveTagsWithoutSeries_ShouldRemoveTags()
|
||||||
{
|
// {
|
||||||
var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
|
// var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
|
||||||
var series = new SeriesBuilder("Test 1").Build();
|
// var series = new SeriesBuilder("Test 1").Build();
|
||||||
var commonTag = new CollectionTagBuilder("Tag 1").Build();
|
// var commonTag = new AppUserCollectionBuilder("Tag 1").Build();
|
||||||
series.Metadata.CollectionTags.Add(commonTag);
|
// series.Metadata.CollectionTags.Add(commonTag);
|
||||||
series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build());
|
// series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build());
|
||||||
|
//
|
||||||
var series2 = new SeriesBuilder("Test 1").Build();
|
// var series2 = new SeriesBuilder("Test 1").Build();
|
||||||
series2.Metadata.CollectionTags.Add(commonTag);
|
// series2.Metadata.CollectionTags.Add(commonTag);
|
||||||
library.Series.Add(series);
|
// library.Series.Add(series);
|
||||||
library.Series.Add(series2);
|
// library.Series.Add(series2);
|
||||||
_unitOfWork.LibraryRepository.Add(library);
|
// _unitOfWork.LibraryRepository.Add(library);
|
||||||
await _unitOfWork.CommitAsync();
|
// await _unitOfWork.CommitAsync();
|
||||||
|
//
|
||||||
Assert.Equal(2, series.Metadata.CollectionTags.Count);
|
// Assert.Equal(2, series.Metadata.CollectionTags.Count);
|
||||||
Assert.Single(series2.Metadata.CollectionTags);
|
// Assert.Single(series2.Metadata.CollectionTags);
|
||||||
|
//
|
||||||
// Delete both series
|
// // Delete both series
|
||||||
_unitOfWork.SeriesRepository.Remove(series);
|
// _unitOfWork.SeriesRepository.Remove(series);
|
||||||
_unitOfWork.SeriesRepository.Remove(series2);
|
// _unitOfWork.SeriesRepository.Remove(series2);
|
||||||
|
//
|
||||||
await _unitOfWork.CommitAsync();
|
// await _unitOfWork.CommitAsync();
|
||||||
|
//
|
||||||
// Validate that both tags exist
|
// // Validate that both tags exist
|
||||||
Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
|
// Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
|
||||||
|
//
|
||||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
// await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||||
|
//
|
||||||
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
|
// Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
[Fact]
|
// [Fact]
|
||||||
public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags()
|
// public async Task RemoveTagsWithoutSeries_ShouldNotRemoveTags()
|
||||||
{
|
// {
|
||||||
var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
|
// var library = new LibraryBuilder("Test", LibraryType.Manga).Build();
|
||||||
var series = new SeriesBuilder("Test 1").Build();
|
// var series = new SeriesBuilder("Test 1").Build();
|
||||||
var commonTag = new CollectionTagBuilder("Tag 1").Build();
|
// var commonTag = new AppUserCollectionBuilder("Tag 1").Build();
|
||||||
series.Metadata.CollectionTags.Add(commonTag);
|
// series.Metadata.CollectionTags.Add(commonTag);
|
||||||
series.Metadata.CollectionTags.Add(new CollectionTagBuilder("Tag 2").Build());
|
// series.Metadata.CollectionTags.Add(new AppUserCollectionBuilder("Tag 2").Build());
|
||||||
|
//
|
||||||
var series2 = new SeriesBuilder("Test 1").Build();
|
// var series2 = new SeriesBuilder("Test 1").Build();
|
||||||
series2.Metadata.CollectionTags.Add(commonTag);
|
// series2.Metadata.CollectionTags.Add(commonTag);
|
||||||
library.Series.Add(series);
|
// library.Series.Add(series);
|
||||||
library.Series.Add(series2);
|
// library.Series.Add(series2);
|
||||||
_unitOfWork.LibraryRepository.Add(library);
|
// _unitOfWork.LibraryRepository.Add(library);
|
||||||
await _unitOfWork.CommitAsync();
|
// await _unitOfWork.CommitAsync();
|
||||||
|
//
|
||||||
Assert.Equal(2, series.Metadata.CollectionTags.Count);
|
// Assert.Equal(2, series.Metadata.CollectionTags.Count);
|
||||||
Assert.Single(series2.Metadata.CollectionTags);
|
// Assert.Single(series2.Metadata.CollectionTags);
|
||||||
|
//
|
||||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
// await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||||
|
//
|
||||||
// Validate that both tags exist
|
// // Validate that both tags exist
|
||||||
Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
|
// Assert.Equal(2, (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).Count());
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
#endregion
|
// #endregion
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,12 +52,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
|
||||||
throw new System.NotImplementedException();
|
throw new System.NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParserInfo Parse(string path, string rootPath, string libraryRoot, Library library)
|
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
|
||||||
{
|
{
|
||||||
throw new System.NotImplementedException();
|
throw new System.NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, Library library)
|
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
|
||||||
{
|
{
|
||||||
throw new System.NotImplementedException();
|
throw new System.NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
|
@ -167,53 +167,53 @@ public class CleanupServiceTests : AbstractDbTest
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region DeleteTagCoverImages
|
// #region DeleteTagCoverImages
|
||||||
|
//
|
||||||
[Fact]
|
// [Fact]
|
||||||
public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles()
|
// public async Task DeleteTagCoverImages_ShouldNotDeleteLinkedFiles()
|
||||||
{
|
// {
|
||||||
var filesystem = CreateFileSystem();
|
// var filesystem = CreateFileSystem();
|
||||||
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData(""));
|
// filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1)}.jpg", new MockFileData(""));
|
||||||
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData(""));
|
// filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(2)}.jpg", new MockFileData(""));
|
||||||
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
|
// filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
|
||||||
|
//
|
||||||
// Delete all Series to reset state
|
// // Delete all Series to reset state
|
||||||
await ResetDb();
|
// await ResetDb();
|
||||||
|
//
|
||||||
// Add 2 series with cover images
|
// // Add 2 series with cover images
|
||||||
|
//
|
||||||
_context.Series.Add(new SeriesBuilder("Test 1")
|
// _context.Series.Add(new SeriesBuilder("Test 1")
|
||||||
.WithMetadata(new SeriesMetadataBuilder()
|
// .WithMetadata(new SeriesMetadataBuilder()
|
||||||
.WithCollectionTag(new CollectionTagBuilder("Something")
|
// .WithCollectionTag(new AppUserCollectionBuilder("Something")
|
||||||
.WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg")
|
// .WithCoverImage($"{ImageService.GetCollectionTagFormat(1)}.jpg")
|
||||||
.Build())
|
// .Build())
|
||||||
.Build())
|
// .Build())
|
||||||
.WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg")
|
// .WithCoverImage($"{ImageService.GetSeriesFormat(1)}.jpg")
|
||||||
.WithLibraryId(1)
|
// .WithLibraryId(1)
|
||||||
.Build());
|
// .Build());
|
||||||
|
//
|
||||||
_context.Series.Add(new SeriesBuilder("Test 2")
|
// _context.Series.Add(new SeriesBuilder("Test 2")
|
||||||
.WithMetadata(new SeriesMetadataBuilder()
|
// .WithMetadata(new SeriesMetadataBuilder()
|
||||||
.WithCollectionTag(new CollectionTagBuilder("Something")
|
// .WithCollectionTag(new AppUserCollectionBuilder("Something")
|
||||||
.WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg")
|
// .WithCoverImage($"{ImageService.GetCollectionTagFormat(2)}.jpg")
|
||||||
.Build())
|
// .Build())
|
||||||
.Build())
|
// .Build())
|
||||||
.WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg")
|
// .WithCoverImage($"{ImageService.GetSeriesFormat(3)}.jpg")
|
||||||
.WithLibraryId(1)
|
// .WithLibraryId(1)
|
||||||
.Build());
|
// .Build());
|
||||||
|
//
|
||||||
|
//
|
||||||
await _context.SaveChangesAsync();
|
// await _context.SaveChangesAsync();
|
||||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
// var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||||
var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
|
// var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub,
|
||||||
ds);
|
// ds);
|
||||||
|
//
|
||||||
await cleanupService.DeleteTagCoverImages();
|
// await cleanupService.DeleteTagCoverImages();
|
||||||
|
//
|
||||||
Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count());
|
// Assert.Equal(2, ds.GetFiles(CoverImageDirectory).Count());
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
#endregion
|
// #endregion
|
||||||
|
|
||||||
#region DeleteReadingListCoverImages
|
#region DeleteReadingListCoverImages
|
||||||
[Fact]
|
[Fact]
|
||||||
|
@ -435,24 +435,26 @@ public class CleanupServiceTests : AbstractDbTest
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CleanupDbEntries_RemoveTagsWithoutSeries()
|
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",
|
Title = "Test Tag",
|
||||||
NormalizedTitle = "Test Tag".ToNormalized(),
|
NormalizedTitle = "Test Tag".ToNormalized(),
|
||||||
|
AgeRating = AgeRating.Unknown,
|
||||||
|
Items = new List<Series>() {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()
|
_context.AppUser.Add(new AppUser()
|
||||||
{
|
{
|
||||||
UserName = "majora2007"
|
UserName = "majora2007",
|
||||||
|
Collections = new List<AppUserCollection>() {c}
|
||||||
});
|
});
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
|
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
|
||||||
|
@ -465,7 +467,7 @@ public class CleanupServiceTests : AbstractDbTest
|
||||||
|
|
||||||
await cleanupService.CleanupDbEntries();
|
await cleanupService.CleanupDbEntries();
|
||||||
|
|
||||||
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
|
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllCollectionsAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
|
@ -3,13 +3,13 @@ using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.Collection;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using API.Tests.Helpers;
|
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ public class CollectionTagServiceTests : AbstractDbTest
|
||||||
|
|
||||||
protected override async Task ResetDb()
|
protected override async Task ResetDb()
|
||||||
{
|
{
|
||||||
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
|
_context.AppUserCollection.RemoveRange(_context.AppUserCollection.ToList());
|
||||||
_context.Library.RemoveRange(_context.Library.ToList());
|
_context.Library.RemoveRange(_context.Library.ToList());
|
||||||
|
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
@ -33,119 +33,148 @@ public class CollectionTagServiceTests : AbstractDbTest
|
||||||
|
|
||||||
private async Task SeedSeries()
|
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)
|
_context.Library.Add(new LibraryBuilder("Library 2", LibraryType.Manga)
|
||||||
.WithSeries(new SeriesBuilder("Series 1").Build())
|
.WithSeries(s1)
|
||||||
.WithSeries(new SeriesBuilder("Series 2").Build())
|
.WithSeries(s2)
|
||||||
.Build());
|
.Build());
|
||||||
|
|
||||||
_context.CollectionTag.Add(new CollectionTagBuilder("Tag 1").Build());
|
var user = new AppUserBuilder("majora2007", "majora2007", Seed.DefaultThemes.First()).Build();
|
||||||
_context.CollectionTag.Add(new CollectionTagBuilder("Tag 2").WithIsPromoted(true).Build());
|
user.Collections = new List<AppUserCollection>()
|
||||||
|
{
|
||||||
|
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();
|
await _unitOfWork.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region UpdateTag
|
||||||
[Fact]
|
|
||||||
public async Task TagExistsByName_ShouldFindTag()
|
|
||||||
{
|
|
||||||
await SeedSeries();
|
|
||||||
Assert.True(await _service.TagExistsByName("Tag 1"));
|
|
||||||
Assert.True(await _service.TagExistsByName("tag 1"));
|
|
||||||
Assert.False(await _service.TagExistsByName("tag5"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateTag_ShouldUpdateFields()
|
public async Task UpdateTag_ShouldUpdateFields()
|
||||||
{
|
{
|
||||||
await SeedSeries();
|
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 _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
await _service.UpdateTag(new CollectionTagDto()
|
await _service.UpdateTag(new AppUserCollectionDto()
|
||||||
{
|
{
|
||||||
Title = "UpdateTag_ShouldUpdateFields",
|
Title = "UpdateTag_ShouldUpdateFields",
|
||||||
Id = 3,
|
Id = 3,
|
||||||
Promoted = true,
|
Promoted = true,
|
||||||
Summary = "Test Summary",
|
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.NotNull(tag);
|
||||||
Assert.True(tag.Promoted);
|
Assert.True(tag.Promoted);
|
||||||
Assert.True(!string.IsNullOrEmpty(tag.Summary));
|
Assert.False(string.IsNullOrEmpty(tag.Summary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UpdateTag should not change any title if non-Kavita source
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddTagToSeries_ShouldAddTagToAllSeries()
|
public async Task UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource()
|
||||||
{
|
{
|
||||||
await SeedSeries();
|
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);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
|
||||||
Assert.Contains(metadatas.ElementAt(0).CollectionTags, t => t.Title.Equals("Tag 1"));
|
Assert.NotNull(user);
|
||||||
Assert.Contains(metadatas.ElementAt(1).CollectionTags, t => t.Title.Equals("Tag 1"));
|
|
||||||
|
user.Collections.Add(new AppUserCollectionBuilder("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource").WithSource(ScrobbleProvider.Mal).Build());
|
||||||
|
_unitOfWork.UserRepository.Update(user);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
await _service.UpdateTag(new AppUserCollectionDto()
|
||||||
|
{
|
||||||
|
Title = "New Title",
|
||||||
|
Id = 3,
|
||||||
|
Promoted = true,
|
||||||
|
Summary = "Test Summary",
|
||||||
|
AgeRating = AgeRating.Unknown
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(3);
|
||||||
|
Assert.NotNull(tag);
|
||||||
|
Assert.Equal("UpdateTag_ShouldNotChangeTitle_WhenNotKavitaSource", tag.Title);
|
||||||
|
Assert.False(string.IsNullOrEmpty(tag.Summary));
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
#region RemoveTagFromSeries
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveTagFromSeries_RemoveSeriesFromTag()
|
||||||
|
{
|
||||||
|
await SeedSeries();
|
||||||
|
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
|
||||||
|
Assert.NotNull(user);
|
||||||
|
|
||||||
|
// Tag 2 has 2 series
|
||||||
|
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
|
||||||
|
Assert.NotNull(tag);
|
||||||
|
|
||||||
|
await _service.RemoveTagFromSeries(tag, new[] {1});
|
||||||
|
var userCollections = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
|
||||||
|
Assert.Equal(2, userCollections!.Collections.Count);
|
||||||
|
Assert.Equal(1, tag.Items.Count);
|
||||||
|
Assert.Equal(2, tag.Items.First().Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensure the rating of the tag updates after a series change
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveTagFromSeries_ShouldRemoveMultiple()
|
public async Task RemoveTagFromSeries_RemoveSeriesFromTag_UpdatesRating()
|
||||||
{
|
{
|
||||||
await SeedSeries();
|
await SeedSeries();
|
||||||
var ids = new[] {1, 2};
|
|
||||||
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(2, CollectionTagIncludes.SeriesMetadata);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Collections);
|
||||||
await _service.AddTagToSeries(tag, ids);
|
Assert.NotNull(user);
|
||||||
|
|
||||||
|
// Tag 2 has 2 series
|
||||||
|
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(2);
|
||||||
|
Assert.NotNull(tag);
|
||||||
|
|
||||||
await _service.RemoveTagFromSeries(tag, new[] {1});
|
await _service.RemoveTagFromSeries(tag, new[] {1});
|
||||||
|
|
||||||
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
|
Assert.Equal(AgeRating.G, tag.AgeRating);
|
||||||
|
|
||||||
Assert.Single(metadatas);
|
|
||||||
Assert.Empty(metadatas.First().CollectionTags);
|
|
||||||
Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Should remove the tag when there are no items left on the tag
|
||||||
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetTagOrCreate_ShouldReturnNewTag()
|
public async Task RemoveTagFromSeries_RemoveSeriesFromTag_DeleteTagWhenNoSeriesLeft()
|
||||||
{
|
{
|
||||||
await SeedSeries();
|
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.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});
|
await _service.RemoveTagFromSeries(tag, new[] {1});
|
||||||
|
var tag2 = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(1);
|
||||||
// Validate it does remove tags it should
|
Assert.Null(tag2);
|
||||||
await _service.RemoveTagsWithoutSeries();
|
|
||||||
Assert.Null(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,14 +54,14 @@ internal class MockReadingItemService : IReadingItemService
|
||||||
throw new NotImplementedException();
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParserInfo Parse(string path, string rootPath, string libraryRoot, Library library)
|
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
|
||||||
{
|
{
|
||||||
return _defaultParser.Parse(path, rootPath, libraryRoot, library.Type);
|
return _defaultParser.Parse(path, rootPath, libraryRoot, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, Library library)
|
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
|
||||||
{
|
{
|
||||||
return _defaultParser.Parse(path, rootPath, libraryRoot, library.Type);
|
return _defaultParser.Parse(path, rootPath, libraryRoot, type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
|
@ -768,7 +768,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
SeriesId = 1,
|
SeriesId = 1,
|
||||||
Genres = new List<GenreTagDto> {new GenreTagDto {Id = 0, Title = "New Genre"}}
|
Genres = new List<GenreTagDto> {new GenreTagDto {Id = 0, Title = "New Genre"}}
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
@ -777,46 +777,6 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
Assert.NotNull(series);
|
Assert.NotNull(series);
|
||||||
Assert.NotNull(series.Metadata);
|
Assert.NotNull(series.Metadata);
|
||||||
Assert.Contains("New 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_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<GenreTagDto> {new GenreTagDto {Id = 0, Title = "New Genre"}},
|
|
||||||
Tags = new List<TagDto> {new TagDto {Id = 0, Title = "New Tag"}},
|
|
||||||
Characters = new List<PersonDto> {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Character}},
|
|
||||||
Colorists = new List<PersonDto> {new PersonDto {Id = 0, Name = "Joe Shmo", Role = PersonRole.Colorist}},
|
|
||||||
Pencillers = new List<PersonDto> {new PersonDto {Id = 0, Name = "Joe Shmo 2", Role = PersonRole.Penciller}},
|
|
||||||
},
|
|
||||||
CollectionTags = new List<CollectionTagDto>
|
|
||||||
{
|
|
||||||
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]
|
[Fact]
|
||||||
|
@ -842,7 +802,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
SeriesId = 1,
|
SeriesId = 1,
|
||||||
Genres = new List<GenreTagDto> {new () {Id = 0, Title = "New Genre"}},
|
Genres = new List<GenreTagDto> {new () {Id = 0, Title = "New Genre"}},
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
@ -875,7 +835,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
SeriesId = 1,
|
SeriesId = 1,
|
||||||
Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
|
Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
@ -911,7 +871,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
|
Publishers = new List<PersonDto> {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}},
|
||||||
PublisherLocked = true
|
PublisherLocked = true
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
@ -944,7 +904,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
SeriesId = 1,
|
SeriesId = 1,
|
||||||
Publishers = new List<PersonDto>(),
|
Publishers = new List<PersonDto>(),
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
@ -978,7 +938,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
Genres = new List<GenreTagDto> {new () {Id = 1, Title = "Existing Genre"}},
|
Genres = new List<GenreTagDto> {new () {Id = 1, Title = "Existing Genre"}},
|
||||||
GenresLocked = true
|
GenresLocked = true
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
@ -1007,7 +967,7 @@ public class SeriesServiceTests : AbstractDbTest
|
||||||
SeriesId = 1,
|
SeriesId = 1,
|
||||||
ReleaseYear = 100,
|
ReleaseYear = 100,
|
||||||
},
|
},
|
||||||
CollectionTags = new List<CollectionTagDto>()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.True(success);
|
Assert.True(success);
|
||||||
|
|
|
@ -66,10 +66,10 @@
|
||||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Hangfire" Version="1.8.11" />
|
<PackageReference Include="Hangfire" Version="1.8.11" />
|
||||||
<PackageReference Include="Hangfire.InMemory" Version="0.8.0" />
|
<PackageReference Include="Hangfire.InMemory" Version="0.8.1" />
|
||||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.1" />
|
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.1" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.59" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.60" />
|
||||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.11" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||||
|
@ -81,8 +81,8 @@
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||||
<PackageReference Include="NetVips" Version="2.4.0" />
|
<PackageReference Include="NetVips" Version="2.4.1" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.15.1" />
|
<PackageReference Include="NetVips.Native" Version="8.15.2" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
|
<PackageReference Include="NReco.Logging.File" Version="1.2.0" />
|
||||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||||
|
@ -95,14 +95,14 @@
|
||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.36.0" />
|
<PackageReference Include="SharpCompress" Version="0.36.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.3" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.3" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.21.0.86780">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.23.0.88079">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.1" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.4.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="20.0.28" />
|
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="8.0.3" />
|
<PackageReference Include="System.Drawing.Common" Version="8.0.3" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
@ -40,8 +40,14 @@ public static class PolicyConstants
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks>
|
/// <remarks>This is used explicitly for Demo Server. Not sure why it would be used in another fashion</remarks>
|
||||||
public const string ReadOnlyRole = "Read Only";
|
public const string ReadOnlyRole = "Read Only";
|
||||||
|
/// <summary>
|
||||||
|
/// Ability to promote entities (Collections, Reading Lists, etc).
|
||||||
|
/// </summary>
|
||||||
|
public const string PromoteRole = "Promote";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static readonly ImmutableArray<string> ValidRoles =
|
public static readonly ImmutableArray<string> ValidRoles =
|
||||||
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole);
|
ImmutableArray.Create(AdminRole, PlebRole, DownloadRole, ChangePasswordRole, BookmarkRole, ChangeRestrictionRole, LoginRole, ReadOnlyRole, PromoteRole);
|
||||||
}
|
}
|
||||||
|
|
|
@ -363,7 +363,7 @@ public class AccountController : BaseApiController
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate no other users exist with this email
|
// 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
|
// Check if email is used by another user
|
||||||
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
|
var existingUserEmail = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email);
|
||||||
|
@ -386,8 +386,12 @@ public class AccountController : BaseApiController
|
||||||
user.ConfirmationToken = token;
|
user.ConfirmationToken = token;
|
||||||
await _userManager.UpdateAsync(user);
|
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)
|
if (!shouldEmailUser)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Cannot email admin, email not setup or admin email invalid");
|
||||||
return Ok(new InviteUserResponse
|
return Ok(new InviteUserResponse
|
||||||
{
|
{
|
||||||
EmailLink = string.Empty,
|
EmailLink = string.Empty,
|
||||||
|
@ -399,9 +403,6 @@ public class AccountController : BaseApiController
|
||||||
// Send a confirmation email
|
// Send a confirmation email
|
||||||
try
|
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 (!_emailService.IsValidEmail(user.Email))
|
||||||
{
|
{
|
||||||
_logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email);
|
_logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email);
|
||||||
|
@ -839,6 +840,7 @@ public class AccountController : BaseApiController
|
||||||
return BadRequest(await _localizationService.Translate(user.Id, "generic-user-email-update"));
|
return BadRequest(await _localizationService.Translate(user.Id, "generic-user-email-update"));
|
||||||
}
|
}
|
||||||
user.ConfirmationToken = null;
|
user.ConfirmationToken = null;
|
||||||
|
user.EmailConfirmed = true;
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Data.ManualMigrations;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
@ -28,4 +30,15 @@ public class AdminController : BaseApiController
|
||||||
var users = await _userManager.GetUsersInRoleAsync("Admin");
|
var users = await _userManager.GetUsersInRoleAsync("Admin");
|
||||||
return users.Count > 0;
|
return users.Count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the progress information for a particular user
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize("RequireAdminRole")]
|
||||||
|
[HttpPost("update-chapter-progress")]
|
||||||
|
public async Task<ActionResult<bool>> UpdateChapterProgress(UpdateUserProgressDto dto)
|
||||||
|
{
|
||||||
|
return Ok(await Task.FromResult(false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.Entities.Metadata;
|
using API.Entities;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
|
using API.Helpers.Builders;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
|
@ -23,61 +27,50 @@ public class CollectionController : BaseApiController
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly ICollectionTagService _collectionService;
|
private readonly ICollectionTagService _collectionService;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly IExternalMetadataService _externalMetadataService;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
|
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService,
|
||||||
ILocalizationService localizationService)
|
ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_collectionService = collectionService;
|
_collectionService = collectionService;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_externalMetadataService = externalMetadataService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return a list of all collection tags on the server for the logged in user.
|
/// Returns all Collection tags for a given User
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> GetAllTags()
|
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetAllTags(bool ownedOnly = false)
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(User.GetUserId(), !ownedOnly));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches against the collection tags on the DB and returns matches that meet the search criteria.
|
/// Returns all collections that contain the Series for the user with the option to allow for promoted collections (non-user owned)
|
||||||
/// <remarks>Search strings will be cleaned of certain fields, like %</remarks>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="queryString">Search term</param>
|
/// <param name="seriesId"></param>
|
||||||
|
/// <param name="ownedOnly"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
[HttpGet("all-series")]
|
||||||
[HttpGet("search")]
|
public async Task<ActionResult<IEnumerable<AppUserCollectionDto>>> GetCollectionsBySeries(int seriesId, bool ownedOnly = false)
|
||||||
public async Task<ActionResult<IEnumerable<CollectionTagDto>>> SearchTags(string? queryString)
|
|
||||||
{
|
{
|
||||||
queryString ??= string.Empty;
|
return Ok(await _unitOfWork.CollectionTagRepository.GetCollectionDtosBySeriesAsync(User.GetUserId(), seriesId, !ownedOnly));
|
||||||
queryString = queryString.Replace(@"%", string.Empty);
|
|
||||||
if (queryString.Length == 0) return await GetAllTags();
|
|
||||||
|
|
||||||
return Ok(await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a collection exists with the name
|
/// Checks if a collection exists with the name
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="name">If empty or null, will return true as that is invalid</param>
|
/// <param name="name">If empty or null, will return true as that is invalid</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
|
||||||
[HttpGet("name-exists")]
|
[HttpGet("name-exists")]
|
||||||
public async Task<ActionResult<bool>> DoesNameExists(string name)
|
public async Task<ActionResult<bool>> DoesNameExists(string name)
|
||||||
{
|
{
|
||||||
return Ok(await _collectionService.TagExistsByName(name));
|
return Ok(await _unitOfWork.CollectionTagRepository.CollectionExists(name, User.GetUserId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -86,13 +79,15 @@ public class CollectionController : BaseApiController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="updatedTag"></param>
|
/// <param name="updatedTag"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
|
||||||
[HttpPost("update")]
|
[HttpPost("update")]
|
||||||
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
|
public async Task<ActionResult> UpdateTag(AppUserCollectionDto updatedTag)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (await _collectionService.UpdateTag(updatedTag)) return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
|
if (await _collectionService.UpdateTag(updatedTag, User.GetUserId()))
|
||||||
|
{
|
||||||
|
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-updated-successfully"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (KavitaException ex)
|
catch (KavitaException ex)
|
||||||
{
|
{
|
||||||
|
@ -103,18 +98,94 @@ public class CollectionController : BaseApiController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("promote-multiple")]
|
||||||
|
public async Task<ActionResult> PromoteMultipleCollections(PromoteCollectionsDto dto)
|
||||||
|
{
|
||||||
|
// This needs to take into account owner as I can select other users cards
|
||||||
|
var collections = await _unitOfWork.CollectionTagRepository.GetCollectionsByIds(dto.CollectionIds);
|
||||||
|
var userId = User.GetUserId();
|
||||||
|
|
||||||
|
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
|
||||||
|
{
|
||||||
|
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var collection in collections)
|
||||||
|
{
|
||||||
|
if (collection.AppUserId != userId) continue;
|
||||||
|
collection.Promoted = dto.Promoted;
|
||||||
|
_unitOfWork.CollectionTagRepository.Update(collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_unitOfWork.HasChanges()) return Ok();
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Promote/UnPromote multiple collections in one go
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("delete-multiple")]
|
||||||
|
public async Task<ActionResult> DeleteMultipleCollections(PromoteCollectionsDto dto)
|
||||||
|
{
|
||||||
|
// This needs to take into account owner as I can select other users cards
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
user.Collections = user.Collections.Where(uc => !dto.CollectionIds.Contains(uc.Id)).ToList();
|
||||||
|
_unitOfWork.UserRepository.Update(user);
|
||||||
|
|
||||||
|
|
||||||
|
if (!_unitOfWork.HasChanges()) return Ok();
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds multiple series to a collection. If tag id is 0, this will create a new tag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dto"></param>
|
/// <param name="dto"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
|
||||||
[HttpPost("update-for-series")]
|
[HttpPost("update-for-series")]
|
||||||
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
|
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
|
||||||
{
|
{
|
||||||
// Create a new tag and save
|
// Create a new tag and save
|
||||||
var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok();
|
AppUserCollection? tag;
|
||||||
|
if (dto.CollectionTagId == 0)
|
||||||
|
{
|
||||||
|
tag = new AppUserCollectionBuilder(dto.CollectionTagTitle).Build();
|
||||||
|
user.Collections.Add(tag);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Validate tag doesn't exist
|
||||||
|
tag = user.Collections.FirstOrDefault(t => t.Id == dto.CollectionTagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag == null)
|
||||||
|
{
|
||||||
|
return BadRequest(_localizationService.Translate(User.GetUserId(), "collection-doesnt-exists"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds.ToList());
|
||||||
|
foreach (var s in series)
|
||||||
|
{
|
||||||
|
if (tag.Items.Contains(s)) continue;
|
||||||
|
tag.Items.Add(s);
|
||||||
|
}
|
||||||
|
_unitOfWork.UserRepository.Update(user);
|
||||||
|
if (await _unitOfWork.CommitAsync()) return Ok();
|
||||||
|
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||||
}
|
}
|
||||||
|
@ -124,13 +195,12 @@ public class CollectionController : BaseApiController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="updateSeriesForTagDto"></param>
|
/// <param name="updateSeriesForTagDto"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
|
||||||
[HttpPost("update-series")]
|
[HttpPost("update-series")]
|
||||||
public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
|
public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
|
||||||
{
|
{
|
||||||
try
|
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 (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
|
||||||
|
|
||||||
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
|
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
|
||||||
|
@ -145,27 +215,42 @@ public class CollectionController : BaseApiController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes the collection tag from all Series it was attached to
|
/// Removes the collection tag from the user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tagId"></param>
|
/// <param name="tagId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
public async Task<ActionResult> DeleteTag(int tagId)
|
public async Task<ActionResult> DeleteTag(int tagId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId, CollectionTagIncludes.SeriesMetadata);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Collections);
|
||||||
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
|
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"));
|
return Ok(await _localizationService.Translate(User.GetUserId(), "collection-deleted"));
|
||||||
}
|
}
|
||||||
catch (Exception)
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
||||||
await _unitOfWork.RollbackAsync();
|
await _unitOfWork.RollbackAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For the authenticated user, if they have an active Kavita+ subscription and a MAL username on record,
|
||||||
|
/// fetch their Mal interest stacks (including restacks)
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("mal-stacks")]
|
||||||
|
public async Task<ActionResult<IList<MalStackDto>>> GetMalStacksForUser()
|
||||||
|
{
|
||||||
|
return Ok(await _externalMetadataService.GetStacksForUser(User.GetUserId()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -111,7 +111,7 @@ public class ImageController : BaseApiController
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns cover image for Collection Tag
|
/// Returns cover image for Collection
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="collectionTagId"></param>
|
/// <param name="collectionTagId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
|
@ -121,6 +121,7 @@ public class ImageController : BaseApiController
|
||||||
{
|
{
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
if (userId == 0) return BadRequest();
|
if (userId == 0) return BadRequest();
|
||||||
|
|
||||||
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
|
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
|
||||||
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
|
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
|
||||||
{
|
{
|
||||||
|
|
|
@ -166,11 +166,36 @@ public class LibraryController : BaseApiController
|
||||||
return Ok(_directoryService.ListDirectory(path));
|
return Ok(_directoryService.ListDirectory(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Return a specific library
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<LibraryDto?>> GetLibrary(int libraryId)
|
||||||
|
{
|
||||||
|
var username = User.GetUsername();
|
||||||
|
if (string.IsNullOrEmpty(username)) return Unauthorized();
|
||||||
|
|
||||||
|
var cacheKey = CacheKey + username;
|
||||||
|
var result = await _libraryCacheProvider.GetAsync<IEnumerable<LibraryDto>>(cacheKey);
|
||||||
|
if (result.HasValue)
|
||||||
|
{
|
||||||
|
return Ok(result.Value.FirstOrDefault(l => l.Id == libraryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var ret = _unitOfWork.LibraryRepository.GetLibraryDtosForUsernameAsync(username).ToList();
|
||||||
|
await _libraryCacheProvider.SetAsync(CacheKey, ret, TimeSpan.FromHours(24));
|
||||||
|
_logger.LogDebug("Caching libraries for {Key}", cacheKey);
|
||||||
|
|
||||||
|
return Ok(ret.Find(l => l.Id == libraryId));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return all libraries in the Server
|
/// Return all libraries in the Server
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet]
|
[HttpGet("libraries")]
|
||||||
public async Task<ActionResult<IEnumerable<LibraryDto>>> GetLibraries()
|
public async Task<ActionResult<IEnumerable<LibraryDto>>> GetLibraries()
|
||||||
{
|
{
|
||||||
var username = User.GetUsername();
|
var username = User.GetUsername();
|
||||||
|
|
|
@ -9,10 +9,12 @@ using API.Comparators;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.OPDS;
|
using API.DTOs.OPDS;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Search;
|
using API.DTOs.Search;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -449,15 +451,13 @@ public class OpdsController : BaseApiController
|
||||||
var userId = await GetUser(apiKey);
|
var userId = await GetUser(apiKey);
|
||||||
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||||
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
||||||
var (baseUrl, prefix) = await GetPrefix();
|
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
|
||||||
|
|
||||||
var tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync())
|
|
||||||
: (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId));
|
|
||||||
|
|
||||||
|
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
|
||||||
|
|
||||||
|
var (baseUrl, prefix) = await GetPrefix();
|
||||||
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
|
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{prefix}{apiKey}/collections", apiKey, prefix);
|
||||||
SetFeedId(feed, "collections");
|
SetFeedId(feed, "collections");
|
||||||
|
|
||||||
|
@ -466,12 +466,15 @@ public class OpdsController : BaseApiController
|
||||||
Id = tag.Id.ToString(),
|
Id = tag.Id.ToString(),
|
||||||
Title = tag.Title,
|
Title = tag.Title,
|
||||||
Summary = tag.Summary,
|
Summary = tag.Summary,
|
||||||
Links = new List<FeedLink>()
|
Links =
|
||||||
{
|
[
|
||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections/{tag.Id}"),
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
|
||||||
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
|
$"{prefix}{apiKey}/collections/{tag.Id}"),
|
||||||
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
|
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));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
|
@ -488,20 +491,9 @@ public class OpdsController : BaseApiController
|
||||||
var (baseUrl, prefix) = await GetPrefix();
|
var (baseUrl, prefix) = await GetPrefix();
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
|
||||||
|
|
||||||
IEnumerable <CollectionTagDto> tags;
|
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId);
|
||||||
if (isAdmin)
|
if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted))
|
||||||
{
|
|
||||||
tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
var tag = tags.SingleOrDefault(t => t.Id == collectionId);
|
|
||||||
if (tag == null)
|
|
||||||
{
|
{
|
||||||
return BadRequest("Collection does not exist or you don't have access");
|
return BadRequest("Collection does not exist or you don't have access");
|
||||||
}
|
}
|
||||||
|
@ -1131,7 +1123,9 @@ public class OpdsController : BaseApiController
|
||||||
Id = mangaFile.Id.ToString(),
|
Id = mangaFile.Id.ToString(),
|
||||||
Title = title,
|
Title = title,
|
||||||
Extent = fileSize,
|
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(),
|
Format = mangaFile.Format.ToString(),
|
||||||
Links = new List<FeedLink>()
|
Links = new List<FeedLink>()
|
||||||
{
|
{
|
||||||
|
@ -1287,7 +1281,7 @@ public class OpdsController : BaseApiController
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SerializeXml(Feed feed)
|
private string SerializeXml(Feed? feed)
|
||||||
{
|
{
|
||||||
if (feed == null) return string.Empty;
|
if (feed == null) return string.Empty;
|
||||||
using var sm = new StringWriter();
|
using var sm = new StringWriter();
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
|
@ -7,8 +7,8 @@ using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Data.Repositories;
|
using API.Data.Repositories;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -880,4 +880,21 @@ public class ReaderController : BaseApiController
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all progress events for a given chapter
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chapterId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("all-chapter-progress")]
|
||||||
|
public async Task<ActionResult<IEnumerable<FullProgressDto>>> GetProgressForChapter(int chapterId)
|
||||||
|
{
|
||||||
|
if (User.IsInRole(PolicyConstants.AdminRole))
|
||||||
|
{
|
||||||
|
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await _unitOfWork.AppUserProgressRepository.GetUserProgressForChapter(chapterId, User.GetUserId()));
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,6 +52,23 @@ public class ScrobblingController : BaseApiController
|
||||||
return Ok(user.AniListAccessToken);
|
return Ok(user.AniListAccessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the current user's MAL token & username
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet("mal-token")]
|
||||||
|
public async Task<ActionResult<MalUserInfoDto>> GetMalToken()
|
||||||
|
{
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(new MalUserInfoDto()
|
||||||
|
{
|
||||||
|
Username = user.MalUserName,
|
||||||
|
AccessToken = user.MalAccessToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update the current user's AniList token
|
/// Update the current user's AniList token
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -76,6 +93,26 @@ public class ScrobblingController : BaseApiController
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update the current user's MAL token (Client ID) and Username
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpPost("update-mal-token")]
|
||||||
|
public async Task<ActionResult> UpdateMalToken(MalUserInfoDto dto)
|
||||||
|
{
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
|
if (user == null) return Unauthorized();
|
||||||
|
|
||||||
|
user.MalAccessToken = dto.AccessToken;
|
||||||
|
user.MalUserName = dto.Username;
|
||||||
|
|
||||||
|
_unitOfWork.UserRepository.Update(user);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
|
/// Checks if the current Scrobbling token for the given Provider has expired for the current user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -58,7 +58,7 @@ public class SearchController : BaseApiController
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) return Unauthorized();
|
||||||
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
|
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 isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
|
||||||
|
|
||||||
|
|
|
@ -221,18 +221,18 @@ public class ServerController : BaseApiController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpGet("jobs")]
|
[HttpGet("jobs")]
|
||||||
public ActionResult<IEnumerable<JobDto>> GetJobs()
|
public async Task<ActionResult<IEnumerable<JobDto>>> GetJobs()
|
||||||
|
{
|
||||||
|
var jobDtoTasks = JobStorage.Current.GetConnection().GetRecurringJobs().Select(async dto =>
|
||||||
|
new JobDto()
|
||||||
{
|
{
|
||||||
var recurringJobs = JobStorage.Current.GetConnection().GetRecurringJobs().Select(
|
|
||||||
dto =>
|
|
||||||
new JobDto() {
|
|
||||||
Id = dto.Id,
|
Id = dto.Id,
|
||||||
Title = dto.Id.Replace('-', ' '),
|
Title = await _localizationService.Translate(User.GetUserId(), dto.Id),
|
||||||
Cron = dto.Cron,
|
Cron = dto.Cron,
|
||||||
LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null
|
LastExecutionUtc = dto.LastExecution.HasValue ? new DateTime(dto.LastExecution.Value.Ticks, DateTimeKind.Utc) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
return Ok(recurringJobs);
|
return Ok(await Task.WhenAll(jobDtoTasks));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
@ -457,6 +457,7 @@ public class SettingsController : BaseApiController
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
/// All values allowed for Task Scheduling APIs. A custom cron job is not included. Disabled is not applicable for Cleanup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -510,6 +511,7 @@ public class SettingsController : BaseApiController
|
||||||
public async Task<ActionResult<EmailTestResultDto>> TestEmailServiceUrl()
|
public async Task<ActionResult<EmailTestResultDto>> TestEmailServiceUrl()
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId());
|
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));
|
return Ok(await _emailService.SendTestEmail(user!.Email));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -22,14 +23,16 @@ public class StatsController : BaseApiController
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly ILocalizationService _localizationService;
|
private readonly ILocalizationService _localizationService;
|
||||||
|
private readonly ILicenseService _licenseService;
|
||||||
|
|
||||||
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
|
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
|
||||||
UserManager<AppUser> userManager, ILocalizationService localizationService)
|
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
|
||||||
{
|
{
|
||||||
_statService = statService;
|
_statService = statService;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_localizationService = localizationService;
|
_localizationService = localizationService;
|
||||||
|
_licenseService = licenseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("user/{userId}/read")]
|
[HttpGet("user/{userId}/read")]
|
||||||
|
@ -181,6 +184,18 @@ public class StatsController : BaseApiController
|
||||||
return Ok(_statService.GetWordsReadCountByYear(userId));
|
return Ok(_statService.GetWordsReadCountByYear(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns for Kavita+ the number of Series that have been processed, errored, and not processed
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize("RequireAdminRole")]
|
||||||
|
[HttpGet("kavitaplus-metadata-breakdown")]
|
||||||
|
[ResponseCache(CacheProfileName = "Statistics")]
|
||||||
|
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetKavitaPlusMetadataBreakdown()
|
||||||
|
{
|
||||||
|
if (!await _licenseService.HasActiveLicense())
|
||||||
|
return BadRequest("This data is not available for non-Kavita+ servers");
|
||||||
|
return Ok(await _statService.GetKavitaPlusMetadataBreakdown());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.DTOs.Uploads;
|
using API.DTOs.Uploads;
|
||||||
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
|
@ -98,6 +99,7 @@ public class UploadController : BaseApiController
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
|
||||||
|
|
||||||
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
|
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
|
||||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
|
||||||
|
|
||||||
|
@ -145,7 +147,7 @@ public class UploadController : BaseApiController
|
||||||
|
|
||||||
try
|
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"));
|
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
|
||||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
|
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
|
||||||
|
|
||||||
|
@ -225,17 +227,14 @@ public class UploadController : BaseApiController
|
||||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save"));
|
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-reading-list-save"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename, int thumbnailSize = 0)
|
private async Task<string> CreateThumbnail(UploadFileDto uploadFileDto, string filename)
|
||||||
{
|
{
|
||||||
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
|
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
if (thumbnailSize > 0)
|
var encodeFormat = settings.EncodeMediaAs;
|
||||||
{
|
var coverImageSize = settings.CoverImageSize;
|
||||||
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
|
|
||||||
filename, encodeFormat, thumbnailSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
|
return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
|
||||||
filename, encodeFormat);
|
filename, encodeFormat, coverImageSize.GetDimensions().Width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -326,8 +325,7 @@ public class UploadController : BaseApiController
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var filePath = await CreateThumbnail(uploadFileDto,
|
var filePath = await CreateThumbnail(uploadFileDto,
|
||||||
$"{ImageService.GetLibraryFormat(uploadFileDto.Id)}",
|
$"{ImageService.GetLibraryFormat(uploadFileDto.Id)}");
|
||||||
ImageService.LibraryThumbnailWidth);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(filePath))
|
if (!string.IsNullOrEmpty(filePath))
|
||||||
{
|
{
|
||||||
|
|
|
@ -112,12 +112,23 @@ public class UsersController : BaseApiController
|
||||||
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
|
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
|
||||||
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
|
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
|
||||||
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
|
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
|
||||||
existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
|
||||||
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
|
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
|
||||||
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
||||||
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
|
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
|
||||||
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
||||||
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
|
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
|
||||||
|
|
||||||
|
existingPreferences.PdfTheme = preferencesDto.PdfTheme;
|
||||||
|
existingPreferences.PdfLayoutMode = preferencesDto.PdfLayoutMode;
|
||||||
|
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
|
||||||
|
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
|
||||||
|
|
||||||
|
if (existingPreferences.Theme.Id != preferencesDto.Theme?.Id)
|
||||||
|
{
|
||||||
|
existingPreferences.Theme = preferencesDto.Theme ?? await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (_localizationService.GetLocales().Contains(preferencesDto.Locale))
|
if (_localizationService.GetLocales().Contains(preferencesDto.Locale))
|
||||||
{
|
{
|
||||||
existingPreferences.Locale = preferencesDto.Locale;
|
existingPreferences.Locale = preferencesDto.Locale;
|
||||||
|
|
|
@ -40,6 +40,7 @@ public class WantToReadController : BaseApiController
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
|
/// Return all Series that are in the current logged in user's Want to Read list, filtered (deprecated, use v2)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This will be removed in v0.8.x</remarks>
|
||||||
/// <param name="userParams"></param>
|
/// <param name="userParams"></param>
|
||||||
/// <param name="filterDto"></param>
|
/// <param name="filterDto"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
|
|
39
API/DTOs/Collection/AppUserCollectionDto.cs
Normal file
39
API/DTOs/Collection/AppUserCollectionDto.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
using System;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
namespace API.DTOs.Collection;
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
public class AppUserCollectionDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string Title { get; set; } = default!;
|
||||||
|
public string Summary { get; set; } = default!;
|
||||||
|
public bool Promoted { get; set; }
|
||||||
|
public AgeRating AgeRating { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public string? CoverImage { get; set; } = string.Empty;
|
||||||
|
public bool CoverImageLocked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner of the Collection
|
||||||
|
/// </summary>
|
||||||
|
public string? Owner { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastSyncUtc { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
|
||||||
|
/// </summary>
|
||||||
|
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
|
||||||
|
/// <summary>
|
||||||
|
/// For Non-Kavita sourced collections, the url to sync from
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceUrl { get; set; }
|
||||||
|
}
|
8
API/DTOs/Collection/DeleteCollectionsDto.cs
Normal file
8
API/DTOs/Collection/DeleteCollectionsDto.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.DTOs.Collection;
|
||||||
|
|
||||||
|
public class DeleteCollectionsDto
|
||||||
|
{
|
||||||
|
public IList<int> CollectionIds { get; set; }
|
||||||
|
}
|
19
API/DTOs/Collection/MalStackDto.cs
Normal file
19
API/DTOs/Collection/MalStackDto.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
namespace API.DTOs.Collection;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an Interest Stack from MAL
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
/// <summary>
|
||||||
|
/// If an existing collection exists within Kavita
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This is filled out from Kavita and not Kavita+</remarks>
|
||||||
|
public int ExistingId { get; set; }
|
||||||
|
}
|
9
API/DTOs/Collection/PromoteCollectionsDto.cs
Normal file
9
API/DTOs/Collection/PromoteCollectionsDto.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.DTOs.Collection;
|
||||||
|
|
||||||
|
public class PromoteCollectionsDto
|
||||||
|
{
|
||||||
|
public IList<int> CollectionIds { get; init; }
|
||||||
|
public bool Promoted { get; init; }
|
||||||
|
}
|
19
API/DTOs/Progress/FullProgressDto.cs
Normal file
19
API/DTOs/Progress/FullProgressDto.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace API.DTOs.Progress;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A full progress Record from the DB (not all data, only what's needed for API)
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs.Progress;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
||||||
public class ProgressDto
|
public class ProgressDto
|
11
API/DTOs/Progress/UpdateUserProgressDto.cs
Normal file
11
API/DTOs/Progress/UpdateUserProgressDto.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace API.DTOs.Progress;
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
|
public class UpdateUserProgressDto
|
||||||
|
{
|
||||||
|
public int PageNum { get; set; }
|
||||||
|
public DateTime LastModifiedUtc { get; set; }
|
||||||
|
public DateTime CreatedUtc { get; set; }
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace API.DTOs.ReadingLists;
|
namespace API.DTOs.ReadingLists;
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
public class ReadingListDto
|
public class ReadingListDto
|
||||||
{
|
{
|
||||||
|
@ -15,7 +16,7 @@ public class ReadingListDto
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 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.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string CoverImage { get; set; } = string.Empty;
|
public string? CoverImage { get; set; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimum Year the Reading List starts
|
/// Minimum Year the Reading List starts
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
13
API/DTOs/Scrobbling/MalUserInfoDto.cs
Normal file
13
API/DTOs/Scrobbling/MalUserInfoDto.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
namespace API.DTOs.Scrobbling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about a User's MAL connection
|
||||||
|
/// </summary>
|
||||||
|
public class MalUserInfoDto
|
||||||
|
{
|
||||||
|
public required string Username { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// This is actually the Client Id
|
||||||
|
/// </summary>
|
||||||
|
public required string AccessToken { get; set; }
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
|
@ -13,7 +14,7 @@ public class SearchResultGroupDto
|
||||||
{
|
{
|
||||||
public IEnumerable<LibraryDto> Libraries { get; set; } = default!;
|
public IEnumerable<LibraryDto> Libraries { get; set; } = default!;
|
||||||
public IEnumerable<SearchResultDto> Series { get; set; } = default!;
|
public IEnumerable<SearchResultDto> Series { get; set; } = default!;
|
||||||
public IEnumerable<CollectionTagDto> Collections { get; set; } = default!;
|
public IEnumerable<AppUserCollectionDto> Collections { get; set; } = default!;
|
||||||
public IEnumerable<ReadingListDto> ReadingLists { get; set; } = default!;
|
public IEnumerable<ReadingListDto> ReadingLists { get; set; } = default!;
|
||||||
public IEnumerable<PersonDto> Persons { get; set; } = default!;
|
public IEnumerable<PersonDto> Persons { get; set; } = default!;
|
||||||
public IEnumerable<GenreTagDto> Genres { get; set; } = default!;
|
public IEnumerable<GenreTagDto> Genres { get; set; } = default!;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using API.DTOs.CollectionTags;
|
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
|
||||||
|
@ -10,11 +9,6 @@ public class SeriesMetadataDto
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Summary { get; set; } = string.Empty;
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Collections the Series belongs to
|
|
||||||
/// </summary>
|
|
||||||
public ICollection<CollectionTagDto> CollectionTags { get; set; } = new List<CollectionTagDto>();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Genres for the Series
|
/// Genres for the Series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
17
API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
Normal file
17
API/DTOs/Statistics/KavitaPlusMetadataBreakdownDto.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
namespace API.DTOs.Statistics;
|
||||||
|
|
||||||
|
public class KavitaPlusMetadataBreakdownDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Total amount of Series
|
||||||
|
/// </summary>
|
||||||
|
public int TotalSeries { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Series on the Blacklist (errored or bad match)
|
||||||
|
/// </summary>
|
||||||
|
public int ErroredSeries { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Completed so far
|
||||||
|
/// </summary>
|
||||||
|
public int SeriesCompleted { get; set; }
|
||||||
|
}
|
|
@ -1,11 +1,6 @@
|
||||||
using System.Collections.Generic;
|
namespace API.DTOs;
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using API.DTOs.CollectionTags;
|
|
||||||
|
|
||||||
namespace API.DTOs;
|
|
||||||
|
|
||||||
public class UpdateSeriesMetadataDto
|
public class UpdateSeriesMetadataDto
|
||||||
{
|
{
|
||||||
public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
|
public SeriesMetadataDto SeriesMetadata { get; set; } = default!;
|
||||||
public ICollection<CollectionTagDto> CollectionTags { get; set; } = default!;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -152,4 +152,25 @@ public class UserPreferencesDto
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required]
|
[Required]
|
||||||
public string Locale { get; set; }
|
public string Locale { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Theme of the Reader
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Scroll mode of the reader
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Layout Mode of the reader
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple;
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Spread Mode of the reader
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||||
public DbSet<ServerSetting> ServerSetting { get; set; } = null!;
|
public DbSet<ServerSetting> ServerSetting { get; set; } = null!;
|
||||||
public DbSet<AppUserPreferences> AppUserPreferences { get; set; } = null!;
|
public DbSet<AppUserPreferences> AppUserPreferences { get; set; } = null!;
|
||||||
public DbSet<SeriesMetadata> SeriesMetadata { get; set; } = null!;
|
public DbSet<SeriesMetadata> SeriesMetadata { get; set; } = null!;
|
||||||
|
[Obsolete]
|
||||||
public DbSet<CollectionTag> CollectionTag { get; set; } = null!;
|
public DbSet<CollectionTag> CollectionTag { get; set; } = null!;
|
||||||
public DbSet<AppUserBookmark> AppUserBookmark { get; set; } = null!;
|
public DbSet<AppUserBookmark> AppUserBookmark { get; set; } = null!;
|
||||||
public DbSet<ReadingList> ReadingList { get; set; } = null!;
|
public DbSet<ReadingList> ReadingList { get; set; } = null!;
|
||||||
|
@ -64,6 +65,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||||
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
|
||||||
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
|
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
|
||||||
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
|
public DbSet<SeriesBlacklist> SeriesBlacklist { get; set; } = null!;
|
||||||
|
public DbSet<AppUserCollection> AppUserCollection { get; set; } = null!;
|
||||||
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
@ -149,6 +151,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||||
.WithOne(s => s.ExternalSeriesMetadata)
|
.WithOne(s => s.ExternalSeriesMetadata)
|
||||||
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
|
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.Entity<AppUserCollection>()
|
||||||
|
.Property(b => b.AgeRating)
|
||||||
|
.HasDefaultValue(AgeRating.Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
187
API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs
Normal file
187
API/Data/ManualMigrations/ManualMigrateLooseLeafChapters.cs
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Extensions;
|
||||||
|
using API.Helpers.Builders;
|
||||||
|
using API.Services;
|
||||||
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Data.ManualMigrations;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.8.0 migration to move loose leaf chapters into their own volume and retain user progress.
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateLooseLeafChapters
|
||||||
|
{
|
||||||
|
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<Program> 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<Program> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,9 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
|
using API.Extensions;
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
|
using API.Services;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using Kavita.Common.EnvironmentInfo;
|
using Kavita.Common.EnvironmentInfo;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -21,6 +23,7 @@ public class UserProgressCsvRecord
|
||||||
public float MinNumber { get; set; }
|
public float MinNumber { get; set; }
|
||||||
public int SeriesId { get; set; }
|
public int SeriesId { get; set; }
|
||||||
public int VolumeId { get; set; }
|
public int VolumeId { get; set; }
|
||||||
|
public int ProgressId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -28,7 +31,7 @@ public class UserProgressCsvRecord
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MigrateMixedSpecials
|
public static class MigrateMixedSpecials
|
||||||
{
|
{
|
||||||
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
|
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<Program> logger)
|
||||||
{
|
{
|
||||||
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateMixedSpecials"))
|
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateMixedSpecials"))
|
||||||
{
|
{
|
||||||
|
@ -39,13 +42,13 @@ public static class MigrateMixedSpecials
|
||||||
"Running ManualMigrateMixedSpecials migration - Please be patient, this may take some time. This is not an error");
|
"Running ManualMigrateMixedSpecials migration - Please be patient, this may take some time. This is not an error");
|
||||||
|
|
||||||
// First, group all the progresses into different series
|
// First, group all the progresses into different series
|
||||||
|
|
||||||
// Get each series and move the specials from old volume to the new Volume()
|
// 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
|
// Create a new progress event from existing and store the Id of existing progress event to delete it
|
||||||
|
|
||||||
// Save per series
|
// Save per series
|
||||||
|
|
||||||
|
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
|
var extension = settings.EncodeMediaAs.GetExtension();
|
||||||
|
|
||||||
var progress = await dataContext.AppUserProgresses
|
var progress = await dataContext.AppUserProgresses
|
||||||
.Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord
|
.Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord
|
||||||
{
|
{
|
||||||
|
@ -56,10 +59,12 @@ public static class MigrateMixedSpecials
|
||||||
Number = c.Number,
|
Number = c.Number,
|
||||||
MinNumber = c.MinNumber,
|
MinNumber = c.MinNumber,
|
||||||
SeriesId = p.SeriesId,
|
SeriesId = p.SeriesId,
|
||||||
VolumeId = p.VolumeId
|
VolumeId = p.VolumeId,
|
||||||
|
ProgressId = p.Id
|
||||||
})
|
})
|
||||||
.Where(d => d.IsSpecial || d.Number == "0")
|
.Where(d => d.IsSpecial || d.Number == "0")
|
||||||
.Join(dataContext.Volume, d => d.VolumeId, v => v.Id, (d, v) => new
|
.Join(dataContext.Volume, d => d.VolumeId, v => v.Id,
|
||||||
|
(d, v) => new
|
||||||
{
|
{
|
||||||
ProgressRecord = d,
|
ProgressRecord = d,
|
||||||
Volume = v
|
Volume = v
|
||||||
|
@ -68,18 +73,19 @@ public static class MigrateMixedSpecials
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// First, group all the progresses into different series
|
// First, group all the progresses into different series
|
||||||
logger.LogCritical("Migrating {Count} progress events to new Volume structure - This may take over 10 minutes depending on size of DB. Please wait", progress.Count);
|
logger.LogCritical("Migrating {Count} progress events to new Volume structure for Specials - This may take over 10 minutes depending on size of DB. Please wait", progress.Count);
|
||||||
var progressesGroupedBySeries = progress.GroupBy(p => p.ProgressRecord.SeriesId);
|
var progressesGroupedBySeries = progress.GroupBy(p => p.ProgressRecord.SeriesId);
|
||||||
|
|
||||||
foreach (var seriesGroup in progressesGroupedBySeries)
|
foreach (var seriesGroup in progressesGroupedBySeries)
|
||||||
{
|
{
|
||||||
// Get each series and move the specials from the old volume to the new Volume
|
// Get each series and move the specials from the old volume to the new Volume
|
||||||
var seriesId = seriesGroup.Key;
|
var seriesId = seriesGroup.Key;
|
||||||
|
|
||||||
|
// Handle All Specials
|
||||||
var specialsInSeries = seriesGroup
|
var specialsInSeries = seriesGroup
|
||||||
.Where(p => p.ProgressRecord.IsSpecial)
|
.Where(p => p.ProgressRecord.IsSpecial)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|
||||||
// Get distinct Volumes by Id. For each one, create it then create the progress events
|
// Get distinct Volumes by Id. For each one, create it then create the progress events
|
||||||
var distinctVolumes = specialsInSeries.DistinctBy(d => d.Volume.Id);
|
var distinctVolumes = specialsInSeries.DistinctBy(d => d.Volume.Id);
|
||||||
foreach (var distinctVolume in distinctVolumes)
|
foreach (var distinctVolume in distinctVolumes)
|
||||||
|
@ -90,29 +96,72 @@ public static class MigrateMixedSpecials
|
||||||
|
|
||||||
var newVolume = new VolumeBuilder(Parser.SpecialVolume)
|
var newVolume = new VolumeBuilder(Parser.SpecialVolume)
|
||||||
.WithSeriesId(seriesId)
|
.WithSeriesId(seriesId)
|
||||||
.WithChapters(chapters)
|
.WithCreated(distinctVolume.Volume.Created)
|
||||||
|
.WithLastModified(distinctVolume.Volume.LastModified)
|
||||||
.Build();
|
.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);
|
dataContext.Volume.Add(newVolume);
|
||||||
await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId
|
await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId
|
||||||
|
|
||||||
// Migrate the progress event to the new volume
|
// Migrate the progress event to the new volume
|
||||||
distinctVolume.ProgressRecord.VolumeId = newVolume.Id;
|
var oldVolumeProgresses = await dataContext.AppUserProgresses
|
||||||
|
.Where(p => p.VolumeId == distinctVolume.Volume.Id).ToListAsync();
|
||||||
|
foreach (var oldProgress in oldVolumeProgresses)
|
||||||
|
{
|
||||||
|
oldProgress.VolumeId = newVolume.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
|
logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
|
||||||
chapters.Count, distinctVolume.Volume.Id, newVolume.Id);
|
chapters.Count, distinctVolume.Volume.Id, newVolume.Id);
|
||||||
// Move the special chapters from the old volume to the new Volume
|
|
||||||
var specialChapters = await dataContext.Chapter
|
|
||||||
.Where(c => c.VolumeId == distinctVolume.ProgressRecord.VolumeId && c.IsSpecial)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
foreach (var specialChapter in specialChapters)
|
// Move the special chapters from the old volume to the new Volume
|
||||||
|
foreach (var specialChapter in chapters)
|
||||||
{
|
{
|
||||||
// Update the VolumeId on the existing progress event
|
// Update the VolumeId on the existing progress event
|
||||||
specialChapter.VolumeId = newVolume.Id;
|
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();
|
await dataContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save changes after processing all series
|
// Save changes after processing all series
|
||||||
|
@ -121,10 +170,6 @@ public static class MigrateMixedSpecials
|
||||||
await dataContext.SaveChangesAsync();
|
await dataContext.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update all Volumes with Name as "0" -> Special
|
|
||||||
logger.LogCritical("Updating all Volumes with Name 0 to SpecialNumber");
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
|
||||||
{
|
{
|
||||||
|
@ -137,4 +182,25 @@ public static class MigrateMixedSpecials
|
||||||
logger.LogCritical(
|
logger.LogCritical(
|
||||||
"Running ManualMigrateMixedSpecials migration - Completed. This is not an error");
|
"Running ManualMigrateMixedSpecials migration - Completed. This is not an error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void UpdateCoverImage(IDirectoryService directoryService, ILogger<Program> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Data.Repositories;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Extensions.QueryExtensions;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Data.ManualMigrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.8.0 refactored User Collections
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateCollectionTagToUserCollections
|
||||||
|
{
|
||||||
|
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
|
||||||
|
{
|
||||||
|
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateCollectionTagToUserCollections"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogCritical(
|
||||||
|
"Running MigrateCollectionTagToUserCollections migration - Please be patient, this may take some time. This is not an error");
|
||||||
|
|
||||||
|
// Find the first user that is an admin
|
||||||
|
var defaultAdmin = await unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections);
|
||||||
|
if (defaultAdmin == null)
|
||||||
|
{
|
||||||
|
await CompleteMigration(dataContext, logger);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all collectionTags, move them over to said user
|
||||||
|
var existingCollections = await dataContext.CollectionTag
|
||||||
|
.OrderBy(c => c.NormalizedTitle)
|
||||||
|
.Includes(CollectionTagIncludes.SeriesMetadataWithSeries)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var existingCollectionTag in existingCollections)
|
||||||
|
{
|
||||||
|
var collection = new AppUserCollection()
|
||||||
|
{
|
||||||
|
Title = existingCollectionTag.Title,
|
||||||
|
NormalizedTitle = existingCollectionTag.Title.Normalize(),
|
||||||
|
CoverImage = existingCollectionTag.CoverImage,
|
||||||
|
CoverImageLocked = existingCollectionTag.CoverImageLocked,
|
||||||
|
Promoted = existingCollectionTag.Promoted,
|
||||||
|
AgeRating = AgeRating.Unknown,
|
||||||
|
Summary = existingCollectionTag.Summary,
|
||||||
|
Items = existingCollectionTag.SeriesMetadatas.Select(s => s.Series).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
collection.AgeRating = await unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(collection.Items.Select(s => s.Id));
|
||||||
|
defaultAdmin.Collections.Add(collection);
|
||||||
|
}
|
||||||
|
unitOfWork.UserRepository.Update(defaultAdmin);
|
||||||
|
|
||||||
|
await unitOfWork.CommitAsync();
|
||||||
|
|
||||||
|
await CompleteMigration(dataContext, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CompleteMigration(DataContext dataContext, ILogger<Program> 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");
|
||||||
|
}
|
||||||
|
}
|
45
API/Data/ManualMigrations/MigrateMangaFilePath.cs
Normal file
45
API/Data/ManualMigrations/MigrateMangaFilePath.cs
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Data.ManualMigrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.8.0 ensured that MangaFile Path is normalized. This will normalize existing data to avoid churn.
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateMangaFilePath
|
||||||
|
{
|
||||||
|
public static async Task Migrate(DataContext dataContext, ILogger<Program> 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");
|
||||||
|
}
|
||||||
|
}
|
123
API/Data/ManualMigrations/MigrateProgressExport.cs
Normal file
123
API/Data/ManualMigrations/MigrateProgressExport.cs
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Services;
|
||||||
|
using CsvHelper;
|
||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
using Kavita.Common.EnvironmentInfo;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Data.ManualMigrations;
|
||||||
|
|
||||||
|
public class ProgressExport
|
||||||
|
{
|
||||||
|
[Name("Library Id")]
|
||||||
|
public int LibraryId { get; set; }
|
||||||
|
|
||||||
|
[Name("Library Name")]
|
||||||
|
public string LibraryName { get; set; }
|
||||||
|
|
||||||
|
[Name("Series Name")]
|
||||||
|
public string SeriesName { get; set; }
|
||||||
|
|
||||||
|
[Name("Volume Number")]
|
||||||
|
public string VolumeRange { get; set; }
|
||||||
|
|
||||||
|
[Name("Volume LookupName")]
|
||||||
|
public string VolumeLookupName { get; set; }
|
||||||
|
|
||||||
|
[Name("Chapter Number")]
|
||||||
|
public string ChapterRange { get; set; }
|
||||||
|
|
||||||
|
[Name("FileName")]
|
||||||
|
public string MangaFileName { get; set; }
|
||||||
|
|
||||||
|
[Name("FilePath")]
|
||||||
|
public string MangaFilePath { get; set; }
|
||||||
|
|
||||||
|
[Name("AppUser Name")]
|
||||||
|
public string AppUserName { get; set; }
|
||||||
|
|
||||||
|
[Name("AppUser Id")]
|
||||||
|
public int AppUserId { get; set; }
|
||||||
|
|
||||||
|
[Name("Pages Read")]
|
||||||
|
public int PagesRead { get; set; }
|
||||||
|
|
||||||
|
[Name("BookScrollId")]
|
||||||
|
public string BookScrollId { get; set; }
|
||||||
|
|
||||||
|
[Name("Progress Created")]
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
|
||||||
|
[Name("Progress LastModified")]
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.8.0 - Progress is extracted and saved in a csv
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateProgressExport
|
||||||
|
{
|
||||||
|
public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger<Program> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,6 +20,7 @@ public static class MigrateWantToReadExport
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
||||||
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadExport"))
|
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadExport"))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|
2904
API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
generated
Normal file
2904
API/Data/Migrations/20240321173812_UserMalToken.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
38
API/Data/Migrations/20240321173812_UserMalToken.cs
Normal file
38
API/Data/Migrations/20240321173812_UserMalToken.cs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UserMalToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MalAccessToken",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MalUserName",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MalAccessToken",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MalUserName",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2916
API/Data/Migrations/20240328130057_PdfSettings.Designer.cs
generated
Normal file
2916
API/Data/Migrations/20240328130057_PdfSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
62
API/Data/Migrations/20240328130057_PdfSettings.cs
Normal file
62
API/Data/Migrations/20240328130057_PdfSettings.cs
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PdfSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PdfLayoutMode",
|
||||||
|
table: "AppUserPreferences",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PdfScrollMode",
|
||||||
|
table: "AppUserPreferences",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PdfSpreadMode",
|
||||||
|
table: "AppUserPreferences",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PdfTheme",
|
||||||
|
table: "AppUserPreferences",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3019
API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs
generated
Normal file
3019
API/Data/Migrations/20240331172900_UserBasedCollections.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
92
API/Data/Migrations/20240331172900_UserBasedCollections.cs
Normal file
92
API/Data/Migrations/20240331172900_UserBasedCollections.cs
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace API.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UserBasedCollections : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppUserCollection",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Title = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
NormalizedTitle = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
Promoted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
CoverImage = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CoverImageLocked = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
AgeRating = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||||
|
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
LastSyncUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
Source = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
SourceUrl = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
AppUserId = table.Column<int>(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<int>(type: "INTEGER", nullable: false),
|
||||||
|
ItemsId = table.Column<int>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppUserCollectionSeries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppUserCollection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -97,6 +97,12 @@ namespace API.Data.Migrations
|
||||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("MalAccessToken")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("MalUserName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("NormalizedEmail")
|
b.Property<string>("NormalizedEmail")
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
@ -183,6 +189,66 @@ namespace API.Data.Migrations
|
||||||
b.ToTable("AppUserBookmark");
|
b.ToTable("AppUserBookmark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("AgeRating")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<int>("AppUserId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("CoverImage")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("CoverImageLocked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastModified")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastModifiedUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSyncUtc")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedTitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Promoted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Source")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUrl")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AppUserId");
|
||||||
|
|
||||||
|
b.ToTable("AppUserCollection");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
|
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
@ -349,6 +415,18 @@ namespace API.Data.Migrations
|
||||||
b.Property<int>("PageSplitOption")
|
b.Property<int>("PageSplitOption")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PdfLayoutMode")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PdfScrollMode")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PdfSpreadMode")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PdfTheme")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<bool>("PromptForDownloadSize")
|
b.Property<bool>("PromptForDownloadSize")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
@ -1900,6 +1978,21 @@ namespace API.Data.Migrations
|
||||||
b.ToTable("Volume");
|
b.ToTable("Volume");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AppUserCollectionSeries", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CollectionsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ItemsId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CollectionsId", "ItemsId");
|
||||||
|
|
||||||
|
b.HasIndex("ItemsId");
|
||||||
|
|
||||||
|
b.ToTable("AppUserCollectionSeries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("AppUserLibrary", b =>
|
modelBuilder.Entity("AppUserLibrary", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("AppUsersId")
|
b.Property<int>("AppUsersId")
|
||||||
|
@ -2160,6 +2253,17 @@ namespace API.Data.Migrations
|
||||||
b.Navigation("AppUser");
|
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 =>
|
modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||||
|
@ -2608,6 +2712,21 @@ namespace API.Data.Migrations
|
||||||
b.Navigation("Series");
|
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 =>
|
modelBuilder.Entity("AppUserLibrary", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("API.Entities.AppUser", null)
|
b.HasOne("API.Entities.AppUser", null)
|
||||||
|
@ -2818,6 +2937,8 @@ namespace API.Data.Migrations
|
||||||
{
|
{
|
||||||
b.Navigation("Bookmarks");
|
b.Navigation("Bookmarks");
|
||||||
|
|
||||||
|
b.Navigation("Collections");
|
||||||
|
|
||||||
b.Navigation("DashboardStreams");
|
b.Navigation("DashboardStreams");
|
||||||
|
|
||||||
b.Navigation("Devices");
|
b.Navigation("Devices");
|
||||||
|
|
|
@ -5,8 +5,10 @@ using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data.ManualMigrations;
|
using API.Data.ManualMigrations;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Extensions.QueryExtensions;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
|
@ -36,6 +38,7 @@ public interface IAppUserProgressRepository
|
||||||
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
|
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
|
||||||
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
|
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
|
||||||
Task UpdateAllProgressThatAreMoreThanChapterPages();
|
Task UpdateAllProgressThatAreMoreThanChapterPages();
|
||||||
|
Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0);
|
||||||
}
|
}
|
||||||
#nullable disable
|
#nullable disable
|
||||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||||
|
@ -233,6 +236,33 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
||||||
await _context.Database.ExecuteSqlRawAsync(batchSql);
|
await _context.Database.ExecuteSqlRawAsync(batchSql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="chapterId"></param>
|
||||||
|
/// <param name="userId">If 0, will pull all records</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<IList<FullProgressDto>> 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
|
#nullable enable
|
||||||
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
|
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
|
||||||
{
|
{
|
||||||
|
|
|
@ -3,43 +3,60 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data.Misc;
|
using API.Data.Misc;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.Collection;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Extensions.QueryExtensions;
|
using API.Extensions.QueryExtensions;
|
||||||
|
using API.Extensions.QueryExtensions.Filtering;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using AutoMapper.QueryableExtensions;
|
using AutoMapper.QueryableExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Data.Repositories;
|
namespace API.Data.Repositories;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
|
||||||
[Flags]
|
[Flags]
|
||||||
public enum CollectionTagIncludes
|
public enum CollectionTagIncludes
|
||||||
{
|
{
|
||||||
None = 1,
|
None = 1,
|
||||||
SeriesMetadata = 2,
|
SeriesMetadata = 2,
|
||||||
|
SeriesMetadataWithSeries = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum CollectionIncludes
|
||||||
|
{
|
||||||
|
None = 1,
|
||||||
|
Series = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ICollectionTagRepository
|
public interface ICollectionTagRepository
|
||||||
{
|
{
|
||||||
void Add(CollectionTag tag);
|
void Remove(AppUserCollection tag);
|
||||||
void Remove(CollectionTag tag);
|
|
||||||
Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync();
|
|
||||||
Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId);
|
|
||||||
Task<string?> GetCoverImageAsync(int collectionTagId);
|
Task<string?> GetCoverImageAsync(int collectionTagId);
|
||||||
Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId);
|
Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None);
|
||||||
Task<CollectionTag?> GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None);
|
void Update(AppUserCollection tag);
|
||||||
void Update(CollectionTag tag);
|
Task<int> RemoveCollectionsWithoutSeries();
|
||||||
Task<int> RemoveTagsWithoutSeries();
|
|
||||||
Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None);
|
Task<IEnumerable<AppUserCollection>> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None);
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all of the user's collections with the option of other user's promoted
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <param name="includePromoted"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false);
|
||||||
|
Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosBySeriesAsync(int userId, int seriesId, bool includePromoted = false);
|
||||||
|
|
||||||
Task<IEnumerable<CollectionTag>> GetAllTagsByNamesAsync(IEnumerable<string> normalizedTitles,
|
|
||||||
CollectionTagIncludes includes = CollectionTagIncludes.None);
|
|
||||||
Task<IList<string>> GetAllCoverImagesAsync();
|
Task<IList<string>> GetAllCoverImagesAsync();
|
||||||
Task<bool> TagExists(string title);
|
Task<bool> CollectionExists(string title, int userId);
|
||||||
Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
Task<IList<AppUserCollection>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
|
||||||
Task<IList<string>> GetRandomCoverImagesAsync(int collectionId);
|
Task<IList<string>> GetRandomCoverImagesAsync(int collectionId);
|
||||||
|
Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None);
|
||||||
|
Task UpdateCollectionAgeRating(AppUserCollection tag);
|
||||||
|
Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None);
|
||||||
}
|
}
|
||||||
public class CollectionTagRepository : ICollectionTagRepository
|
public class CollectionTagRepository : ICollectionTagRepository
|
||||||
{
|
{
|
||||||
|
@ -52,17 +69,12 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||||
_mapper = mapper;
|
_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)
|
public void Update(AppUserCollection tag)
|
||||||
{
|
|
||||||
_context.CollectionTag.Remove(tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(CollectionTag tag)
|
|
||||||
{
|
{
|
||||||
_context.Entry(tag).State = EntityState.Modified;
|
_context.Entry(tag).State = EntityState.Modified;
|
||||||
}
|
}
|
||||||
|
@ -70,38 +82,53 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes any collection tags without any series
|
/// Removes any collection tags without any series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<int> RemoveTagsWithoutSeries()
|
public async Task<int> RemoveCollectionsWithoutSeries()
|
||||||
{
|
{
|
||||||
var tagsToDelete = await _context.CollectionTag
|
var tagsToDelete = await _context.AppUserCollection
|
||||||
.Include(c => c.SeriesMetadatas)
|
.Include(c => c.Items)
|
||||||
.Where(c => c.SeriesMetadatas.Count == 0)
|
.Where(c => c.Items.Count == 0)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
_context.RemoveRange(tagsToDelete);
|
_context.RemoveRange(tagsToDelete);
|
||||||
|
|
||||||
return await _context.SaveChangesAsync();
|
return await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<CollectionTag>> GetAllTagsAsync(CollectionTagIncludes includes = CollectionTagIncludes.None)
|
public async Task<IEnumerable<AppUserCollection>> GetAllCollectionsAsync(CollectionIncludes includes = CollectionIncludes.None)
|
||||||
{
|
{
|
||||||
return await _context.CollectionTag
|
return await _context.AppUserCollection
|
||||||
.OrderBy(c => c.NormalizedTitle)
|
.OrderBy(c => c.NormalizedTitle)
|
||||||
.Includes(includes)
|
.Includes(includes)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<CollectionTag>> GetAllTagsByNamesAsync(IEnumerable<string> normalizedTitles, CollectionTagIncludes includes = CollectionTagIncludes.None)
|
public async Task<IEnumerable<AppUserCollectionDto>> GetCollectionDtosAsync(int userId, bool includePromoted = false)
|
||||||
{
|
{
|
||||||
return await _context.CollectionTag
|
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
|
||||||
.Where(c => normalizedTitles.Contains(c.NormalizedTitle))
|
return await _context.AppUserCollection
|
||||||
.OrderBy(c => c.NormalizedTitle)
|
.Where(uc => uc.AppUserId == userId || (includePromoted && uc.Promoted))
|
||||||
.Includes(includes)
|
.WhereIf(ageRating.AgeRating != AgeRating.NotApplicable, uc => uc.AgeRating <= ageRating.AgeRating)
|
||||||
|
.OrderBy(uc => uc.Title)
|
||||||
|
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<AppUserCollectionDto>> 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<AppUserCollectionDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetCoverImageAsync(int collectionTagId)
|
public async Task<string?> GetCoverImageAsync(int collectionTagId)
|
||||||
{
|
{
|
||||||
return await _context.CollectionTag
|
return await _context.AppUserCollection
|
||||||
.Where(c => c.Id == collectionTagId)
|
.Where(c => c.Id == collectionTagId)
|
||||||
.Select(c => c.CoverImage)
|
.Select(c => c.CoverImage)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
|
@ -109,23 +136,30 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||||
|
|
||||||
public async Task<IList<string>> GetAllCoverImagesAsync()
|
public async Task<IList<string>> GetAllCoverImagesAsync()
|
||||||
{
|
{
|
||||||
return (await _context.CollectionTag
|
return await _context.AppUserCollection
|
||||||
.Select(t => t.CoverImage)
|
.Select(t => t.CoverImage)
|
||||||
.Where(t => !string.IsNullOrEmpty(t))
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
.ToListAsync())!;
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> TagExists(string title)
|
/// <summary>
|
||||||
|
/// If any tag exists for that given user's collections
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title"></param>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public async Task<bool> CollectionExists(string title, int userId)
|
||||||
{
|
{
|
||||||
var normalized = title.ToNormalized();
|
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));
|
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IList<CollectionTag>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
|
public async Task<IList<AppUserCollection>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
|
||||||
{
|
{
|
||||||
var extension = encodeFormat.GetExtension();
|
var extension = encodeFormat.GetExtension();
|
||||||
return await _context.CollectionTag
|
return await _context.AppUserCollection
|
||||||
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
|
.Where(c => !string.IsNullOrEmpty(c.CoverImage) && !c.CoverImage.EndsWith(extension))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
@ -133,44 +167,50 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||||
public async Task<IList<string>> GetRandomCoverImagesAsync(int collectionId)
|
public async Task<IList<string>> GetRandomCoverImagesAsync(int collectionId)
|
||||||
{
|
{
|
||||||
var random = new Random();
|
var random = new Random();
|
||||||
var data = await _context.CollectionTag
|
var data = await _context.AppUserCollection
|
||||||
.Where(t => t.Id == collectionId)
|
.Where(t => t.Id == collectionId)
|
||||||
.SelectMany(t => t.SeriesMetadatas)
|
.SelectMany(uc => uc.Items.Select(series => series.CoverImage))
|
||||||
.Select(sm => sm.Series.CoverImage)
|
|
||||||
.Where(t => !string.IsNullOrEmpty(t))
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return data
|
return data
|
||||||
.OrderBy(_ => random.Next())
|
.OrderBy(_ => random.Next())
|
||||||
.Take(4)
|
.Take(4)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
public async Task<IList<AppUserCollection>> GetCollectionsForUserAsync(int userId, CollectionIncludes includes = CollectionIncludes.None)
|
||||||
{
|
{
|
||||||
|
return await _context.AppUserCollection
|
||||||
return await _context.CollectionTag
|
.Where(c => c.AppUserId == userId)
|
||||||
.OrderBy(c => c.NormalizedTitle)
|
.Includes(includes)
|
||||||
.AsNoTracking()
|
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<CollectionTagDto>> GetAllPromotedTagDtosAsync(int userId)
|
public async Task UpdateCollectionAgeRating(AppUserCollection tag)
|
||||||
{
|
{
|
||||||
var userRating = await GetUserAgeRestriction(userId);
|
var maxAgeRating = await _context.AppUserCollection
|
||||||
return await _context.CollectionTag
|
.Where(t => t.Id == tag.Id)
|
||||||
.Where(c => c.Promoted)
|
.SelectMany(uc => uc.Items.Select(s => s.Metadata))
|
||||||
.RestrictAgainstAgeRestriction(userRating)
|
.Select(sm => sm.AgeRating)
|
||||||
.OrderBy(c => c.NormalizedTitle)
|
.MaxAsync();
|
||||||
.AsNoTracking()
|
tag.AgeRating = maxAgeRating;
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<AppUserCollection>> GetCollectionsByIds(IEnumerable<int> tags, CollectionIncludes includes = CollectionIncludes.None)
|
||||||
|
{
|
||||||
|
return await _context.AppUserCollection
|
||||||
|
.Where(c => tags.Contains(c.Id))
|
||||||
|
.Includes(includes)
|
||||||
|
.AsSplitQuery()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<CollectionTag?> GetTagAsync(int tagId, CollectionTagIncludes includes = CollectionTagIncludes.None)
|
public async Task<AppUserCollection?> GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None)
|
||||||
{
|
{
|
||||||
return await _context.CollectionTag
|
return await _context.AppUserCollection
|
||||||
.Where(c => c.Id == tagId)
|
.Where(c => c.Id == tagId)
|
||||||
.Includes(includes)
|
.Includes(includes)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
|
@ -190,16 +230,12 @@ public class CollectionTagRepository : ICollectionTagRepository
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<CollectionTagDto>> SearchTagDtosAsync(string searchQuery, int userId)
|
public async Task<IEnumerable<AppUserCollectionDto>> SearchTagDtosAsync(string searchQuery, int userId)
|
||||||
{
|
{
|
||||||
var userRating = await GetUserAgeRestriction(userId);
|
var userRating = await GetUserAgeRestriction(userId);
|
||||||
return await _context.CollectionTag
|
return await _context.AppUserCollection
|
||||||
.Where(s => EF.Functions.Like(s.Title!, $"%{searchQuery}%")
|
.Search(searchQuery, userId, userRating)
|
||||||
|| EF.Functions.Like(s.NormalizedTitle!, $"%{searchQuery}%"))
|
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
|
||||||
.RestrictAgainstAgeRestriction(userRating)
|
|
||||||
.OrderBy(s => s.NormalizedTitle)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ using API.Constants;
|
||||||
using API.Data.Misc;
|
using API.Data.Misc;
|
||||||
using API.Data.Scanner;
|
using API.Data.Scanner;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.DTOs.Dashboard;
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
|
@ -141,7 +142,7 @@ public interface ISeriesRepository
|
||||||
MangaFormat format);
|
MangaFormat format);
|
||||||
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
Task<IList<Series>> RemoveSeriesNotInList(IList<ParsedSeries> seenSeries, int libraryId);
|
||||||
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
|
Task<IDictionary<string, IList<SeriesModified>>> GetFolderPathMap(int libraryId);
|
||||||
Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
|
Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This is only used for <see cref="MigrateUserProgressLibraryId"/>
|
/// This is only used for <see cref="MigrateUserProgressLibraryId"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -342,10 +343,7 @@ public class SeriesRepository : ISeriesRepository
|
||||||
return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync();
|
return await _context.Library.GetUserLibraries(userId, queryContext).ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new List<int>()
|
return [libraryId];
|
||||||
{
|
|
||||||
libraryId
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
|
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery)
|
||||||
|
@ -362,12 +360,9 @@ public class SeriesRepository : ISeriesRepository
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
result.Libraries = await _context.Library
|
result.Libraries = await _context.Library
|
||||||
.Where(l => libraryIds.Contains(l.Id))
|
.Search(searchQuery, userId, libraryIds)
|
||||||
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
|
|
||||||
.IsRestricted(QueryContext.Search)
|
|
||||||
.AsSplitQuery()
|
|
||||||
.OrderBy(l => l.Name.ToLower())
|
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
|
.OrderBy(l => l.Name.ToLower())
|
||||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
@ -419,53 +414,33 @@ public class SeriesRepository : ISeriesRepository
|
||||||
|
|
||||||
|
|
||||||
result.ReadingLists = await _context.ReadingList
|
result.ReadingLists = await _context.ReadingList
|
||||||
.Where(rl => rl.AppUserId == userId || rl.Promoted)
|
.Search(searchQuery, userId, userRating)
|
||||||
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
|
|
||||||
.RestrictAgainstAgeRestriction(userRating)
|
|
||||||
.AsSplitQuery()
|
|
||||||
.OrderBy(r => r.NormalizedTitle)
|
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
result.Collections = await _context.CollectionTag
|
result.Collections = await _context.AppUserCollection
|
||||||
.Where(c => (EF.Functions.Like(c.Title, $"%{searchQuery}%"))
|
.Search(searchQuery, userId, userRating)
|
||||||
|| (EF.Functions.Like(c.NormalizedTitle, $"%{searchQueryNormalized}%")))
|
|
||||||
.Where(c => c.Promoted || isAdmin)
|
|
||||||
.RestrictAgainstAgeRestriction(userRating)
|
|
||||||
.OrderBy(s => s.NormalizedTitle)
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
.OrderBy(c => c.NormalizedTitle)
|
.OrderBy(c => c.NormalizedTitle)
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
result.Persons = await _context.SeriesMetadata
|
result.Persons = await _context.SeriesMetadata
|
||||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
.SearchPeople(searchQuery, seriesIds)
|
||||||
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(p => p.NormalizedName)
|
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
|
.OrderBy(t => t.NormalizedName)
|
||||||
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
result.Genres = await _context.SeriesMetadata
|
result.Genres = await _context.SeriesMetadata
|
||||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
.SearchGenres(searchQuery, seriesIds)
|
||||||
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(t => t.NormalizedTitle)
|
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
result.Tags = await _context.SeriesMetadata
|
result.Tags = await _context.SeriesMetadata
|
||||||
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
.SearchTags(searchQuery, seriesIds)
|
||||||
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(t => t.NormalizedTitle)
|
|
||||||
.Take(maxRecords)
|
.Take(maxRecords)
|
||||||
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
@ -740,6 +715,7 @@ public class SeriesRepository : ISeriesRepository
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
|
public async Task AddSeriesModifiers(int userId, IList<SeriesDto> series)
|
||||||
{
|
{
|
||||||
var userProgress = await _context.AppUserProgresses
|
var userProgress = await _context.AppUserProgresses
|
||||||
|
@ -968,6 +944,20 @@ public class SeriesRepository : ISeriesRepository
|
||||||
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
|
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter,
|
||||||
out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
|
out var hasPublicationFilter, out var hasSeriesNameFilter, out var hasReleaseYearMinFilter, out var hasReleaseYearMaxFilter);
|
||||||
|
|
||||||
|
IList<int> 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
|
var query = _context.Series
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
// This new style can handle any filterComparision coming from the user
|
// This new style can handle any filterComparision coming from the user
|
||||||
|
@ -979,7 +969,7 @@ public class SeriesRepository : ISeriesRepository
|
||||||
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
|
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
|
||||||
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
|
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
|
||||||
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
|
.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)
|
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
|
||||||
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
|
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
|
||||||
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
|
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
|
||||||
|
@ -1045,6 +1035,8 @@ public class SeriesRepository : ISeriesRepository
|
||||||
.Select(u => u.CollapseSeriesRelationships)
|
.Select(u => u.CollapseSeriesRelationships)
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
query ??= _context.Series
|
query ??= _context.Series
|
||||||
.AsNoTracking();
|
.AsNoTracking();
|
||||||
|
|
||||||
|
@ -1062,6 +1054,9 @@ public class SeriesRepository : ISeriesRepository
|
||||||
query = ApplyWantToReadFilter(filter, query, userId);
|
query = ApplyWantToReadFilter(filter, query, userId);
|
||||||
|
|
||||||
|
|
||||||
|
query = await ApplyCollectionFilter(filter, query, userId, userRating);
|
||||||
|
|
||||||
|
|
||||||
query = BuildFilterQuery(userId, filter, query);
|
query = BuildFilterQuery(userId, filter, query);
|
||||||
|
|
||||||
|
|
||||||
|
@ -1078,6 +1073,50 @@ public class SeriesRepository : ISeriesRepository
|
||||||
.AsSplitQuery(), filter.LimitTo);
|
.AsSplitQuery(), filter.LimitTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IQueryable<Series>> ApplyCollectionFilter(FilterV2Dto filter, IQueryable<Series> query, int userId, AgeRestriction userRating)
|
||||||
|
{
|
||||||
|
var collectionStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.CollectionTags);
|
||||||
|
if (collectionStmt == null) return query;
|
||||||
|
|
||||||
|
var value = (IList<int>) 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<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
|
private IQueryable<Series> ApplyWantToReadFilter(FilterV2Dto filter, IQueryable<Series> query, int userId)
|
||||||
{
|
{
|
||||||
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
|
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
|
||||||
|
@ -1175,7 +1214,6 @@ public class SeriesRepository : ISeriesRepository
|
||||||
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
|
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
|
||||||
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
|
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
|
||||||
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
|
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.CollectionTags => query.HasCollectionTags(true, statement.Comparison, (IList<int>) value),
|
|
||||||
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||||
|
@ -1190,6 +1228,9 @@ public class SeriesRepository : ISeriesRepository
|
||||||
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
|
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
|
||||||
|
FilterField.CollectionTags =>
|
||||||
|
// This is handled in the code before this as it's handled in a more general, combined manner
|
||||||
|
query,
|
||||||
FilterField.Libraries =>
|
FilterField.Libraries =>
|
||||||
// This is handled in the code before this as it's handled in a more general, combined manner
|
// This is handled in the code before this as it's handled in a more general, combined manner
|
||||||
query,
|
query,
|
||||||
|
@ -1241,7 +1282,7 @@ public class SeriesRepository : ISeriesRepository
|
||||||
|
|
||||||
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
|
public async Task<SeriesMetadataDto?> GetSeriesMetadata(int seriesId)
|
||||||
{
|
{
|
||||||
var metadataDto = await _context.SeriesMetadata
|
return await _context.SeriesMetadata
|
||||||
.Where(metadata => metadata.SeriesId == seriesId)
|
.Where(metadata => metadata.SeriesId == seriesId)
|
||||||
.Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
|
.Include(m => m.Genres.OrderBy(g => g.NormalizedTitle))
|
||||||
.Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
|
.Include(m => m.Tags.OrderBy(g => g.NormalizedTitle))
|
||||||
|
@ -1250,42 +1291,20 @@ public class SeriesRepository : ISeriesRepository
|
||||||
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesMetadataDto>(_mapper.ConfigurationProvider)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.SingleOrDefaultAsync();
|
.SingleOrDefaultAsync();
|
||||||
|
|
||||||
if (metadataDto != null)
|
|
||||||
{
|
|
||||||
metadataDto.CollectionTags = await _context.CollectionTag
|
|
||||||
.Include(t => t.SeriesMetadatas)
|
|
||||||
.Where(t => t.SeriesMetadatas.Select(s => s.SeriesId).Contains(seriesId))
|
|
||||||
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(t => t.Title.ToLower())
|
|
||||||
.AsSplitQuery()
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadataDto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams)
|
public async Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams)
|
||||||
{
|
{
|
||||||
var userLibraries = _context.Library
|
var userLibraries = _context.Library.GetUserLibraries(userId);
|
||||||
.Include(l => l.AppUsers)
|
|
||||||
.Where(library => library.AppUsers.Any(user => user.Id == userId))
|
|
||||||
.AsSplitQuery()
|
|
||||||
.AsNoTracking()
|
|
||||||
.Select(library => library.Id)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var query = _context.CollectionTag
|
var query = _context.AppUserCollection
|
||||||
.Where(s => s.Id == collectionId)
|
.Where(s => s.Id == collectionId)
|
||||||
.Include(c => c.SeriesMetadatas)
|
.Include(c => c.Items)
|
||||||
.ThenInclude(m => m.Series)
|
.SelectMany(c => c.Items.Where(s => userLibraries.Contains(s.LibraryId)))
|
||||||
.SelectMany(c => c.SeriesMetadatas.Select(sm => sm.Series).Where(s => userLibraries.Contains(s.LibraryId)))
|
|
||||||
.OrderBy(s => s.LibraryId)
|
.OrderBy(s => s.LibraryId)
|
||||||
.ThenBy(s => s.SortName.ToLower())
|
.ThenBy(s => s.SortName.ToLower())
|
||||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery();
|
||||||
.AsNoTracking();
|
|
||||||
|
|
||||||
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||||
}
|
}
|
||||||
|
@ -2072,18 +2091,20 @@ public class SeriesRepository : ISeriesRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the highest Age Rating for a list of Series
|
/// Returns the highest Age Rating for a list of Series. Defaults to <see cref="AgeRating.Unknown"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="seriesIds"></param>
|
/// <param name="seriesIds"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<AgeRating?> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
|
public async Task<AgeRating> GetMaxAgeRatingFromSeriesAsync(IEnumerable<int> seriesIds)
|
||||||
{
|
{
|
||||||
return await _context.Series
|
var ret = await _context.Series
|
||||||
.Where(s => seriesIds.Contains(s.Id))
|
.Where(s => seriesIds.Contains(s.Id))
|
||||||
.Include(s => s.Metadata)
|
.Include(s => s.Metadata)
|
||||||
.Select(s => s.Metadata.AgeRating)
|
.Select(s => s.Metadata.AgeRating)
|
||||||
.OrderBy(s => s)
|
.OrderBy(s => s)
|
||||||
.LastOrDefaultAsync();
|
.LastOrDefaultAsync();
|
||||||
|
if (ret == null) return AgeRating.Unknown;
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
@ -38,7 +38,8 @@ public enum AppUserIncludes
|
||||||
SmartFilters = 1024,
|
SmartFilters = 1024,
|
||||||
DashboardStreams = 2048,
|
DashboardStreams = 2048,
|
||||||
SideNavStreams = 4096,
|
SideNavStreams = 4096,
|
||||||
ExternalSources = 8192 // 2^13
|
ExternalSources = 8192,
|
||||||
|
Collections = 16384 // 2^14
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IUserRepository
|
public interface IUserRepository
|
||||||
|
@ -57,6 +58,7 @@ public interface IUserRepository
|
||||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
||||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||||
Task<bool> IsUserAdminAsync(AppUser? user);
|
Task<bool> IsUserAdminAsync(AppUser? user);
|
||||||
|
Task<IList<string>> GetRoles(int userId);
|
||||||
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
|
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
|
||||||
Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
|
Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
|
||||||
Task<AppUserPreferences?> GetPreferencesAsync(string username);
|
Task<AppUserPreferences?> GetPreferencesAsync(string username);
|
||||||
|
@ -78,7 +80,7 @@ public interface IUserRepository
|
||||||
Task<bool> HasAccessToSeries(int userId, int seriesId);
|
Task<bool> HasAccessToSeries(int userId, int seriesId);
|
||||||
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
|
Task<IEnumerable<AppUser>> GetAllUsersAsync(AppUserIncludes includeFlags = AppUserIncludes.None);
|
||||||
Task<AppUser?> GetUserByConfirmationToken(string token);
|
Task<AppUser?> GetUserByConfirmationToken(string token);
|
||||||
Task<AppUser> GetDefaultAdminUser();
|
Task<AppUser> GetDefaultAdminUser(AppUserIncludes includes = AppUserIncludes.None);
|
||||||
Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId);
|
Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId);
|
||||||
Task<IEnumerable<AppUserRating>> GetSeriesWithReviews(int userId);
|
Task<IEnumerable<AppUserRating>> GetSeriesWithReviews(int userId);
|
||||||
Task<bool> HasHoldOnSeries(int userId, int seriesId);
|
Task<bool> HasHoldOnSeries(int userId, int seriesId);
|
||||||
|
@ -298,11 +300,13 @@ public class UserRepository : IUserRepository
|
||||||
/// Returns the first admin account created
|
/// Returns the first admin account created
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<AppUser> GetDefaultAdminUser()
|
public async Task<AppUser> 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)
|
.OrderBy(u => u.Created)
|
||||||
.First();
|
.FirstAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId)
|
public async Task<IEnumerable<AppUserRating>> GetSeriesWithRatings(int userId)
|
||||||
|
@ -482,7 +486,7 @@ public class UserRepository : IUserRepository
|
||||||
|
|
||||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||||
{
|
{
|
||||||
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
|
return (await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole)).OrderBy(u => u.CreatedUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsUserAdminAsync(AppUser? user)
|
public async Task<bool> IsUserAdminAsync(AppUser? user)
|
||||||
|
@ -491,6 +495,14 @@ public class UserRepository : IUserRepository
|
||||||
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<string>> GetRoles(int userId)
|
||||||
|
{
|
||||||
|
var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
|
if (user == null || _userManager == null) return ArraySegment<string>.Empty; // userManager is null on Unit Tests only
|
||||||
|
|
||||||
|
return await _userManager.GetRolesAsync(user);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId)
|
public async Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId)
|
||||||
{
|
{
|
||||||
return await _context.AppUserRating
|
return await _context.AppUserRating
|
||||||
|
|
|
@ -29,6 +29,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<ReadingList> ReadingLists { get; set; } = null!;
|
public ICollection<ReadingList> ReadingLists { get; set; } = null!;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Collections associated with this user
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<AppUserCollection> Collections { get; set; } = null!;
|
||||||
|
/// <summary>
|
||||||
/// A list of Series the user want's to read
|
/// A list of Series the user want's to read
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;
|
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;
|
||||||
|
@ -63,6 +67,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||||
/// <remarks>Requires Kavita+ Subscription</remarks>
|
/// <remarks>Requires Kavita+ Subscription</remarks>
|
||||||
public string? AniListAccessToken { get; set; }
|
public string? AniListAccessToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Username of the MAL user
|
||||||
|
/// </summary>
|
||||||
|
public string? MalUserName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The Client ID for the user's MAL account. User should create a client on MAL for this.
|
||||||
|
/// </summary>
|
||||||
|
public string? MalAccessToken { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A list of Series the user doesn't want scrobbling for
|
/// A list of Series the user doesn't want scrobbling for
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
60
API/Entities/AppUserCollection.cs
Normal file
60
API/Entities/AppUserCollection.cs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Entities.Interfaces;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
|
||||||
|
namespace API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Collection of Series for a given User
|
||||||
|
/// </summary>
|
||||||
|
public class AppUserCollection : IEntityDate
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// A normalized string used to check if the collection already exists in the DB
|
||||||
|
/// </summary>
|
||||||
|
public required string NormalizedTitle { get; set; }
|
||||||
|
public string? Summary { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Reading lists that are promoted are only done by admins
|
||||||
|
/// </summary>
|
||||||
|
public bool Promoted { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the (managed) image file
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
|
||||||
|
public string? CoverImage { get; set; }
|
||||||
|
public bool CoverImageLocked { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The highest age rating from all Series within the collection
|
||||||
|
/// </summary>
|
||||||
|
public required AgeRating AgeRating { get; set; } = AgeRating.Unknown;
|
||||||
|
public ICollection<Series> 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+
|
||||||
|
/// <summary>
|
||||||
|
/// Last time Kavita Synced the Collection with an upstream source (for non Kavita sourced collections)
|
||||||
|
/// </summary>
|
||||||
|
public DateTime LastSyncUtc { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Who created/manages the list. Non-Kavita lists are not editable by the user, except to promote
|
||||||
|
/// </summary>
|
||||||
|
public ScrobbleProvider Source { get; set; } = ScrobbleProvider.Kavita;
|
||||||
|
/// <summary>
|
||||||
|
/// For Non-Kavita sourced collections, the url to sync from
|
||||||
|
/// </summary>
|
||||||
|
public string? SourceUrl { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
// Relationship
|
||||||
|
public AppUser AppUser { get; set; } = null!;
|
||||||
|
public int AppUserId { get; set; }
|
||||||
|
}
|
|
@ -7,6 +7,9 @@ namespace API.Entities;
|
||||||
public class AppUserPreferences
|
public class AppUserPreferences
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
#region MangaReader
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manga Reader Option: What direction should the next/prev page buttons go
|
/// Manga Reader Option: What direction should the next/prev page buttons go
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -51,6 +54,11 @@ public class AppUserPreferences
|
||||||
/// Manga Reader Option: Should swiping trigger pagination
|
/// Manga Reader Option: Should swiping trigger pagination
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SwipeToPaginate { get; set; }
|
public bool SwipeToPaginate { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region EpubReader
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Book Reader Option: Override extra Margin
|
/// Book Reader Option: Override extra Margin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -75,17 +83,11 @@ public class AppUserPreferences
|
||||||
/// Book Reader Option: What direction should the next/prev page buttons go
|
/// Book Reader Option: What direction should the next/prev page buttons go
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight;
|
public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Book Reader Option: Defines the writing styles vertical/horizontal
|
/// Book Reader Option: Defines the writing styles vertical/horizontal
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal;
|
public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// UI Site Global Setting: The UI theme the user should use.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>Should default to Dark</remarks>
|
|
||||||
public required SiteTheme Theme { get; set; } = Seed.DefaultThemes[0];
|
|
||||||
/// <summary>
|
|
||||||
/// Book Reader Option: The color theme to decorate the book contents
|
/// Book Reader Option: The color theme to decorate the book contents
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Should default to Dark</remarks>
|
/// <remarks>Should default to Dark</remarks>
|
||||||
|
@ -101,6 +103,37 @@ public class AppUserPreferences
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Defaults to false</remarks>
|
/// <remarks>Defaults to false</remarks>
|
||||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region PdfReader
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Theme of the Reader
|
||||||
|
/// </summary>
|
||||||
|
public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Scroll mode of the reader
|
||||||
|
/// </summary>
|
||||||
|
public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Layout Mode of the reader
|
||||||
|
/// </summary>
|
||||||
|
public PdfLayoutMode PdfLayoutMode { get; set; } = PdfLayoutMode.Multiple;
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Reader: Spread Mode of the reader
|
||||||
|
/// </summary>
|
||||||
|
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
|
||||||
|
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Global
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UI Site Global Setting: The UI theme the user should use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>Should default to Dark</remarks>
|
||||||
|
public required SiteTheme Theme { get; set; } = Seed.DefaultThemes[0];
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global Site Option: If the UI should layout items as Cards or List items
|
/// Global Site Option: If the UI should layout items as Cards or List items
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -132,6 +165,8 @@ public class AppUserPreferences
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Locale { get; set; }
|
public string Locale { get; set; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
public AppUser AppUser { get; set; } = null!;
|
public AppUser AppUser { get; set; } = null!;
|
||||||
public int AppUserId { get; set; }
|
public int AppUserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ namespace API.Entities;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the progress a single user has on a given Chapter.
|
/// Represents the progress a single user has on a given Chapter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AppUserProgress : IEntityDate
|
public class AppUserProgress
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Id of Entity
|
/// Id of Entity
|
||||||
|
@ -59,4 +59,10 @@ public class AppUserProgress : IEntityDate
|
||||||
/// User this progress belongs to
|
/// User this progress belongs to
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AppUserId { get; set; }
|
public int AppUserId { get; set; }
|
||||||
|
|
||||||
|
public void MarkModified()
|
||||||
|
{
|
||||||
|
LastModified = DateTime.Now;
|
||||||
|
LastModifiedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Services.Plus;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Entities;
|
namespace API.Entities;
|
||||||
|
@ -7,6 +9,7 @@ namespace API.Entities;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents a user entered field that is used as a tagging and grouping mechanism
|
/// Represents a user entered field that is used as a tagging and grouping mechanism
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("Use AppUserCollection instead")]
|
||||||
[Index(nameof(Id), nameof(Promoted), IsUnique = true)]
|
[Index(nameof(Id), nameof(Promoted), IsUnique = true)]
|
||||||
public class CollectionTag
|
public class CollectionTag
|
||||||
{
|
{
|
||||||
|
@ -41,6 +44,21 @@ public class CollectionTag
|
||||||
|
|
||||||
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;
|
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is this Collection tag managed by another system, like Kavita+
|
||||||
|
/// </summary>
|
||||||
|
//public bool IsManaged { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last time this Collection was Synchronized. Only applicable for Managed Tags.
|
||||||
|
/// </summary>
|
||||||
|
//public DateTime LastSynchronized { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Who created this Collection (Kavita, or external services)
|
||||||
|
/// </summary>
|
||||||
|
//public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Not Used due to not using concurrency update
|
/// Not Used due to not using concurrency update
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -39,5 +39,4 @@ public enum LibraryType
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Description("Generic")]
|
[Description("Generic")]
|
||||||
Generic = 6,
|
Generic = 6,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
21
API/Entities/Enums/UserPreferences/PdfBookMode.cs
Normal file
21
API/Entities/Enums/UserPreferences/PdfBookMode.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace API.Entities.Enums.UserPreferences;
|
||||||
|
|
||||||
|
public enum PdfLayoutMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Multiple pages render stacked (normal pdf experience)
|
||||||
|
/// </summary>
|
||||||
|
[Description("Multiple")]
|
||||||
|
Multiple = 0,
|
||||||
|
// [Description("Single")]
|
||||||
|
// Single = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// A book mode where page turns are animated and layout is side-by-side
|
||||||
|
/// </summary>
|
||||||
|
[Description("Book")]
|
||||||
|
Book = 2,
|
||||||
|
// [Description("Infinite Scroll")]
|
||||||
|
// InfiniteScroll = 3
|
||||||
|
}
|
21
API/Entities/Enums/UserPreferences/PdfScrollMode.cs
Normal file
21
API/Entities/Enums/UserPreferences/PdfScrollMode.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace API.Entities.Enums.UserPreferences;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enum values match PdfViewer's enums
|
||||||
|
/// </summary>
|
||||||
|
public enum PdfScrollMode
|
||||||
|
{
|
||||||
|
[Description("Vertical")]
|
||||||
|
Vertical = 0,
|
||||||
|
[Description("Horizontal")]
|
||||||
|
Horizontal = 1,
|
||||||
|
// [Description("Wrapped")]
|
||||||
|
// Wrapped = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// Single page view (tap to pagninate)
|
||||||
|
/// </summary>
|
||||||
|
[Description("Page")]
|
||||||
|
Page = 3
|
||||||
|
}
|
13
API/Entities/Enums/UserPreferences/PdfSpreadMode.cs
Normal file
13
API/Entities/Enums/UserPreferences/PdfSpreadMode.cs
Normal file
|
@ -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
|
||||||
|
}
|
11
API/Entities/Enums/UserPreferences/PdfTheme.cs
Normal file
11
API/Entities/Enums/UserPreferences/PdfTheme.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace API.Entities.Enums.UserPreferences;
|
||||||
|
|
||||||
|
public enum PdfTheme
|
||||||
|
{
|
||||||
|
[Description("Dark")]
|
||||||
|
Dark = 0,
|
||||||
|
[Description("Light")]
|
||||||
|
Light = 1
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ public class SeriesMetadata : IHasConcurrencyToken
|
||||||
|
|
||||||
public string Summary { get; set; } = string.Empty;
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Obsolete("Use AppUserCollection instead")]
|
||||||
public ICollection<CollectionTag> CollectionTags { get; set; } = new List<CollectionTag>();
|
public ICollection<CollectionTag> CollectionTags { get; set; } = new List<CollectionTag>();
|
||||||
|
|
||||||
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
|
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
using API.Entities.Metadata;
|
using API.Entities.Metadata;
|
||||||
|
using API.Extensions;
|
||||||
|
|
||||||
namespace API.Entities;
|
namespace API.Entities;
|
||||||
|
|
||||||
|
@ -105,6 +106,7 @@ public class Series : IEntityDate, IHasReadTimeEstimate
|
||||||
|
|
||||||
public ICollection<AppUserRating> Ratings { get; set; } = null!;
|
public ICollection<AppUserRating> Ratings { get; set; } = null!;
|
||||||
public ICollection<AppUserProgress> Progress { get; set; } = null!;
|
public ICollection<AppUserProgress> Progress { get; set; } = null!;
|
||||||
|
public ICollection<AppUserCollection> Collections { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Relations to other Series, like Sequels, Prequels, etc
|
/// Relations to other Series, like Sequels, Prequels, etc
|
||||||
|
@ -114,6 +116,8 @@ public class Series : IEntityDate, IHasReadTimeEstimate
|
||||||
public ICollection<SeriesRelation> RelationOf { get; set; } = null!;
|
public ICollection<SeriesRelation> RelationOf { get; set; } = null!;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public List<Volume> Volumes { get; set; } = null!;
|
public List<Volume> Volumes { get; set; } = null!;
|
||||||
public Library Library { get; set; } = null!;
|
public Library Library { get; set; } = null!;
|
||||||
|
@ -131,4 +135,12 @@ public class Series : IEntityDate, IHasReadTimeEstimate
|
||||||
LastChapterAdded = DateTime.Now;
|
LastChapterAdded = DateTime.Now;
|
||||||
LastChapterAddedUtc = DateTime.UtcNow;
|
LastChapterAddedUtc = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool MatchesSeriesByName(string nameNormalized, string localizedNameNormalized)
|
||||||
|
{
|
||||||
|
return NormalizedName == nameNormalized ||
|
||||||
|
NormalizedLocalizedName == nameNormalized ||
|
||||||
|
NormalizedName == localizedNameNormalized ||
|
||||||
|
NormalizedLocalizedName == localizedNameNormalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
|
using API.Helpers.Builders;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
|
||||||
namespace API.Extensions;
|
namespace API.Extensions;
|
||||||
|
@ -24,6 +25,7 @@ public static class ChapterListExtensions
|
||||||
/// Gets a single chapter (or null if doesn't exist) where Range matches the info.Chapters property. If the info
|
/// Gets a single chapter (or null if doesn't exist) where Range matches the info.Chapters property. If the info
|
||||||
/// is <see cref="ParserInfo.IsSpecial"/> then, the filename is used to search against Range or if filename exists within Files of said Chapter.
|
/// is <see cref="ParserInfo.IsSpecial"/> then, the filename is used to search against Range or if filename exists within Files of said Chapter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This uses GetNumberTitle() to calculate the Range to compare against the info.Chapters</remarks>
|
||||||
/// <param name="chapters"></param>
|
/// <param name="chapters"></param>
|
||||||
/// <param name="info"></param>
|
/// <param name="info"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
|
@ -31,9 +33,12 @@ public static class ChapterListExtensions
|
||||||
{
|
{
|
||||||
var normalizedPath = Parser.NormalizePath(info.FullFilePath);
|
var normalizedPath = Parser.NormalizePath(info.FullFilePath);
|
||||||
var specialTreatment = info.IsSpecialInfo();
|
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
|
return specialTreatment
|
||||||
? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath))
|
? chapters.FirstOrDefault(c => c.Range == Parser.RemoveExtensionIfSupported(info.Filename) || c.Files.Select(f => Parser.NormalizePath(f.FilePath)).Contains(normalizedPath))
|
||||||
: chapters.FirstOrDefault(c => c.Range == info.Chapters);
|
: chapters.FirstOrDefault(c => c.Range == fakeChapter.GetNumberTitle());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using API.Data.Misc;
|
||||||
|
using API.Data.Repositories;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Entities.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace API.Extensions.QueryExtensions.Filtering;
|
||||||
|
|
||||||
|
public static class SearchQueryableExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<AppUserCollection> Search(this IQueryable<AppUserCollection> 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<ReadingList> Search(this IQueryable<ReadingList> 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<Library> Search(this IQueryable<Library> queryable,
|
||||||
|
string searchQuery, int userId, IEnumerable<int> 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<Person> SearchPeople(this IQueryable<SeriesMetadata> queryable,
|
||||||
|
string searchQuery, IEnumerable<int> seriesIds)
|
||||||
|
{
|
||||||
|
return queryable
|
||||||
|
.Where(sm => seriesIds.Contains(sm.SeriesId))
|
||||||
|
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(p => p.NormalizedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<Genre> SearchGenres(this IQueryable<SeriesMetadata> queryable,
|
||||||
|
string searchQuery, IEnumerable<int> 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<Tag> SearchTags(this IQueryable<SeriesMetadata> queryable,
|
||||||
|
string searchQuery, IEnumerable<int> 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -551,25 +551,26 @@ public static class SeriesFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
|
public static IQueryable<Series> HasCollectionTags(this IQueryable<Series> queryable, bool condition,
|
||||||
FilterComparison comparison, IList<int> collectionTags)
|
FilterComparison comparison, IList<int> collectionTags, IList<int> collectionSeries)
|
||||||
{
|
{
|
||||||
if (!condition || collectionTags.Count == 0) return queryable;
|
if (!condition || collectionTags.Count == 0) return queryable;
|
||||||
|
|
||||||
|
|
||||||
switch (comparison)
|
switch (comparison)
|
||||||
{
|
{
|
||||||
case FilterComparison.Equal:
|
case FilterComparison.Equal:
|
||||||
case FilterComparison.Contains:
|
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.NotContains:
|
||||||
case FilterComparison.NotEqual:
|
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:
|
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<IQueryable<Series>>()
|
var queries = new List<IQueryable<Series>>()
|
||||||
{
|
{
|
||||||
queryable
|
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));
|
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
|
|
|
@ -31,7 +31,7 @@ public static class SeriesSort
|
||||||
SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, sortOptions),
|
SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, sortOptions),
|
||||||
SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, 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)
|
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),
|
.Max(), sortOptions),
|
||||||
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
|
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
|
||||||
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), sortOptions),
|
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), sortOptions),
|
||||||
|
|
|
@ -19,6 +19,23 @@ public static class IncludesExtensions
|
||||||
queryable = queryable.Include(c => c.SeriesMetadatas);
|
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<AppUserCollection> Includes(this IQueryable<AppUserCollection> queryable,
|
||||||
|
CollectionIncludes includes)
|
||||||
|
{
|
||||||
|
if (includes.HasFlag(CollectionIncludes.Series))
|
||||||
|
{
|
||||||
|
queryable = queryable.Include(c => c.Items);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return queryable.AsSplitQuery();
|
return queryable.AsSplitQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,7 +181,9 @@ public static class IncludesExtensions
|
||||||
|
|
||||||
if (includeFlags.HasFlag(AppUserIncludes.UserPreferences))
|
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))
|
if (includeFlags.HasFlag(AppUserIncludes.WantToRead))
|
||||||
|
@ -204,6 +223,12 @@ public static class IncludesExtensions
|
||||||
query = query.Include(u => u.ExternalSources);
|
query = query.Include(u => u.ExternalSources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeFlags.HasFlag(AppUserIncludes.Collections))
|
||||||
|
{
|
||||||
|
query = query.Include(u => u.Collections)
|
||||||
|
.ThenInclude(c => c.Items);
|
||||||
|
}
|
||||||
|
|
||||||
return query.AsSplitQuery();
|
return query.AsSplitQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Linq;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using API.Data.Misc;
|
using API.Data.Misc;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -24,6 +25,7 @@ public static class RestrictByAgeExtensions
|
||||||
return q;
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Obsolete]
|
||||||
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
|
public static IQueryable<CollectionTag> RestrictAgainstAgeRestriction(this IQueryable<CollectionTag> queryable, AgeRestriction restriction)
|
||||||
{
|
{
|
||||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||||
|
@ -38,6 +40,20 @@ public static class RestrictByAgeExtensions
|
||||||
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IQueryable<AppUserCollection> RestrictAgainstAgeRestriction(this IQueryable<AppUserCollection> 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<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction)
|
public static IQueryable<Genre> RestrictAgainstAgeRestriction(this IQueryable<Genre> queryable, AgeRestriction restriction)
|
||||||
{
|
{
|
||||||
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
if (restriction.AgeRating == AgeRating.NotApplicable) return queryable;
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
||||||
using API.Data.Migrations;
|
using API.Data.Migrations;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
|
using API.DTOs.Collection;
|
||||||
using API.DTOs.CollectionTags;
|
using API.DTOs.CollectionTags;
|
||||||
using API.DTOs.Dashboard;
|
using API.DTOs.Dashboard;
|
||||||
using API.DTOs.Device;
|
using API.DTOs.Device;
|
||||||
|
@ -10,6 +11,7 @@ using API.DTOs.Filtering;
|
||||||
using API.DTOs.Filtering.v2;
|
using API.DTOs.Filtering.v2;
|
||||||
using API.DTOs.MediaErrors;
|
using API.DTOs.MediaErrors;
|
||||||
using API.DTOs.Metadata;
|
using API.DTOs.Metadata;
|
||||||
|
using API.DTOs.Progress;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.DTOs.ReadingLists;
|
using API.DTOs.ReadingLists;
|
||||||
using API.DTOs.Recommendation;
|
using API.DTOs.Recommendation;
|
||||||
|
@ -52,6 +54,8 @@ public class AutoMapperProfiles : Profile
|
||||||
CreateMap<Chapter, ChapterDto>();
|
CreateMap<Chapter, ChapterDto>();
|
||||||
CreateMap<Series, SeriesDto>();
|
CreateMap<Series, SeriesDto>();
|
||||||
CreateMap<CollectionTag, CollectionTagDto>();
|
CreateMap<CollectionTag, CollectionTagDto>();
|
||||||
|
CreateMap<AppUserCollection, AppUserCollectionDto>()
|
||||||
|
.ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName));
|
||||||
CreateMap<Person, PersonDto>();
|
CreateMap<Person, PersonDto>();
|
||||||
CreateMap<Genre, GenreTagDto>();
|
CreateMap<Genre, GenreTagDto>();
|
||||||
CreateMap<Tag, TagDto>();
|
CreateMap<Tag, TagDto>();
|
||||||
|
@ -140,10 +144,6 @@ public class AutoMapperProfiles : Profile
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(
|
opt.MapFrom(
|
||||||
src => src.Genres.OrderBy(p => p.NormalizedTitle)))
|
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,
|
.ForMember(dest => dest.Tags,
|
||||||
opt =>
|
opt =>
|
||||||
opt.MapFrom(
|
opt.MapFrom(
|
||||||
|
|
72
API/Helpers/Builders/AppUserCollectionBuilder.cs
Normal file
72
API/Helpers/Builders/AppUserCollectionBuilder.cs
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
using API.Extensions;
|
||||||
|
using API.Services.Plus;
|
||||||
|
|
||||||
|
namespace API.Helpers.Builders;
|
||||||
|
|
||||||
|
public class AppUserCollectionBuilder : IEntityBuilder<AppUserCollection>
|
||||||
|
{
|
||||||
|
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<Series>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Series>();
|
||||||
|
_collection.Items.Add(series);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppUserCollectionBuilder WithItems(IEnumerable<Series> series)
|
||||||
|
{
|
||||||
|
_collection.Items ??= new List<Series>();
|
||||||
|
foreach (var s in series)
|
||||||
|
{
|
||||||
|
_collection.Items.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppUserCollectionBuilder WithCoverImage(string cover)
|
||||||
|
{
|
||||||
|
_collection.CoverImage = cover;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
@ -36,7 +35,7 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
|
||||||
var specialTitle = specialTreatment ? Parser.RemoveExtensionIfSupported(info.Filename) : info.Chapters;
|
var specialTitle = specialTreatment ? Parser.RemoveExtensionIfSupported(info.Filename) : info.Chapters;
|
||||||
var builder = new ChapterBuilder(Parser.DefaultChapter);
|
var builder = new ChapterBuilder(Parser.DefaultChapter);
|
||||||
|
|
||||||
return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters))
|
return builder.WithNumber(Parser.RemoveExtensionIfSupported(info.Chapters)!)
|
||||||
.WithRange(specialTreatment ? info.Filename : info.Chapters)
|
.WithRange(specialTreatment ? info.Filename : info.Chapters)
|
||||||
.WithTitle((specialTreatment && info.Format == MangaFormat.Epub)
|
.WithTitle((specialTreatment && info.Format == MangaFormat.Epub)
|
||||||
? info.Title
|
? info.Title
|
||||||
|
|
|
@ -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<CollectionTag>
|
|
||||||
{
|
|
||||||
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<SeriesMetadata>()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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<SeriesMetadata>();
|
|
||||||
_collectionTag.SeriesMetadatas.Add(seriesMetadata);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CollectionTagBuilder WithCoverImage(string cover)
|
|
||||||
{
|
|
||||||
_collectionTag.CoverImage = cover;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -15,7 +15,7 @@ public class MangaFileBuilder : IEntityBuilder<MangaFile>
|
||||||
{
|
{
|
||||||
_mangaFile = new MangaFile()
|
_mangaFile = new MangaFile()
|
||||||
{
|
{
|
||||||
FilePath = filePath,
|
FilePath = Parser.NormalizePath(filePath),
|
||||||
Format = format,
|
Format = format,
|
||||||
Pages = pages,
|
Pages = pages,
|
||||||
LastModified = File.GetLastWriteTime(filePath),
|
LastModified = File.GetLastWriteTime(filePath),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
|
@ -75,4 +76,18 @@ public class VolumeBuilder : IEntityBuilder<Volume>
|
||||||
_volume.CoverImage = cover;
|
_volume.CoverImage = cover;
|
||||||
return this;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
|
|
||||||
namespace API.Helpers;
|
namespace API.Helpers;
|
||||||
|
@ -46,6 +47,7 @@ public static class OrderableHelper
|
||||||
|
|
||||||
public static void ReorderItems(List<ReadingListItem> items, int readingListItemId, int toPosition)
|
public static void ReorderItems(List<ReadingListItem> 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);
|
var item = items.Find(r => r.Id == readingListItemId);
|
||||||
if (item != null)
|
if (item != null)
|
||||||
{
|
{
|
||||||
|
|
|
@ -40,6 +40,8 @@
|
||||||
"invalid-username": "Invalid username",
|
"invalid-username": "Invalid username",
|
||||||
"critical-email-migration": "There was an issue during email migration. Contact support",
|
"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.",
|
"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",
|
"chapter-doesnt-exist": "Chapter does not exist",
|
||||||
"file-missing": "File was not found in book",
|
"file-missing": "File was not found in book",
|
||||||
|
@ -200,8 +202,19 @@
|
||||||
"volume-num": "Volume {0}",
|
"volume-num": "Volume {0}",
|
||||||
"book-num": "Book {0}",
|
"book-num": "Book {0}",
|
||||||
"issue-num": "Issue {0}{1}",
|
"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"
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,7 +88,7 @@ public class Program
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply Before manual migrations that need to run before actual migrations
|
// Apply Before manual migrations that need to run before actual migrations
|
||||||
try
|
if (isDbCreated)
|
||||||
{
|
{
|
||||||
Task.Run(async () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
@ -96,17 +96,22 @@ public class Program
|
||||||
logger.LogInformation("Running Migrations");
|
logger.LogInformation("Running Migrations");
|
||||||
|
|
||||||
// v0.7.14
|
// v0.7.14
|
||||||
|
try
|
||||||
|
{
|
||||||
await MigrateWantToReadExport.Migrate(context, directoryService, logger);
|
await MigrateWantToReadExport.Migrate(context, directoryService, logger);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
/* Swallow */
|
||||||
|
}
|
||||||
|
|
||||||
await unitOfWork.CommitAsync();
|
await unitOfWork.CommitAsync();
|
||||||
logger.LogInformation("Running Migrations - complete");
|
logger.LogInformation("Running Migrations - complete");
|
||||||
}).GetAwaiter()
|
}).GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogCritical(ex, "An error occurred during migration");
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.Database.MigrateAsync();
|
await context.Database.MigrateAsync();
|
||||||
|
|
||||||
|
|
|
@ -353,7 +353,15 @@ public class ArchiveService : IArchiveService
|
||||||
{
|
{
|
||||||
var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name));
|
var tempPath = Path.Join(tempLocation, _directoryService.FileSystem.Path.GetFileNameWithoutExtension(_directoryService.FileSystem.FileInfo.New(path).Name));
|
||||||
progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count));
|
progressCallback(Tuple.Create(_directoryService.FileSystem.FileInfo.New(path).Name, (1.0f * totalFiles) / count));
|
||||||
|
if (Tasks.Scanner.Parser.Parser.IsArchive(path))
|
||||||
|
{
|
||||||
ExtractArchive(path, tempPath);
|
ExtractArchive(path, tempPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_directoryService.CopyFileToDirectory(path, tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -392,7 +400,7 @@ public class ArchiveService : IArchiveService
|
||||||
return false;
|
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);
|
_logger.LogWarning("Archive {ArchivePath} is not a valid archive", archivePath);
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -382,7 +382,7 @@ public class BookService : IBookService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link");
|
var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link[@href]");
|
||||||
if (styleNodes != null)
|
if (styleNodes != null)
|
||||||
{
|
{
|
||||||
foreach (var styleLinks in styleNodes)
|
foreach (var styleLinks in styleNodes)
|
||||||
|
@ -781,7 +781,7 @@ public class BookService : IBookService
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public ParserInfo? ParseInfo(string filePath)
|
public ParserInfo? ParseInfo(string filePath)
|
||||||
{
|
{
|
||||||
if (!Parser.IsEpub(filePath)) return null;
|
if (!Parser.IsEpub(filePath) || !_directoryService.FileSystem.File.Exists(filePath)) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
@ -848,7 +848,7 @@ public class BookService : IBookService
|
||||||
Format = MangaFormat.Epub,
|
Format = MangaFormat.Epub,
|
||||||
Filename = Path.GetFileName(filePath),
|
Filename = Path.GetFileName(filePath),
|
||||||
Title = specialName?.Trim() ?? string.Empty,
|
Title = specialName?.Trim() ?? string.Empty,
|
||||||
FullFilePath = filePath,
|
FullFilePath = Parser.NormalizePath(filePath),
|
||||||
IsSpecial = false,
|
IsSpecial = false,
|
||||||
Series = series.Trim(),
|
Series = series.Trim(),
|
||||||
SeriesSort = series.Trim(),
|
SeriesSort = series.Trim(),
|
||||||
|
@ -870,7 +870,7 @@ public class BookService : IBookService
|
||||||
Format = MangaFormat.Epub,
|
Format = MangaFormat.Epub,
|
||||||
Filename = Path.GetFileName(filePath),
|
Filename = Path.GetFileName(filePath),
|
||||||
Title = epubBook.Title.Trim(),
|
Title = epubBook.Title.Trim(),
|
||||||
FullFilePath = filePath,
|
FullFilePath = Parser.NormalizePath(filePath),
|
||||||
IsSpecial = false,
|
IsSpecial = false,
|
||||||
Series = epubBook.Title.Trim(),
|
Series = epubBook.Title.Trim(),
|
||||||
Volumes = Parser.LooseLeafVolume,
|
Volumes = Parser.LooseLeafVolume,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue