Merged develop in

This commit is contained in:
Joseph Milazzo 2024-04-07 14:13:06 -05:00
commit 5423526484
260 changed files with 15553 additions and 2369 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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
View 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=

View file

@ -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>

View file

@ -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);
} }

View file

@ -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(),
}; };

View file

@ -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 = "",

View file

@ -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));

View file

@ -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));

View file

@ -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
} }

View file

@ -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();
} }

View file

@ -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

View file

@ -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
} }

View file

@ -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);
} }
} }

View file

@ -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;

View file

@ -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);

View file

@ -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>

View file

@ -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);
} }

View file

@ -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();

View file

@ -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));
}
} }

View file

@ -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()));
}
} }

View file

@ -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))
{ {

View file

@ -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();

View file

@ -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();

View file

@ -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;

View file

@ -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()));
}
} }

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

@ -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));
} }
} }

View file

@ -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());
}
} }

View file

@ -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))
{ {

View file

@ -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;

View file

@ -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>

View 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; }
}

View file

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace API.DTOs.Collection;
public class DeleteCollectionsDto
{
public IList<int> CollectionIds { get; set; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View file

@ -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

View 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; }
}

View file

@ -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>

View 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; }
}

View file

@ -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!;

View file

@ -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>

View 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; }
}

View file

@ -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!;
} }

View file

@ -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;
} }

View file

@ -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

View 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);
}
}
}

View file

@ -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);
}
}
} }

View file

@ -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");
}
}

View 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");
}
}

View 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();
}
}

View file

@ -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;

File diff suppressed because it is too large Load diff

View 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");
}
}
}

File diff suppressed because it is too large Load diff

View 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");
}
}
}

File diff suppressed because it is too large Load diff

View 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");
}
}
}

View file

@ -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");

View file

@ -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)
{ {

View file

@ -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();
} }
} }

View file

@ -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>

View file

@ -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

View file

@ -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>

View 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; }
}

View file

@ -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; }
} }

View file

@ -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;
}
} }

View file

@ -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>

View file

@ -39,5 +39,4 @@ public enum LibraryType
/// </summary> /// </summary>
[Description("Generic")] [Description("Generic")]
Generic = 6, Generic = 6,
} }

View 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
}

View 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
}

View 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
}

View file

@ -0,0 +1,11 @@
using System.ComponentModel;
namespace API.Entities.Enums.UserPreferences;
public enum PdfTheme
{
[Description("Dark")]
Dark = 0,
[Description("Light")]
Light = 1
}

View file

@ -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>();

View file

@ -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;
}
} }

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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:

View file

@ -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),

View file

@ -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();
} }

View file

@ -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;

View file

@ -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(

View 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;
}
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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),

View file

@ -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;
}
} }

View file

@ -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)
{ {

View file

@ -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"
} }

View file

@ -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();

View file

@ -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;

View file

@ -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